molecule-core/canvas/src/lib/theme-cookie.ts
Hongming Wang c0eca8d0e1 feat(canvas): warm-paper theme + Tailwind v4 migration
Brings the canvas onto the warm-paper design system already shipped to
landing, marketplace, and SaaS surfaces, and migrates the build from
Tailwind v3 → v4 to match molecule-app.

Plumbing:
- swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss
- delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS)
- globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography"
- two @theme blocks: warm-paper light defaults + always-dark surface
  tokens (bg-bg / ink-mute / line-strong) for terminal/console panels
- [data-theme="dark"] cascade overrides the warm-paper tokens for dark
- React Flow edge stroke + scrollbar + selection colour pull from
  semantic tokens so they flip with the theme

Theme infra (ported from molecule-app, identical contracts):
- lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client"
  so server components can read the constants)
- lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with
  Domain=.moleculesai.app so the preference follows the user across
  canvas/app/market/landing subdomains AND tenant subdomains
- lib/theme.ts: ColorToken union + cssVar() helper
- components/ThemeToggle.tsx: 3-way System/Light/Dark picker
- layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs
  the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd
  inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body

Component migration (62 files):
- Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white →
  semantic surface/ink/line tokens via perl negative-lookahead pass
  (preserves opacity modifiers like /80, /60)
- bg-blue-500/600 → bg-accent / bg-accent-strong
- text-red-* / amber-* / emerald-* → text-bad / warm / good
- Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950
  etc.) intentionally left literal — they remain readable on warm-paper
  in light mode without inventing new state-soft tokens
- TerminalTab.tsx skipped — xterm renders to canvas, not DOM
- 3 unit-test assertions updated to match new token strings (credits
  pillTone, AuthGate overlay class, A2AEdge accent)

Verification:
- pnpm test: 1214/1214 pass
- pnpm tsc --noEmit: clean
- next build: ✓ Compiled successfully (8 routes)
- dev server inspection: html data-theme stamped, body uses
  bg-surface text-ink, boot script carries nonce, compiled CSS
  contains both @theme blocks + [data-theme="dark"] override

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:43:55 -07:00

41 lines
1.8 KiB
TypeScript

/**
* Theme cookie constants + boot script.
*
* No "use client" pragma — these are imported by both server components
* (app/layout.tsx, which calls cookies() during SSR) and client
* components (lib/theme-provider.tsx). Constants exported from a
* "use client" file get rewritten by Next.js as client-reference
* placeholders, so a server importer sees a Function instead of the
* underlying value. Keeping shared primitives here avoids that trap.
*
* Aligned with molecule-app's matching module — same cookie name, same
* three-value enum — so the preference follows the user across surfaces
* (app, market, landing, canvas) when the cookie is set with
* Domain=.moleculesai.app.
*/
export type ThemePreference = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export const THEME_COOKIE = "mol_theme";
export function readThemeCookie(value: string | undefined): ThemePreference {
if (value === "light" || value === "dark" || value === "system") {
return value;
}
return "system";
}
/**
* Inline boot script. Stringified verbatim by app/layout.tsx so it runs
* synchronously before the body paints — preventing a flash of the wrong
* theme. Reads cookie via document.cookie regex (no parser available
* yet), falls back to matchMedia, and stamps data-theme on <html>.
*
* Must remain tiny and dependency-free — runs before hydration. The
* canvas's middleware sets a strict CSP with nonce-based script-src in
* production; the layout passes the nonce on the <script> tag so this
* passes the inline-script gate.
*/
export const themeBootScript = `(()=>{try{var m=document.cookie.match(/(?:^|;\\s*)${THEME_COOKIE}=(system|light|dark)/);var p=m?m[1]:"system";var r=p==="system"?(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"):p;document.documentElement.dataset.theme=r;}catch(e){}})();`;