/** * 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}`)); }