this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add site-wide theme settings

+586 -64
+1 -1
app/(creator)/dashboard/page.tsx
··· 13 13 14 14 return forms.length === 0 ? ( 15 15 <div className="space-y-8"> 16 - <section className="flex flex-col gap-4 border-b border-black/8 pb-6 lg:flex-row lg:items-end lg:justify-between"> 16 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 17 17 <div className="space-y-2"> 18 18 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Dashboard</p> 19 19 <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">Your forms</h1>
+4 -4
app/(creator)/forms/[id]/responses/[responseId]/page.tsx
··· 31 31 32 32 return ( 33 33 <div className="space-y-6"> 34 - <section className="flex flex-col gap-4 border-b border-black/8 pb-5 lg:flex-row lg:items-end lg:justify-between"> 34 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 35 35 <div> 36 36 <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--accent)]">Submission replay</p> 37 37 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Submission #{response.submissionNumber} · {formatDate(response.submittedAt)}</h1> ··· 61 61 <Card key={block.id} className="p-6"> 62 62 <div className="flex flex-wrap items-center gap-3"> 63 63 <Badge>{block.type.replaceAll("_", " ")}</Badge> 64 - {block.required ? <Badge className="bg-amber-100 text-amber-700">Required</Badge> : null} 64 + {block.required ? <Badge className="bg-[var(--accent-soft)] text-[var(--accent-ink)]">Required</Badge> : null} 65 65 </div> 66 66 <h2 className="mt-4 font-display text-3xl text-[var(--ink)]">{block.title}</h2> 67 67 {block.description ? <p className="mt-3 text-sm leading-7 text-[var(--muted)]">{block.description}</p> : null} 68 - <div className="mt-6 rounded-[18px] border border-black/8 bg-[var(--bg-strong)] px-5 py-4 text-[var(--ink)]"> 68 + <div className="mt-6 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-5 py-4 text-[var(--ink)]"> 69 69 {Array.isArray(answer) ? ( 70 70 <div className="flex flex-wrap gap-2"> 71 71 {answer.map((value) => ( 72 - <Badge key={value} className="bg-white text-[var(--ink)]"> 72 + <Badge key={value} className="bg-[var(--surface-strong)] text-[var(--ink)]"> 73 73 {value} 74 74 </Badge> 75 75 ))}
+2 -2
app/(creator)/forms/[id]/responses/page.tsx
··· 32 32 33 33 return ( 34 34 <div className="space-y-5"> 35 - <section className="flex flex-col gap-4 border-b border-black/8 pb-5 lg:flex-row lg:items-end lg:justify-between"> 35 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 36 36 <div> 37 37 <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--accent)]">Responses</p> 38 38 <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">{form.title}</h1> ··· 59 59 /> 60 60 ) : ( 61 61 <Card className="overflow-hidden p-0"> 62 - <div className="divide-y divide-black/8"> 62 + <div className="divide-y divide-[color:var(--line)]"> 63 63 {responses.map((response) => ( 64 64 <div key={response.id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> 65 65 <div className="min-w-0">
+4 -1
app/(creator)/layout.tsx
··· 24 24 25 25 return ( 26 26 <main className="mx-auto min-h-screen w-full max-w-7xl px-6 py-8 lg:px-10"> 27 - <header className="mb-8 flex flex-col gap-4 border-b border-black/8 pb-5 lg:flex-row lg:items-center lg:justify-between"> 27 + <header className="mb-8 flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-center lg:justify-between"> 28 28 <div className="flex items-center gap-4"> 29 29 <Link 30 30 href="/dashboard" ··· 40 40 <div className="flex items-center gap-5"> 41 41 <Link href="/dashboard" className="text-sm font-medium text-[var(--muted)] transition hover:text-[var(--ink)]"> 42 42 Dashboard 43 + </Link> 44 + <Link href="/settings" className="text-sm font-medium text-[var(--muted)] transition hover:text-[var(--ink)]"> 45 + Settings 43 46 </Link> 44 47 <SignOutButton /> 45 48 </div>
+5
app/(creator)/settings/page.tsx
··· 1 + import { ThemeSettingsPanel } from "@/components/theme-settings-panel"; 2 + 3 + export default function SettingsPage() { 4 + return <ThemeSettingsPanel />; 5 + }
+33 -2
app/globals.css
··· 2 2 3 3 :root { 4 4 --bg: #f5f5f1; 5 + --bg-start: #f8f8f4; 6 + --bg-end: #f2f1eb; 5 7 --bg-strong: #ecece6; 6 - --surface: rgba(255, 255, 255, 0.88); 8 + --surface: rgba(255, 255, 255, 0.85); 7 9 --surface-strong: rgba(255, 255, 255, 0.96); 8 10 --ink: #171717; 9 11 --muted: #5f5f5a; 10 12 --accent: #4f7a58; 11 13 --accent-soft: #dbe6dd; 14 + --accent-ink: #2f5d35; 15 + --danger-soft: rgba(190, 24, 93, 0.08); 16 + --danger-ink: #be123c; 17 + --danger-line: rgba(190, 24, 93, 0.24); 12 18 --line: rgba(23, 23, 23, 0.08); 19 + --line-strong: rgba(23, 23, 23, 0.14); 20 + } 21 + 22 + :root[data-theme="dark"] { 23 + --bg: #141512; 24 + --bg-start: #171915; 25 + --bg-end: #10110f; 26 + --bg-strong: #1d1f1b; 27 + --surface: rgba(28, 30, 27, 0.88); 28 + --surface-strong: rgba(34, 36, 32, 0.98); 29 + --ink: #f2f1eb; 30 + --muted: #b4b2aa; 31 + --accent: #8db896; 32 + --accent-soft: rgba(141, 184, 150, 0.16); 33 + --accent-ink: #d7ecd9; 34 + --danger-soft: rgba(190, 24, 93, 0.16); 35 + --danger-ink: #ffb3c7; 36 + --danger-line: rgba(255, 179, 199, 0.28); 37 + --line: rgba(255, 255, 255, 0.1); 38 + --line-strong: rgba(255, 255, 255, 0.18); 39 + color-scheme: dark; 40 + } 41 + 42 + :root[data-theme="light"] { 43 + color-scheme: light; 13 44 } 14 45 15 46 @theme inline { ··· 25 56 26 57 body { 27 58 min-height: 100vh; 28 - background: linear-gradient(180deg, #f8f8f4 0%, #f2f1eb 100%); 59 + background: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%); 29 60 color: var(--ink); 30 61 font-family: var(--font-manrope), sans-serif; 31 62 }
+10 -2
app/layout.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 import { Kurale, Manrope } from "next/font/google"; 3 + 4 + import { ThemeProvider } from "@/components/theme-provider"; 5 + import { themeInitScript } from "@/lib/theme"; 3 6 import "./globals.css"; 4 7 5 8 const kurale = Kurale({ ··· 24 27 children: React.ReactNode; 25 28 }>) { 26 29 return ( 27 - <html lang="en"> 28 - <body className={`${kurale.variable} ${manrope.variable}`}>{children}</body> 30 + <html lang="en" suppressHydrationWarning> 31 + <head> 32 + <script dangerouslySetInnerHTML={{ __html: themeInitScript }} /> 33 + </head> 34 + <body className={`${kurale.variable} ${manrope.variable}`}> 35 + <ThemeProvider>{children}</ThemeProvider> 36 + </body> 29 37 </html> 30 38 ); 31 39 }
+9 -9
components/dashboard-form-browser.tsx
··· 105 105 106 106 return ( 107 107 <div className="space-y-8"> 108 - <section className="flex flex-col gap-4 border-b border-black/8 pb-6 lg:flex-row lg:items-end lg:justify-between"> 108 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 109 109 <div className="space-y-2"> 110 110 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Dashboard</p> 111 111 <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">Your forms</h1> 112 112 <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]">Create a form, open a draft, or review responses.</p> 113 113 </div> 114 114 <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> 115 - <div className="inline-flex h-12 items-center gap-1 rounded-xl border border-black/8 bg-white/70 p-1"> 115 + <div className="inline-flex h-12 items-center gap-1 rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] p-1"> 116 116 <button 117 117 type="button" 118 118 onClick={() => setDashboardView("grid")} 119 119 className={cn( 120 120 "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 121 - view === "grid" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-black/5 hover:text-[var(--ink)]", 121 + view === "grid" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 122 122 )} 123 123 aria-pressed={view === "grid"} 124 124 > ··· 130 130 onClick={() => setDashboardView("table")} 131 131 className={cn( 132 132 "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 133 - view === "table" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-black/5 hover:text-[var(--ink)]", 133 + view === "table" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 134 134 )} 135 135 aria-pressed={view === "table"} 136 136 > ··· 155 155 <div> 156 156 <div className="flex flex-wrap items-center gap-3"> 157 157 <h2 className="font-display text-3xl text-[var(--ink)]">{form.title}</h2> 158 - <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[#2f5d35]" : "rounded-full bg-black/5"}> 158 + <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "rounded-full"}> 159 159 {form.status === "PUBLISHED" ? "Published" : "Draft"} 160 160 </Badge> 161 161 </div> ··· 187 187 ) : ( 188 188 <Card className="overflow-hidden p-0"> 189 189 <div className="overflow-x-auto"> 190 - <table className="min-w-full divide-y divide-black/8 text-left"> 191 - <thead className="bg-[#e6e5dd] border-b border-black/8 shadow-[inset_0_-1px_0_rgba(23,23,23,0.06)]"> 190 + <table className="min-w-full divide-y divide-[color:var(--line)] text-left"> 191 + <thead className="bg-[var(--bg-strong)] border-b border-[color:var(--line)] shadow-[inset_0_-1px_0_rgba(23,23,23,0.06)]"> 192 192 <tr className="text-xs uppercase tracking-[0.2em] text-[var(--muted)]"> 193 193 <th className="px-5 py-4"> 194 194 <button type="button" onClick={() => handleSort("title")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]"> ··· 216 216 </th> 217 217 </tr> 218 218 </thead> 219 - <tbody className="divide-y divide-black/8 bg-white/70 text-sm text-[var(--ink)]"> 219 + <tbody className="divide-y divide-[color:var(--line)] bg-[var(--surface-strong)] text-sm text-[var(--ink)]"> 220 220 {sortedForms.map((form) => ( 221 221 <tr key={form.id} className="align-top"> 222 222 <td className="px-5 py-4"> ··· 228 228 </div> 229 229 </td> 230 230 <td className="px-5 py-4"> 231 - <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[#2f5d35]" : "rounded-full bg-black/5"}> 231 + <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "rounded-full"}> 232 232 {form.status === "PUBLISHED" ? "Published" : "Draft"} 233 233 </Badge> 234 234 </td>
+20 -16
components/form-builder.tsx
··· 156 156 <Icon className="size-3.5 shrink-0" /> 157 157 <span className="truncate text-[10px] font-semibold uppercase tracking-[0.18em]">{blockLabels[block.type]}</span> 158 158 </div> 159 - <span className="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--bg-strong)] text-[10px] font-semibold text-[var(--ink)]"> 159 + <span className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-[color:var(--line-strong)] bg-[var(--accent-soft)] text-[10px] font-semibold text-[var(--accent-ink)]"> 160 160 {block.position + 1} 161 161 </span> 162 162 </div> ··· 186 186 }} 187 187 className={cn( 188 188 "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 189 - selected ? "border-[var(--accent)] bg-white shadow-[0_18px_44px_rgba(15,23,42,0.08)]" : "border-black/8 bg-white/65 hover:bg-white", 189 + selected 190 + ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]" 191 + : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 190 192 )} 191 193 > 192 194 <BlockRowInner ··· 194 196 onSelect={onSelect} 195 197 dragHandle={ 196 198 <button 197 - className="rounded-full border border-black/8 p-1.5 text-[var(--muted)] transition hover:bg-black/5" 199 + className="rounded-full border border-[color:var(--line)] p-1.5 text-[var(--muted)] transition hover:bg-[var(--accent-soft)]" 198 200 type="button" 199 201 {...attributes} 200 202 {...listeners} ··· 220 222 <div 221 223 className={cn( 222 224 "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 223 - selected ? "border-[var(--accent)] bg-white shadow-[0_18px_44px_rgba(15,23,42,0.08)]" : "border-black/8 bg-white/65 hover:bg-white", 225 + selected 226 + ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]" 227 + : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 224 228 )} 225 229 > 226 230 <BlockRowInner 227 231 block={block} 228 232 onSelect={onSelect} 229 233 dragHandle={ 230 - <div className="rounded-full border border-black/8 p-1.5 text-[var(--muted)]"> 234 + <div className="rounded-full border border-[color:var(--line)] p-1.5 text-[var(--muted)]"> 231 235 <GripVertical className="size-3.5" /> 232 236 </div> 233 237 } ··· 471 475 <> 472 476 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 473 477 <div className="space-y-6"> 474 - <section className="flex flex-col gap-4 border-b border-black/8 pb-5 lg:flex-row lg:items-end lg:justify-between"> 478 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 475 479 <div> 476 480 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Builder</p> 477 481 <div className="mt-3 flex flex-wrap items-center gap-3"> 478 482 <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 479 - <Badge className={cn("rounded-full", form.status === "PUBLISHED" && "bg-[var(--accent-soft)] text-[#2f5d35]")}>{form.status}</Badge> 483 + <Badge className={cn("rounded-full", form.status === "PUBLISHED" && "bg-[var(--accent-soft)] text-[var(--accent-ink)]")}>{form.status}</Badge> 480 484 </div> 481 485 <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Edit blocks, update settings, and review responses.</p> 482 486 </div> ··· 534 538 )} 535 539 </div> 536 540 537 - <div className="mt-6 space-y-3 border-t border-black/8 pt-6"> 541 + <div className="mt-6 space-y-3 border-t border-[color:var(--line)] pt-6"> 538 542 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Add block</p> 539 543 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"> 540 544 {blockCreationOrder.map((type) => { ··· 586 590 </label> 587 591 </div> 588 592 589 - <div className="space-y-5 border-t border-black/8 pt-6"> 593 + <div className="space-y-5 border-t border-[color:var(--line)] pt-6"> 590 594 <div> 591 595 <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">After submission</p> 592 596 <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> ··· 630 634 </div> 631 635 </div> 632 636 633 - <div className="grid gap-4 rounded-[20px] border border-black/8 bg-[var(--bg-strong)] p-5 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 637 + <div className="grid gap-4 rounded-[20px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-5 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 634 638 <div> 635 639 <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Public route</p> 636 640 <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> ··· 650 654 </Link> 651 655 </div> 652 656 653 - <div className="flex flex-wrap items-center justify-between gap-3 border-t border-black/8 pt-6"> 657 + <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 654 658 <Button variant="danger" onClick={deleteForm}> 655 659 {busy === "delete-form" ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 656 660 Delete form ··· 668 672 {SelectedBlockIcon ? <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> : null} 669 673 <h2 className="font-display text-4xl text-[var(--ink)]">{blockLabels[blockDraft.type]}</h2> 670 674 </div> 671 - <span className="inline-flex size-8 items-center justify-center rounded-full bg-[var(--bg-strong)] text-sm font-semibold text-[var(--ink)]"> 675 + <span className="inline-flex size-8 items-center justify-center rounded-full border border-[color:var(--line-strong)] bg-[var(--accent-soft)] text-sm font-semibold text-[var(--accent-ink)]"> 672 676 {blockDraft.position + 1} 673 677 </span> 674 678 </div> ··· 741 745 )} 742 746 743 747 {isQuestion(blockDraft.type) ? ( 744 - <label className="flex items-center gap-3 rounded-xl border border-black/8 bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 748 + <label className="flex items-center gap-3 rounded-xl border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 745 749 <input 746 750 checked={blockDraft.required} 747 - className="size-4 rounded border-black/15" 751 + className="size-4 rounded border-[color:var(--line-strong)]" 748 752 type="checkbox" 749 753 onChange={(event) => 750 754 setBlockDraft((current) => (current ? { ...current, required: event.target.checked } : current)) ··· 755 759 ) : null} 756 760 </div> 757 761 758 - <div className="flex flex-wrap items-center justify-between gap-3 border-t border-black/8 pt-6"> 762 + <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 759 763 <Button variant="danger" onClick={() => deleteBlock(blockDraft.id)}> 760 764 {busy === `delete-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 761 765 Delete block ··· 767 771 </div> 768 772 </div> 769 773 ) : ( 770 - <div className="flex min-h-[420px] items-center justify-center rounded-[20px] border border-dashed border-black/10 bg-[var(--bg-strong)] p-10 text-center"> 774 + <div className="flex min-h-[420px] items-center justify-center rounded-[20px] border border-dashed border-[color:var(--line)] bg-[var(--bg-strong)] p-10 text-center"> 771 775 <div> 772 776 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Nothing selected</p> 773 777 <h2 className="mt-4 font-display text-4xl text-[var(--ink)]">Select a block or open form settings.</h2>
+4 -4
components/loading-shell.tsx
··· 1 1 export function LoadingShell({ title }: { title: string }) { 2 2 return ( 3 3 <div className="space-y-6"> 4 - <div className="h-4 w-24 animate-pulse rounded-full bg-black/10" /> 5 - <div className="h-12 w-64 animate-pulse rounded-full bg-black/10" /> 4 + <div className="h-4 w-24 animate-pulse rounded-full bg-[var(--line)]" /> 5 + <div className="h-12 w-64 animate-pulse rounded-full bg-[var(--line)]" /> 6 6 <div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]"> 7 - <div className="h-[420px] animate-pulse rounded-[20px] bg-white/70 shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 8 - <div className="h-[420px] animate-pulse rounded-[20px] bg-white/70 shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 7 + <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 8 + <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 9 9 </div> 10 10 <p className="text-sm text-[var(--muted)]">Loading {title}…</p> 11 11 </div>
+12 -12
components/public-form-runner.tsx
··· 160 160 if (isComplete) { 161 161 return ( 162 162 <Card className="w-full max-w-3xl overflow-hidden"> 163 - <div className="border-b border-black/8 bg-[var(--accent-soft)]/55 px-6 py-6 sm:px-8"> 163 + <div className="border-b border-[color:var(--line)] bg-[var(--accent-soft)]/55 px-6 py-6 sm:px-8"> 164 164 <div className="flex items-center gap-4"> 165 165 <Image src="/sproute.png" alt="Lively Forms" width={44} height={44} priority className="size-11" /> 166 166 <div> ··· 178 178 href={completionLinkUrl} 179 179 target="_blank" 180 180 rel="noreferrer" 181 - className={buttonVariants({ className: "w-fit" })} 181 + className={buttonVariants({ variant: "default", className: "w-fit !bg-[var(--ink)] !text-[var(--bg)]" })} 182 182 > 183 183 {completionLinkLabel} 184 184 </Link> ··· 193 193 <> 194 194 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 195 195 <Card className="w-full max-w-5xl overflow-hidden"> 196 - <div className="border-b border-black/8 px-6 py-6 sm:px-8 sm:py-7"> 196 + <div className="border-b border-[color:var(--line)] px-6 py-6 sm:px-8 sm:py-7"> 197 197 <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> 198 198 <div className="min-w-0"> 199 199 <div className="flex items-center gap-4"> ··· 208 208 209 209 <div className="w-full max-w-sm shrink-0"> 210 210 <div className="flex items-center justify-between gap-3"> 211 - <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#2f5d35]"> 211 + <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-ink)]"> 212 212 Step {step + 1} of {form.blocks.length} 213 213 </span> 214 214 <span className="text-xs font-medium uppercase tracking-[0.2em] text-[var(--muted)]">{Math.round(progress)}%</span> ··· 248 248 <div className="flex flex-wrap items-center gap-3"> 249 249 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Question</p> 250 250 {currentBlock.required ? ( 251 - <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#2f5d35]"> 251 + <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-ink)]"> 252 252 Required 253 253 </span> 254 254 ) : null} ··· 269 269 ) : null} 270 270 {currentBlock.type === "SHORT_TEXT" ? ( 271 271 <Input 272 - className="h-14 border-black/10 bg-[var(--surface-strong)] text-base text-[var(--ink)] placeholder:text-[var(--muted)]/65" 272 + className="h-14 text-base placeholder:text-[var(--muted)]/65" 273 273 placeholder={(currentBlock.config as { placeholder: string }).placeholder} 274 274 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 275 275 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} ··· 278 278 279 279 {currentBlock.type === "LONG_TEXT" ? ( 280 280 <Textarea 281 - className="min-h-[180px] border-black/10 bg-[var(--surface-strong)] text-base text-[var(--ink)] placeholder:text-[var(--muted)]/65" 281 + className="min-h-[180px] text-base placeholder:text-[var(--muted)]/65" 282 282 placeholder={(currentBlock.config as { placeholder: string }).placeholder} 283 283 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 284 284 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} ··· 298 298 "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 299 299 selected 300 300 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 301 - : "border-black/8 bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-white", 301 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 302 302 )} 303 303 onClick={() => setAnswer(currentBlock.id, option)} 304 304 > 305 305 <div className="flex items-center gap-3"> 306 - <span className="inline-flex size-5 items-center justify-center rounded-full border border-black/12 bg-white/70"> 306 + <span className="inline-flex size-5 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface)]"> 307 307 {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Circle className="size-3.5 text-[var(--muted)]/55" />} 308 308 </span> 309 309 <span>{option}</span> ··· 329 329 "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 330 330 selected 331 331 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 332 - : "border-black/8 bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-white", 332 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 333 333 )} 334 334 onClick={() => { 335 335 const nextValues = selected ··· 339 339 }} 340 340 > 341 341 <div className="flex items-center gap-3"> 342 - <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-black/12 bg-white/70"> 342 + <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 343 343 {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Square className="size-3.5 text-[var(--muted)]/55" />} 344 344 </span> 345 345 <span>{option}</span> ··· 355 355 </motion.div> 356 356 </AnimatePresence> 357 357 358 - <div className="mt-8 flex items-center justify-between gap-3 border-t border-black/8 pt-6"> 358 + <div className="mt-8 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 359 359 <Button variant="secondary" onClick={handleBack} disabled={step === 0 || isSubmitting}> 360 360 <ArrowLeft className="size-4" /> 361 361 Back
+90
components/theme-provider.tsx
··· 1 + "use client"; 2 + 3 + import { createContext, useContext, useEffect, useMemo, useState } from "react"; 4 + 5 + import { 6 + THEME_STORAGE_KEY, 7 + applyThemePreference, 8 + persistThemePreference, 9 + readStoredThemePreference, 10 + resolveTheme, 11 + type ResolvedTheme, 12 + type ThemePreference, 13 + } from "@/lib/theme"; 14 + 15 + type ThemeContextValue = { 16 + preference: ThemePreference; 17 + resolvedTheme: ResolvedTheme; 18 + setPreference: (preference: ThemePreference) => void; 19 + }; 20 + 21 + const ThemeContext = createContext<ThemeContextValue | null>(null); 22 + 23 + export function ThemeProvider({ children }: { children: React.ReactNode }) { 24 + const [preference, setPreferenceState] = useState<ThemePreference>("system"); 25 + const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("light"); 26 + 27 + useEffect(() => { 28 + const nextPreference = readStoredThemePreference(); 29 + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 30 + 31 + setPreferenceState(nextPreference); 32 + setResolvedTheme(resolveTheme(nextPreference, mediaQuery.matches)); 33 + applyThemePreference(nextPreference); 34 + 35 + const handleSystemChange = (event: MediaQueryListEvent) => { 36 + const currentPreference = readStoredThemePreference(); 37 + 38 + if (currentPreference !== "system") { 39 + return; 40 + } 41 + 42 + const nextResolved = event.matches ? "dark" : "light"; 43 + document.documentElement.dataset.theme = nextResolved; 44 + setResolvedTheme(nextResolved); 45 + }; 46 + 47 + const handleStorage = (event: StorageEvent) => { 48 + if (event.key && event.key !== THEME_STORAGE_KEY) { 49 + return; 50 + } 51 + 52 + const next = readStoredThemePreference(); 53 + setPreferenceState(next); 54 + applyThemePreference(next); 55 + setResolvedTheme(resolveTheme(next, mediaQuery.matches)); 56 + }; 57 + 58 + mediaQuery.addEventListener("change", handleSystemChange); 59 + window.addEventListener("storage", handleStorage); 60 + 61 + return () => { 62 + mediaQuery.removeEventListener("change", handleSystemChange); 63 + window.removeEventListener("storage", handleStorage); 64 + }; 65 + }, []); 66 + 67 + function setPreference(nextPreference: ThemePreference) { 68 + setPreferenceState(nextPreference); 69 + persistThemePreference(nextPreference); 70 + applyThemePreference(nextPreference); 71 + setResolvedTheme(resolveTheme(nextPreference, window.matchMedia("(prefers-color-scheme: dark)").matches)); 72 + } 73 + 74 + const value = useMemo( 75 + () => ({ preference, resolvedTheme, setPreference }), 76 + [preference, resolvedTheme], 77 + ); 78 + 79 + return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 80 + } 81 + 82 + export function useThemePreference() { 83 + const value = useContext(ThemeContext); 84 + 85 + if (!value) { 86 + throw new Error("useThemePreference must be used inside ThemeProvider."); 87 + } 88 + 89 + return value; 90 + }
+91
components/theme-settings-panel.tsx
··· 1 + "use client"; 2 + 3 + import { LaptopMinimal, MoonStar, SunMedium } from "lucide-react"; 4 + 5 + import { Card } from "@/components/ui/card"; 6 + import { useThemePreference } from "@/components/theme-provider"; 7 + import { cn, sentenceCase } from "@/lib/utils"; 8 + import type { ThemePreference } from "@/lib/theme"; 9 + 10 + const options: Array<{ 11 + value: ThemePreference; 12 + title: string; 13 + description: string; 14 + icon: typeof SunMedium; 15 + }> = [ 16 + { 17 + value: "light", 18 + title: "Light", 19 + description: "Always use the light theme.", 20 + icon: SunMedium, 21 + }, 22 + { 23 + value: "dark", 24 + title: "Dark", 25 + description: "Always use the dark theme.", 26 + icon: MoonStar, 27 + }, 28 + { 29 + value: "system", 30 + title: "System", 31 + description: "Follow your device theme automatically.", 32 + icon: LaptopMinimal, 33 + }, 34 + ]; 35 + 36 + export function ThemeSettingsPanel() { 37 + const { preference, resolvedTheme, setPreference } = useThemePreference(); 38 + 39 + return ( 40 + <Card className="p-6 lg:p-8"> 41 + <div> 42 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Appearance</p> 43 + <h1 className="mt-3 font-display text-4xl text-[var(--ink)]">Theme settings</h1> 44 + <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 45 + Choose how Lively Forms looks across creator pages, public forms, and the home page. 46 + </p> 47 + </div> 48 + 49 + <div className="mt-8 grid gap-4 md:grid-cols-3"> 50 + {options.map((option) => { 51 + const Icon = option.icon; 52 + const selected = preference === option.value; 53 + 54 + return ( 55 + <button 56 + key={option.value} 57 + type="button" 58 + onClick={() => setPreference(option.value)} 59 + className={cn( 60 + "rounded-[18px] border p-5 text-left transition", 61 + selected 62 + ? "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[0_16px_40px_rgba(79,122,88,0.10)]" 63 + : "border-[color:var(--line)] bg-[var(--surface-strong)] hover:bg-[var(--surface)]", 64 + )} 65 + aria-pressed={selected} 66 + > 67 + <div className="flex items-center gap-3"> 68 + <span className="inline-flex size-10 items-center justify-center rounded-xl bg-[var(--bg-strong)] text-[var(--ink)]"> 69 + <Icon className="size-5" /> 70 + </span> 71 + <div> 72 + <p className="font-semibold text-[var(--ink)]">{option.title}</p> 73 + <p className="mt-1 text-sm text-[var(--muted)]">{option.description}</p> 74 + </div> 75 + </div> 76 + </button> 77 + ); 78 + })} 79 + </div> 80 + 81 + <div className="mt-8 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-5 py-4"> 82 + <p className="text-sm text-[var(--muted)]"> 83 + Selected mode: <span className="font-medium text-[var(--ink)]">{sentenceCase(preference)}</span> 84 + </p> 85 + <p className="mt-2 text-sm text-[var(--muted)]"> 86 + Active theme right now: <span className="font-medium text-[var(--ink)]">{sentenceCase(resolvedTheme)}</span> 87 + </p> 88 + </div> 89 + </Card> 90 + ); 91 + }
+1 -1
components/ui/badge.tsx
··· 6 6 return ( 7 7 <span 8 8 className={cn( 9 - "inline-flex items-center rounded-lg border border-black/8 bg-black/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--muted)]", 9 + "inline-flex items-center rounded-lg border border-[color:var(--line)] bg-[var(--bg-strong)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--muted)]", 10 10 className, 11 11 )} 12 12 {...props}
+3 -3
components/ui/button.tsx
··· 11 11 default: 12 12 "bg-[var(--ink)] px-5 py-3 text-[var(--bg)] shadow-[0_16px_40px_rgba(15,23,42,0.18)] hover:-translate-y-0.5 hover:shadow-[0_20px_48px_rgba(15,23,42,0.24)] focus-visible:ring-[var(--ink)]", 13 13 secondary: 14 - "bg-white/70 px-5 py-3 text-[var(--ink)] ring-1 ring-black/10 hover:bg-white focus-visible:ring-[var(--ink)]", 14 + "bg-[var(--surface-strong)] px-5 py-3 text-[var(--ink)] ring-1 ring-[color:var(--line)] hover:bg-[var(--surface)] focus-visible:ring-[var(--ink)]", 15 15 ghost: 16 - "px-3 py-2 text-[var(--muted)] hover:bg-black/5 hover:text-[var(--ink)] focus-visible:ring-[var(--ink)]", 16 + "px-3 py-2 text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)] focus-visible:ring-[var(--ink)]", 17 17 danger: 18 - "bg-[#f87171] px-5 py-3 text-white shadow-[0_12px_28px_rgba(248,113,113,0.28)] hover:bg-[#ef4444] focus-visible:ring-[#ef4444]", 18 + "bg-[var(--surface-strong)] px-5 py-3 text-[var(--muted)] ring-1 ring-[color:var(--line)] hover:bg-[#ef4444] hover:text-white hover:ring-[#ef4444] focus-visible:ring-[#ef4444]", 19 19 }, 20 20 size: { 21 21 default: "h-11",
+1 -1
components/ui/card.tsx
··· 6 6 return ( 7 7 <div 8 8 className={cn( 9 - "rounded-[20px] border border-black/8 bg-white/85 shadow-[0_22px_60px_rgba(15,23,42,0.08)] backdrop-blur-sm", 9 + "rounded-[20px] border border-[color:var(--line)] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)] backdrop-blur-sm", 10 10 className, 11 11 )} 12 12 {...props}
+1 -1
components/ui/input.tsx
··· 8 8 <input 9 9 ref={ref} 10 10 className={cn( 11 - "flex h-11 w-full rounded-xl border border-black/10 bg-white px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.5)] outline-none transition focus:border-black/20 focus:ring-2 focus:ring-black/5", 11 + "flex h-11 w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 12 12 className, 13 13 )} 14 14 {...props}
+1 -1
components/ui/textarea.tsx
··· 8 8 <textarea 9 9 ref={ref} 10 10 className={cn( 11 - "flex min-h-[120px] w-full rounded-xl border border-black/10 bg-white px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.5)] outline-none transition focus:border-black/20 focus:ring-2 focus:ring-black/5", 11 + "flex min-h-[120px] w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 12 12 className, 13 13 )} 14 14 {...props}
+7 -4
components/ui/toast.tsx
··· 38 38 exit={{ opacity: 0, x: 24, y: -8, scale: 0.98 }} 39 39 transition={{ duration: 0.18, ease: "easeOut" }} 40 40 className={cn( 41 - "pointer-events-auto flex items-start gap-3 rounded-xl border bg-white px-4 py-3 shadow-[0_18px_50px_rgba(15,23,42,0.12)]", 41 + "pointer-events-auto flex items-start gap-3 rounded-xl border bg-[var(--bg-strong)] px-4 py-3 shadow-[0_18px_50px_rgba(15,23,42,0.12)]", 42 42 toast.variant === "error" 43 - ? "border-rose-200 text-rose-700" 44 - : "border-[#cfe9d0] text-[#2f5d35]", 43 + ? "border-[color:var(--danger-line)] text-[var(--danger-ink)]" 44 + : "border-[color:var(--accent)] text-[var(--accent-ink)]", 45 45 )} 46 46 role="status" 47 47 aria-live="polite" ··· 51 51 <button 52 52 type="button" 53 53 onClick={() => onDismiss(toast.id)} 54 - className="rounded-full p-1 opacity-60 transition hover:bg-black/5 hover:opacity-100" 54 + className={cn( 55 + "rounded-full p-1 opacity-60 transition hover:opacity-100", 56 + toast.variant === "error" ? "hover:bg-[var(--danger-soft)]" : "hover:bg-[var(--accent-soft)]", 57 + )} 55 58 aria-label="Dismiss notification" 56 59 > 57 60 <X className="size-4" />
+64
lib/theme.ts
··· 1 + export const THEME_STORAGE_KEY = "lively-forms-theme-preference"; 2 + 3 + export const THEME_PREFERENCES = ["light", "dark", "system"] as const; 4 + 5 + export type ThemePreference = (typeof THEME_PREFERENCES)[number]; 6 + export type ResolvedTheme = "light" | "dark"; 7 + 8 + export function isThemePreference(value: unknown): value is ThemePreference { 9 + return typeof value === "string" && THEME_PREFERENCES.includes(value as ThemePreference); 10 + } 11 + 12 + export function resolveTheme(preference: ThemePreference, systemPrefersDark: boolean): ResolvedTheme { 13 + if (preference === "system") { 14 + return systemPrefersDark ? "dark" : "light"; 15 + } 16 + 17 + return preference; 18 + } 19 + 20 + export function applyThemePreference(preference: ThemePreference) { 21 + const resolved = resolveTheme(preference, window.matchMedia("(prefers-color-scheme: dark)").matches); 22 + const root = document.documentElement; 23 + 24 + root.dataset.themePreference = preference; 25 + root.dataset.theme = resolved; 26 + root.style.colorScheme = resolved; 27 + } 28 + 29 + export function readStoredThemePreference(): ThemePreference { 30 + try { 31 + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); 32 + return isThemePreference(stored) ? stored : "system"; 33 + } catch { 34 + return "system"; 35 + } 36 + } 37 + 38 + export function persistThemePreference(preference: ThemePreference) { 39 + try { 40 + window.localStorage.setItem(THEME_STORAGE_KEY, preference); 41 + } catch { 42 + // ignore persistence failures 43 + } 44 + } 45 + 46 + export const themeInitScript = `(() => { 47 + const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)}; 48 + const root = document.documentElement; 49 + const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 50 + 51 + let preference = "system"; 52 + 53 + try { 54 + const stored = window.localStorage.getItem(storageKey); 55 + if (stored === "light" || stored === "dark" || stored === "system") { 56 + preference = stored; 57 + } 58 + } catch {} 59 + 60 + const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference; 61 + root.dataset.themePreference = preference; 62 + root.dataset.theme = resolved; 63 + root.style.colorScheme = resolved; 64 + })();`;
+2
openspec/changes/archive/2026-04-09-site-dark-mode-settings/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-09
+87
openspec/changes/archive/2026-04-09-site-dark-mode-settings/design.md
··· 1 + ## Context 2 + 3 + The site currently uses one light visual theme across the home page, creator shell, dashboard, builder, response review, and public form runner. The requested change adds two related capabilities: automatic adaptation to OS theme preference and a creator-facing Settings page where the user can explicitly choose Light, Dark, or System. 4 + 5 + This is a cross-cutting UI change because it affects shared tokens, top-level layout behavior, creator navigation, and both authenticated and public-facing pages. It also needs to avoid a visible flash of the wrong theme during initial page load. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Support both light and dark themes site-wide. 11 + - Default to following the OS theme when no explicit preference is selected. 12 + - Add a Settings page in creator navigation with a three-way appearance control: Light, Dark, System. 13 + - Apply the selected preference consistently to creator pages, home page, and public forms. 14 + - Minimize theme flicker during initial render. 15 + 16 + **Non-Goals:** 17 + - Syncing theme preference to the database or across all devices. 18 + - Per-form or per-page custom theme palettes. 19 + - Multiple settings sections beyond appearance. 20 + - Rebuilding every component with a unique dark-mode design language; the first pass should adapt the current design system coherently. 21 + 22 + ## Decisions 23 + 24 + ### Store the explicit preference client-side and derive the active theme at runtime 25 + Use a client-side persisted preference with values `light`, `dark`, or `system`, and compute the active theme from that setting plus `prefers-color-scheme`. 26 + 27 + **Why this approach:** 28 + - Matches the requested Light / Dark / System behavior. 29 + - Avoids schema and account-model changes for an initial version. 30 + - Lets the theme apply to public pages in the same browser without authentication. 31 + 32 + **Alternatives considered:** 33 + - Persist preference in the user record: stronger cross-device consistency, but adds backend and migration scope not required for the initial feature. 34 + - Support only automatic OS detection with no settings page: does not satisfy the requirement for explicit user control. 35 + 36 + ### Apply theme through shared CSS variables at the document level 37 + Use the existing tokenized color system in `app/globals.css` and switch token values through a top-level theme class or data attribute. 38 + 39 + **Why this approach:** 40 + - Most of the UI already references shared variables like `--bg`, `--ink`, and `--accent`. 41 + - Allows broad theme coverage without rewriting each component individually. 42 + - Keeps the visual language aligned between creator and public surfaces. 43 + 44 + **Alternatives considered:** 45 + - Add dark-mode utility classes component by component: harder to maintain and more error-prone for a site-wide theme. 46 + 47 + ### Set theme early during page boot to reduce flash 48 + Add a lightweight early theme initialization path so the chosen preference is applied before the main UI paints. 49 + 50 + **Why this approach:** 51 + - Prevents a distracting flash from light to dark or dark to light. 52 + - Important because the theme affects the entire shell and public runner. 53 + 54 + **Alternatives considered:** 55 + - Apply theme only after client hydration: simpler, but more likely to produce visible flicker. 56 + 57 + ### Add Settings as a creator page in the existing authenticated shell 58 + The Settings page should live alongside Dashboard in the creator navigation and initially expose only the appearance control. 59 + 60 + **Why this approach:** 61 + - Matches the requested IA change. 62 + - Keeps settings discoverable in the same shell used for creator management. 63 + - Leaves room for future settings without changing navigation again. 64 + 65 + **Alternatives considered:** 66 + - Put appearance controls in the dashboard header: faster, but less scalable and less aligned with the explicit request for a Settings page. 67 + 68 + ## Risks / Trade-offs 69 + 70 + - **Client-side persistence does not sync across devices** → Mitigation: acceptable for the first version; document this as a non-goal and revisit later if needed. 71 + - **Some components may still use hard-coded light colors** → Mitigation: audit creator and public surfaces and move remaining values onto shared theme-aware tokens where needed. 72 + - **Theme flicker on first load** → Mitigation: initialize theme before main UI paint and keep the algorithm simple. 73 + - **System mode behavior can be confusing if OS theme changes while the page is open** → Mitigation: listen to `prefers-color-scheme` changes when the selected preference is `system`. 74 + 75 + ## Migration Plan 76 + 77 + 1. Introduce a shared theme preference model (`light`, `dark`, `system`) and early theme application logic. 78 + 2. Extend global CSS variables to define both light and dark token sets. 79 + 3. Add authenticated Settings navigation and build the Settings page with the appearance switch. 80 + 4. Update creator and public surfaces to rely on theme-aware tokens instead of fixed light-only colors where necessary. 81 + 5. Verify OS detection, explicit override behavior, and page-to-page consistency. 82 + 83 + ## Open Questions 84 + 85 + - Should the theme preference also be reflected in the URL or remain fully local to the browser? 86 + - Do we want the settings control presented as segmented buttons, radio cards, or a select-style control? 87 + - Should future account-level persistence replace or complement browser-local persistence?
+25
openspec/changes/archive/2026-04-09-site-dark-mode-settings/proposal.md
··· 1 + ## Why 2 + 3 + The product currently uses a single light theme, which can feel out of place for users whose devices are already set to dark mode. Adding site-wide theme support and a simple appearance setting will make the experience feel more native and give creators explicit control over light, dark, or system behavior. 4 + 5 + ## What Changes 6 + 7 + - Add site-wide dark mode support across creator and public form surfaces. 8 + - Detect the user’s OS color scheme and use it automatically when no explicit preference is selected. 9 + - Add a creator Settings page linked next to Dashboard in the authenticated navigation. 10 + - Add an appearance control with three options: Light, Dark, and System (default). 11 + - Apply the selected theme consistently across the full site, including the home page, creator pages, and public form runner. 12 + 13 + ## Capabilities 14 + 15 + ### New Capabilities 16 + - `site-theme-preferences`: site-wide theme support with system detection and a creator-accessible appearance setting for light, dark, or system mode. 17 + 18 + ### Modified Capabilities 19 + - None. 20 + 21 + ## Impact 22 + 23 + - Affected specs: `site-theme-preferences` 24 + - Affected code: global layout, global styling variables, authenticated navigation, new settings page, theme preference persistence and hydration logic, public form and creator surfaces 25 + - Affected APIs: none required if theme preference is stored client-side for now
+46
openspec/changes/archive/2026-04-09-site-dark-mode-settings/specs/site-theme-preferences/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Site supports light, dark, and system theme modes 4 + The system SHALL support light and dark themes across site surfaces and SHALL use the system color scheme when the active appearance preference is `system`. 5 + 6 + #### Scenario: Visitor has no explicit theme preference 7 + - **WHEN** a visitor opens the site without a saved appearance preference 8 + - **THEN** the system uses the device's current color scheme 9 + 10 + #### Scenario: User selects dark mode 11 + - **WHEN** a user selects the dark appearance mode 12 + - **THEN** the system applies the dark theme across the site 13 + 14 + #### Scenario: User selects light mode 15 + - **WHEN** a user selects the light appearance mode 16 + - **THEN** the system applies the light theme across the site 17 + 18 + #### Scenario: User selects system mode 19 + - **WHEN** a user selects the system appearance mode 20 + - **THEN** the system follows the device color scheme instead of forcing light or dark 21 + 22 + ### Requirement: Creator can manage appearance from a settings page 23 + The system SHALL provide an authenticated creator Settings page in creator navigation and SHALL allow the creator to choose Light, Dark, or System appearance mode from that page. 24 + 25 + #### Scenario: Creator opens settings 26 + - **WHEN** an authenticated creator opens the Settings page 27 + - **THEN** the system shows an appearance control with Light, Dark, and System options 28 + 29 + #### Scenario: Creator updates appearance preference 30 + - **WHEN** an authenticated creator changes the appearance setting in the Settings page 31 + - **THEN** the system applies the selected mode across the site 32 + 33 + #### Scenario: Unauthenticated user opens creator settings 34 + - **WHEN** an unauthenticated user attempts to open the creator Settings page 35 + - **THEN** the system requires authentication before showing settings 36 + 37 + ### Requirement: Theme choice persists for the current browser 38 + The system SHALL remember the selected appearance preference for the current browser so it continues to apply across page reloads and navigation. 39 + 40 + #### Scenario: User navigates after changing theme 41 + - **WHEN** a user changes the appearance preference and opens another page in the same browser 42 + - **THEN** the system keeps the selected appearance mode active 43 + 44 + #### Scenario: User reloads after changing theme 45 + - **WHEN** a user reloads the site after selecting an appearance preference 46 + - **THEN** the system restores the same preference in that browser
+17
openspec/changes/archive/2026-04-09-site-dark-mode-settings/tasks.md
··· 1 + ## 1. Theme foundation 2 + 3 + - [x] 1.1 Add a shared theme preference model for `light`, `dark`, and `system` 4 + - [x] 1.2 Add early theme initialization and browser persistence so the selected mode survives reloads and navigation 5 + - [x] 1.3 Extend global theme tokens and shared surfaces to support both light and dark mode 6 + 7 + ## 2. Settings page and navigation 8 + 9 + - [x] 2.1 Add Settings navigation next to Dashboard in the authenticated creator shell 10 + - [x] 2.2 Create the authenticated Settings page 11 + - [x] 2.3 Add an appearance control with Light, Dark, and System options and wire it to the shared theme preference 12 + 13 + ## 3. Site-wide adoption and verification 14 + 15 + - [x] 3.1 Update creator and public pages that still rely on fixed light-only colors so they adapt correctly in dark mode 16 + - [x] 3.2 Verify system mode follows OS preference while explicit Light and Dark overrides remain stable across navigation and reloads 17 + - [x] 3.3 Run build and relevant checks to confirm site-wide theme switching works correctly
+46
openspec/specs/site-theme-preferences/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Site supports light, dark, and system theme modes 4 + The system SHALL support light and dark themes across site surfaces and SHALL use the system color scheme when the active appearance preference is `system`. 5 + 6 + #### Scenario: Visitor has no explicit theme preference 7 + - **WHEN** a visitor opens the site without a saved appearance preference 8 + - **THEN** the system uses the device's current color scheme 9 + 10 + #### Scenario: User selects dark mode 11 + - **WHEN** a user selects the dark appearance mode 12 + - **THEN** the system applies the dark theme across the site 13 + 14 + #### Scenario: User selects light mode 15 + - **WHEN** a user selects the light appearance mode 16 + - **THEN** the system applies the light theme across the site 17 + 18 + #### Scenario: User selects system mode 19 + - **WHEN** a user selects the system appearance mode 20 + - **THEN** the system follows the device color scheme instead of forcing light or dark 21 + 22 + ### Requirement: Creator can manage appearance from a settings page 23 + The system SHALL provide an authenticated creator Settings page in creator navigation and SHALL allow the creator to choose Light, Dark, or System appearance mode from that page. 24 + 25 + #### Scenario: Creator opens settings 26 + - **WHEN** an authenticated creator opens the Settings page 27 + - **THEN** the system shows an appearance control with Light, Dark, and System options 28 + 29 + #### Scenario: Creator updates appearance preference 30 + - **WHEN** an authenticated creator changes the appearance setting in the Settings page 31 + - **THEN** the system applies the selected mode across the site 32 + 33 + #### Scenario: Unauthenticated user opens creator settings 34 + - **WHEN** an unauthenticated user attempts to open the creator Settings page 35 + - **THEN** the system requires authentication before showing settings 36 + 37 + ### Requirement: Theme choice persists for the current browser 38 + The system SHALL remember the selected appearance preference for the current browser so it continues to apply across page reloads and navigation. 39 + 40 + #### Scenario: User navigates after changing theme 41 + - **WHEN** a user changes the appearance preference and opens another page in the same browser 42 + - **THEN** the system keeps the selected appearance mode active 43 + 44 + #### Scenario: User reloads after changing theme 45 + - **WHEN** a user reloads the site after selecting an appearance preference 46 + - **THEN** the system restores the same preference in that browser