feat(design-explorer): token inventory — what exists and what it resolves to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 31s
Milestone #251 step 1 (#2258). Foundation for the gallery and the drift panel. Parses NAMES from theme.css and asks the BROWSER for every value. That split is deliberate. Extracting `--foo` is a trivial regex; extracting its value is not — theme.css has nested parens, commas inside rgba(), var() chains, multi-part shadows and gradients. getComputedStyle already resolves all of it, reports what actually won the cascade, and — the reason that matters here — reflects live overrides set on a container, which is exactly what the preview surface needs (#2261). Parsing values would report what the file says rather than what the user is looking at. It also keeps the error-prone half out of our code, which matters because the frontend has no test runner: `vue-tsc --noEmit` is the entire check. Logic that can't be unit-tested should be logic that can't be very wrong. readTokens(host) takes an element, so the same function reads app-wide values from :root and scoped values from inside a preview container. A BUG CAUGHT BEFORE SHIPPING, worth recording because the first version looked obviously right: the declaration regex originally required the match to follow `{` or `;`, to avoid matching var() uses. That silently dropped every declaration preceded by a COMMENT — including --color-bg, the first and most-used token in the file. 67 of 70 tokens found, no error, no warning. The anchor was never needed. A declaration is `--name:` and a reference is `var(--name)` or `var(--name,` — the colon alone discriminates. Comments are stripped first so commented-out declarations aren't counted. Verified against the real stylesheet: 70 unique tokens, 60 dark-overridden, 10 light-only, every group resolving, zero var()-only false positives. Also records a constraint discovered while building, which shapes step 6: light is declared on :root and dark on [data-theme="dark"], so an attribute selector can ADD dark to a subtree but nothing can add light back. Dark-inside-light previews work; light-inside-dark previews cannot, until a [data-theme="light"] block exists. readTokensForMode documents this rather than pretending otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -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<string>();
|
||||||
|
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<string> {
|
||||||
|
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<TokenGroup, DesignToken[]> {
|
||||||
|
const out = new Map<TokenGroup, DesignToken[]>();
|
||||||
|
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}`));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user