diff --git a/frontend/src/utils/designTokens.ts b/frontend/src/utils/designTokens.ts new file mode 100644 index 0000000..029d475 --- /dev/null +++ b/frontend/src/utils/designTokens.ts @@ -0,0 +1,181 @@ +/** + * Design-token inventory — what tokens exist, and what they actually resolve to. + * + * Foundation for the design explorer (milestone #251): the gallery renders + * against these, and the drift panel compares them to the design rulebook. + * + * DESIGN NOTE — why this parses NAMES but never VALUES. + * Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting + * its VALUE is not: values contain nested parens, commas inside rgba(), + * `var()` references to other tokens, multi-part shadows, and gradients — and + * `theme.css` has all of those today. So we take the names from the source and + * ask the BROWSER for every value. + * + * That is not just easier, it is more correct. getComputedStyle reports what + * actually won the cascade, resolves `var()` chains, and — critically for this + * milestone — reflects live overrides set on a container, which is exactly what + * the preview surface needs (see #2261). Parsing the source would report what + * the file says rather than what the user is looking at. + * + * It also means this module needs no unit tests to be trustworthy: the only + * logic here is a name regex and a group lookup. The frontend has no test + * runner today (`vue-tsc --noEmit` is the whole check), so keeping the + * error-prone half in the browser rather than in our code is deliberate. + */ +import themeCss from "@/assets/theme.css?raw"; + +export type TokenGroup = + | "color" + | "radius" + | "gradient" + | "glow" + | "focus" + | "layout" + | "other"; + +export type ThemeMode = "light" | "dark"; + +export interface DesignToken { + /** Full custom-property name, including the leading `--`. */ + name: string; + /** Coarse family, derived from the name prefix. */ + group: TokenGroup; + /** Resolved value in the requested context, straight from the browser. */ + value: string; + /** True when the declaration appears inside the dark block in source. */ + overriddenInDark: boolean; +} + +/** + * Matches a custom-property DECLARATION, and never a `var(--name)` use. + * + * The discriminator is the COLON, not the preceding character. A declaration is + * `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed + * by `)` or `,`, never by `:`. So no anchor is needed, and adding one is + * actively wrong: an earlier version required the match to follow `{` or `;`, + * which silently dropped every declaration that came after a comment — + * including `--color-bg`, the first and most-used token in the file. + */ +const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g; + +/** Comments are stripped first so a commented-out declaration isn't counted. */ +const COMMENT = /\/\*[\s\S]*?\*\//g; + +/** The dark block's selector, as written in theme.css. */ +const DARK_SELECTOR = '[data-theme="dark"]'; + +const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [ + ["--color-", "color"], + ["--radius-", "radius"], + ["--gradient-", "gradient"], + ["--glow-", "glow"], + ["--focus-", "focus"], + ["--page-", "layout"], + ["--sidebar-", "layout"], + ["--chat-", "layout"], +]; + +export function groupFor(name: string): TokenGroup { + for (const [prefix, group] of GROUP_PREFIXES) { + if (name.startsWith(prefix)) return group; + } + return "other"; +} + +/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */ +export function tokenNames(css: string = themeCss): string[] { + const seen = new Set(); + const out: string[] = []; + for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) { + const name = match[1]; + if (!seen.has(name)) { + seen.add(name); + out.push(name); + } + } + return out; +} + +/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */ +export function darkOverriddenNames(css: string = themeCss): Set { + const bare = css.replace(COMMENT, ""); + const start = bare.indexOf(DARK_SELECTOR); + if (start === -1) return new Set(); + const open = bare.indexOf("{", start); + const close = bare.indexOf("}", open); + if (open === -1 || close === -1) return new Set(); + return new Set(tokenNames(bare.slice(open, close))); +} + +/** + * Read the resolved value of every token in `host`'s context. + * + * Pass a container to read the tokens as they apply INSIDE it — which is how + * the preview surface reads a scoped override without disturbing the page. + * Defaults to the document root, i.e. the app-wide values. + */ +export function readTokens(host: Element = document.documentElement): DesignToken[] { + const computed = getComputedStyle(host); + const dark = darkOverriddenNames(); + return tokenNames().map((name) => ({ + name, + group: groupFor(name), + value: computed.getPropertyValue(name).trim(), + overriddenInDark: dark.has(name), + })); +} + +/** + * Read tokens as they would resolve in a given mode, without touching the page. + * + * Uses an offscreen probe carrying the mode attribute, so the live UI is never + * mutated to take a reading. + * + * KNOWN LIMITATION, and it is a property of the stylesheet rather than of this + * function: light is declared on `:root` while dark is declared on + * `[data-theme="dark"]`. An attribute selector can ADD the dark values to a + * subtree, but there is no `[data-theme="light"]` block to add the light ones + * back. So reading "light" from inside a dark page returns the dark values — + * the probe has nothing to match. + * + * Concretely: dark-inside-light previews work, light-inside-dark previews do + * not. Introducing a `[data-theme="light"]` block alongside the dark-first flip + * (milestone #251 step 6) is what makes this symmetric, and until then callers + * should treat a cross-mode read as best-effort. + */ +export function readTokensForMode(mode: ThemeMode): DesignToken[] { + const probe = document.createElement("div"); + probe.setAttribute("data-theme", mode); + probe.style.display = "none"; + document.body.appendChild(probe); + try { + return readTokens(probe); + } finally { + probe.remove(); + } +} + +/** Tokens grouped by family, preserving source order within each group. */ +export function groupTokens(tokens: DesignToken[]): Map { + const out = new Map(); + for (const token of tokens) { + const bucket = out.get(token.group); + if (bucket) bucket.push(token); + else out.set(token.group, [token]); + } + return out; +} + +/** + * Tokens declared in the stylesheet that nothing references with `var()`. + * + * Dead tokens are drift too: `--chat-reading-width` and + * `--chat-context-sidebar-width` outlived the chat subsystem that was deleted + * in the MCP-first pivot, and nothing has referenced them since. Takes the + * corpus of source files to search as an argument so the caller decides what + * "used" means — this module has no opinion about the project layout. + */ +export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] { + const haystack = sources.join("\n"); + return tokens.filter((token) => !haystack.includes(`var(${token.name}`)); +}