From 9b7dd8272efa039eb692a06a38c0f30df161b7af Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 13:39:54 -0400 Subject: [PATCH] feat(web/m7-362): tokens.json dark/light/flat split + dual-block CSS generator Co-Authored-By: Claude Sonnet 4.6 --- web/scripts/tokens-to-css.js | 46 ++++++++++++++------- web/src/lib/styles/tokens.generated.css | 37 +++++++++++++++++ web/src/lib/styles/tokens.json | 55 ++++++++++++++----------- 3 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 web/src/lib/styles/tokens.generated.css diff --git a/web/scripts/tokens-to-css.js b/web/scripts/tokens-to-css.js index 34cd46b8..d72f50a3 100644 --- a/web/scripts/tokens-to-css.js +++ b/web/scripts/tokens-to-css.js @@ -2,7 +2,7 @@ // Reads ../src/lib/styles/tokens.json and emits tokens.generated.css. // Single source of truth for FabledSword tokens shared with the Flutter // client. Keep output deterministic — Tailwind reads tokens.json directly, -// the .css emission is for raw CSS consumers. +// the .css emission is for raw CSS consumers and theme switching. import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -10,19 +10,35 @@ import { dirname, resolve } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const tokensPath = resolve(here, '../src/lib/styles/tokens.json'); const outPath = resolve(here, '../src/lib/styles/tokens.generated.css'); -const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); -const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; -for (const [name, value] of Object.entries(tokens.colors)) { - lines.push(` --fs-${name}: ${value};`); -} -for (const [name, value] of Object.entries(tokens.radii)) { - lines.push(` --fs-radius-${name}: ${value};`); -} -lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); -lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); -lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); -lines.push('}', '[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.accent};`, '}', ''); +export function emit(tokens) { + const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {']; + for (const [name, value] of Object.entries(tokens.colors.dark)) { + lines.push(` --fs-${name}: ${value};`); + } + for (const [name, value] of Object.entries(tokens.colors.flat)) { + lines.push(` --fs-${name}: ${value};`); + } + for (const [name, value] of Object.entries(tokens.radii)) { + lines.push(` --fs-radius-${name}: ${value};`); + } + lines.push(` --fs-font-display: ${tokens.fontStacks.display};`); + lines.push(` --fs-font-body: ${tokens.fontStacks.body};`); + lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`); + lines.push('}'); -writeFileSync(outPath, lines.join('\n')); -console.log(`wrote ${outPath}`); + lines.push('[data-theme="light"] {'); + for (const [name, value] of Object.entries(tokens.colors.light)) { + lines.push(` --fs-${name}: ${value};`); + } + lines.push('}'); + + lines.push('[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.flat.accent};`, '}', ''); + return lines.join('\n'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const tokens = JSON.parse(readFileSync(tokensPath, 'utf8')); + writeFileSync(outPath, emit(tokens)); + console.log(`wrote ${outPath}`); +} diff --git a/web/src/lib/styles/tokens.generated.css b/web/src/lib/styles/tokens.generated.css new file mode 100644 index 00000000..0ebfd72f --- /dev/null +++ b/web/src/lib/styles/tokens.generated.css @@ -0,0 +1,37 @@ +/* GENERATED — do not edit. Source: tokens.json */ +:root { + --fs-obsidian: #14171A; + --fs-iron: #1E2228; + --fs-slate: #2C313A; + --fs-pewter: #3F4651; + --fs-parchment: #E8E4D8; + --fs-vellum: #C2BFB4; + --fs-ash: #9C9A92; + --fs-moss: #4A5D3F; + --fs-bronze: #8B7355; + --fs-oxblood: #6B2118; + --fs-warning: #8B6F1E; + --fs-error: #C04A1F; + --fs-info: #3D5A6E; + --fs-accent: #4A6B5C; + --fs-on-action: #E8E4D8; + --fs-radius-sm: 4px; + --fs-radius-md: 8px; + --fs-radius-lg: 12px; + --fs-radius-xl: 16px; + --fs-font-display: 'Fraunces', Georgia, serif; + --fs-font-body: 'Inter', system-ui, sans-serif; + --fs-font-mono: 'JetBrains Mono', ui-monospace, monospace; +} +[data-theme="light"] { + --fs-obsidian: #F8F5EE; + --fs-iron: #ECE6D5; + --fs-slate: #DCD3BD; + --fs-pewter: #B8AE94; + --fs-parchment: #14171A; + --fs-vellum: #2C313A; + --fs-ash: #5C6068; +} +[data-fs-app="minstrel"] { + --fs-accent: #4A6B5C; +} diff --git a/web/src/lib/styles/tokens.json b/web/src/lib/styles/tokens.json index e8fa04ec..c602dfe7 100644 --- a/web/src/lib/styles/tokens.json +++ b/web/src/lib/styles/tokens.json @@ -1,31 +1,36 @@ { "colors": { - "obsidian": "#14171A", - "iron": "#1E2228", - "slate": "#2C313A", - "pewter": "#3F4651", - "parchment": "#E8E4D8", - "vellum": "#C2BFB4", - "ash": "#9C9A92", - "moss": "#4A5D3F", - "bronze": "#8B7355", - "oxblood": "#6B2118", - "warning": "#8B6F1E", - "error": "#C04A1F", - "info": "#3D5A6E", - "accent": "#4A6B5C" - }, - "radii": { - "sm": "4px", - "md": "8px", - "lg": "12px", - "xl": "16px" - }, - "fonts": { - "display": "Fraunces", - "body": "Inter", - "mono": "JetBrains Mono" + "dark": { + "obsidian": "#14171A", + "iron": "#1E2228", + "slate": "#2C313A", + "pewter": "#3F4651", + "parchment": "#E8E4D8", + "vellum": "#C2BFB4", + "ash": "#9C9A92" + }, + "light": { + "obsidian": "#F8F5EE", + "iron": "#ECE6D5", + "slate": "#DCD3BD", + "pewter": "#B8AE94", + "parchment": "#14171A", + "vellum": "#2C313A", + "ash": "#5C6068" + }, + "flat": { + "moss": "#4A5D3F", + "bronze": "#8B7355", + "oxblood": "#6B2118", + "warning": "#8B6F1E", + "error": "#C04A1F", + "info": "#3D5A6E", + "accent": "#4A6B5C", + "on-action": "#E8E4D8" + } }, + "radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" }, + "fonts": { "display": "Fraunces", "body": "Inter", "mono": "JetBrains Mono" }, "fontStacks": { "display": "'Fraunces', Georgia, serif", "body": "'Inter', system-ui, sans-serif",