Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

legal.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

legal.tsxBlame363 lines · 1 contributor
36b4cbdClaude1/**
54eab24Claude2 * Legal pages — Terms, Privacy, AUP served from markdown source.
3 *
4 * The body wraps the rendered markdown in a 2026-style hero card +
5 * table of contents + scoped section card. The TOC is parsed from the
6 * markdown's `<h2>`s with a tiny regex pass — no shared file touches.
7 *
8 * All CSS is scoped under `.legal-page-*` so it cannot bleed into the
9 * sub-routes under `/legal/terms` etc. (which render their own JSX).
36b4cbdClaude10 */
11
12import { Hono } from "hono";
13import { readFileSync } from "fs";
14import { join } from "path";
15import { Layout } from "../views/layout";
16import { renderMarkdown, markdownCss } from "../lib/markdown";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { html } from "hono/html";
20
21const legal = new Hono<AuthEnv>();
22
23legal.use("*", softAuth);
24
54eab24Claude25// ─── Scoped CSS ─────────────────────────────────────────────────────────────
26const legalStyles = `
27 .legal-page-wrap {
28 max-width: 880px;
29 margin: 0 auto;
30 padding: var(--space-6) var(--space-4);
31 }
32
33 /* ─── Hero ─── */
34 .legal-page-hero {
35 position: relative;
36 margin-bottom: var(--space-5);
37 padding: var(--space-5) var(--space-6);
38 background: var(--bg-elevated);
39 border: 1px solid var(--border);
40 border-radius: 16px;
41 overflow: hidden;
42 }
43 .legal-page-hero::before {
44 content: '';
45 position: absolute;
46 top: 0; left: 0; right: 0;
47 height: 2px;
48 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
49 opacity: 0.78;
50 pointer-events: none;
51 }
52 .legal-page-hero-orb {
53 position: absolute;
54 inset: -22% -10% auto auto;
55 width: 380px; height: 380px;
56 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
57 filter: blur(80px);
58 opacity: 0.7;
59 pointer-events: none;
60 z-index: 0;
61 }
62 .legal-page-hero-inner { position: relative; z-index: 1; max-width: 720px; }
63 .legal-page-eyebrow {
64 display: inline-flex;
65 align-items: center;
66 gap: 8px;
67 font-family: var(--font-mono);
68 font-size: 11.5px;
69 text-transform: uppercase;
70 letter-spacing: 0.14em;
71 color: var(--text-muted);
72 font-weight: 600;
73 margin-bottom: 14px;
74 }
75 .legal-page-eyebrow-pill {
76 display: inline-flex;
77 align-items: center;
78 justify-content: center;
79 width: 18px; height: 18px;
80 border-radius: 6px;
81 background: rgba(140,109,255,0.14);
82 color: #b69dff;
83 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
84 }
85 .legal-page-title {
86 font-size: clamp(28px, 4vw, 40px);
87 font-family: var(--font-display);
88 font-weight: 800;
89 letter-spacing: -0.028em;
90 line-height: 1.05;
91 margin: 0 0 var(--space-2);
92 color: var(--text-strong);
93 }
94 .legal-page-title-grad {
95 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
96 -webkit-background-clip: text;
97 background-clip: text;
98 -webkit-text-fill-color: transparent;
99 color: transparent;
100 }
101 .legal-page-sub {
102 font-size: 15px;
103 color: var(--text-muted);
104 margin: 0;
105 line-height: 1.55;
106 }
107
108 /* ─── Table of contents ─── */
109 .legal-page-toc {
110 position: relative;
111 margin-bottom: var(--space-5);
112 padding: var(--space-4) var(--space-5);
113 background: var(--bg-elevated);
114 border: 1px solid var(--border);
115 border-radius: 14px;
116 overflow: hidden;
117 }
118 .legal-page-toc::before {
119 content: '';
120 position: absolute;
121 left: 0; top: 0; bottom: 0;
122 width: 3px;
123 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
124 }
125 .legal-page-toc-label {
126 font-family: var(--font-mono);
127 font-size: 11px;
128 text-transform: uppercase;
129 letter-spacing: 0.12em;
130 color: var(--accent);
131 margin-bottom: var(--space-3);
132 font-weight: 600;
133 }
134 .legal-page-toc-list {
135 list-style: none;
136 margin: 0;
137 padding: 0;
138 columns: 2;
139 column-gap: 28px;
140 }
141 @media (max-width: 720px) {
142 .legal-page-toc-list { columns: 1; }
143 }
144 .legal-page-toc-list li {
145 margin-bottom: 6px;
146 font-family: var(--font-mono);
147 font-size: 12.5px;
148 break-inside: avoid;
149 }
150 .legal-page-toc-list a {
151 color: var(--text);
152 text-decoration: none;
153 transition: color 120ms ease;
154 display: inline-block;
155 }
156 .legal-page-toc-list a:hover { color: var(--accent); text-decoration: underline; }
157 .legal-page-toc-list .num {
158 color: var(--text-faint);
159 margin-right: 8px;
160 font-variant-numeric: tabular-nums;
161 }
162
163 /* ─── Body card ─── */
164 .legal-page-body {
165 padding: var(--space-6) clamp(var(--space-4), 3vw, var(--space-6));
166 background: var(--bg-elevated);
167 border: 1px solid var(--border);
168 border-radius: 14px;
169 }
170 .legal-page-body .markdown-body { background: transparent; }
171 .legal-page-body .markdown-body h1:first-child { display: none; }
172 .legal-page-body .markdown-body h2 {
173 scroll-margin-top: 72px;
174 }
175
176 .legal-page-foot {
177 margin-top: var(--space-5);
178 padding: var(--space-4);
179 text-align: center;
180 color: var(--text-muted);
181 font-size: 13px;
182 border: 1px dashed var(--border);
183 border-radius: 12px;
184 }
185 .legal-page-foot a {
186 color: var(--accent);
187 text-decoration: none;
188 font-weight: 600;
189 }
190 .legal-page-foot a:hover { text-decoration: underline; }
191`;
192
193/**
194 * Build a deterministic slug for a heading. Matches the post-process pass
195 * below so the TOC anchors hit their targets.
196 */
197function slugify(text: string): string {
198 return text
199 .toLowerCase()
200 .replace(/<[^>]+>/g, "")
201 .replace(/&[a-z]+;/g, "")
202 .replace(/[^\w\s-]/g, "")
203 .trim()
204 .replace(/\s+/g, "-");
205}
206
207/**
208 * Pull `## Heading` lines from the raw markdown and synth a TOC. Returns an
209 * empty list if no h2s are found, in which case the renderer skips the TOC
210 * card entirely.
211 */
212function extractToc(markdown: string): Array<{ id: string; text: string }> {
213 const out: Array<{ id: string; text: string }> = [];
214 const seen = new Map<string, number>();
215 const re = /^##\s+(.+?)\s*$/gm;
216 let m: RegExpExecArray | null;
217 while ((m = re.exec(markdown)) !== null) {
218 const text = m[1]!.trim();
219 let id = slugify(text);
220 const n = seen.get(id) ?? 0;
221 seen.set(id, n + 1);
222 if (n > 0) id = `${id}-${n}`;
223 out.push({ id, text });
224 }
225 return out;
226}
227
228/**
229 * Inject `id="..."` into rendered `<h2>` tags so the TOC anchors land.
230 * marked doesn't add heading ids out of the box and we don't want to touch
231 * the shared `lib/markdown.ts` for a single page.
232 */
233function addHeadingIds(htmlString: string): string {
234 const seen = new Map<string, number>();
235 return htmlString.replace(/<h2>([^<]+)<\/h2>/g, (_full, inner) => {
236 let id = slugify(inner);
237 const n = seen.get(id) ?? 0;
238 seen.set(id, n + 1);
239 if (n > 0) id = `${id}-${n}`;
240 return `<h2 id="${id}">${inner}</h2>`;
241 });
242}
243
244function getPageMeta(slug: string): { eyebrow: string; title: string; grad: string; sub: string } {
245 switch (slug) {
246 case "terms":
247 return {
248 eyebrow: "Legal · Terms of Service",
249 title: "The rules we both",
250 grad: "agree to.",
251 sub: "Last updated April 2026. Plain-English where we can, lawyer-English where we must.",
252 };
253 case "privacy":
254 return {
255 eyebrow: "Legal · Privacy Policy",
256 title: "What we collect, why,",
257 grad: "and how to make us stop.",
258 sub: "We collect the minimum we need to run the platform. You can export or delete your data at any time.",
259 };
260 case "acceptable-use":
261 return {
262 eyebrow: "Legal · Acceptable Use",
263 title: "What you can",
264 grad: "build here.",
265 sub: "Short list: almost anything. Don't host malware, don't dox people, don't break the law.",
266 };
267 default:
268 return {
269 eyebrow: "Legal",
270 title: "Policy",
271 grad: "document.",
272 sub: "",
273 };
274 }
275}
276
277function serveLegalPage(title: string, slug: string, filename: string) {
36b4cbdClaude278 return async (c: any) => {
279 const user = c.get("user");
280 let content: string;
281 try {
282 content = readFileSync(
283 join(process.cwd(), "legal", filename),
284 "utf-8"
285 );
286 } catch {
287 content = `# ${title}\n\nThis page is being prepared. Check back soon.`;
288 }
289
54eab24Claude290 const rendered = addHeadingIds(renderMarkdown(content));
291 const toc = extractToc(content);
292 const meta = getPageMeta(slug);
36b4cbdClaude293
294 return c.html(
295 <Layout title={title} user={user}>
296 <style>{markdownCss}</style>
54eab24Claude297 <style dangerouslySetInnerHTML={{ __html: legalStyles }} />
298 <div class={`legal-page-wrap legal-page-${slug}`}>
299 {/* ─── Hero ─── */}
300 <section class="legal-page-hero">
301 <div class="legal-page-hero-orb" aria-hidden="true" />
302 <div class="legal-page-hero-inner">
303 <div class="legal-page-eyebrow">
304 <span class="legal-page-eyebrow-pill" aria-hidden="true">
305 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
306 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
307 <polyline points="14 2 14 8 20 8" />
308 </svg>
309 </span>
310 {meta.eyebrow}
311 </div>
312 <h1 class="legal-page-title">
313 {meta.title}{" "}
314 <span class="legal-page-title-grad">{meta.grad}</span>
315 </h1>
316 <p class="legal-page-sub">{meta.sub}</p>
317 </div>
318 </section>
319
320 {/* ─── Table of contents ─── */}
321 {toc.length > 0 && (
322 <nav class="legal-page-toc" aria-label="Table of contents">
323 <div class="legal-page-toc-label">Contents</div>
324 <ol class="legal-page-toc-list">
325 {toc.map((item, i) => (
326 <li>
327 <a href={`#${item.id}`}>
328 <span class="num">
329 {String(i + 1).padStart(2, "0")}
330 </span>
331 {item.text}
332 </a>
333 </li>
334 ))}
335 </ol>
336 </nav>
337 )}
338
339 {/* ─── Body ─── */}
340 <article class="legal-page-body">
341 <div class="markdown-body">
342 {html([rendered] as unknown as TemplateStringsArray)}
343 </div>
344 </article>
345
346 <div class="legal-page-foot">
347 Questions? Email{" "}
348 <a href="mailto:legal@gluecron.com">legal@gluecron.com</a>{" "}
349 · Other policies:{" "}
350 <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> ·{" "}
351 <a href="/acceptable-use">AUP</a>
352 </div>
36b4cbdClaude353 </div>
354 </Layout>
355 );
356 };
357}
358
54eab24Claude359legal.get("/terms", serveLegalPage("Terms of Service", "terms", "TERMS.md"));
360legal.get("/privacy", serveLegalPage("Privacy Policy", "privacy", "PRIVACY.md"));
361legal.get("/acceptable-use", serveLegalPage("Acceptable Use Policy", "acceptable-use", "AUP.md"));
36b4cbdClaude362
363export default legal;