import tokensCss from '@/styles/tokens.css?raw'; import { paintToHex } from './color'; /** * The hex CBAR actually publishes, recovered from `tokens.css`. * * The tokens page reads its values with `getComputedStyle`, which answers with * the substitution value — the oklch triple the kit authored, and for a * semantic role the literal string `var(--ui-color-brand-500)`. Neither is a * number a designer can compare against Figma. * * The hex is already in the repo. `tokens.css` is generated (see * `tools/figma-cbar/gen-tokens.js`) and writes the source value as a trailing * comment on every declaration it took from Figma: * * --ui-color-brand-500: oklch(0.392 0.098 244.9); /* #004976 *\/ * * Comments do not survive into the CSSOM, so the file is read a second time as * text. That is the whole trick, and it is why nothing here converts a colour: * a conversion is a derived number — the oklch above paints as `#004a76`, one * off what CBAR publishes — while the comment is the value the extraction read * out of Figma. `figma-sync` rewrites value and comment together, so the two * cannot drift. */ export interface TokenEntry { /** The declaration as authored: an `oklch(...)`, a `var(--x)`, a length, … */ value: string; /** CBAR's own hex, when the generator recorded one. */ hex?: string; } /** * The name is anchored to `--ui-` so the file's long banner comments cannot * match, and `[^;]+` spans newlines so the two multi-line declarations * (`--ui-font-sans`, the shadows) still parse. The comment group is optional * and must be *exactly* a hex: the interpolated steps carry a prose comment * ("interpolyasiya — CBAR 900-də bitir") and must not be mistaken for a value. */ const DECL = /^[ \t]*(--ui-[a-z0-9-]+)[ \t]*:[ \t]*([^;]+);(?:[ \t]*\/\*[ \t]*(#[0-9A-Fa-f]{3,8})[ \t]*\*\/)?/gm; const VAR_ONLY = /^var\(\s*(--[a-z0-9-]+)\s*\)$/; function parse(css: string): Map { const out = new Map(); for (const [, name, value, hex] of css.matchAll(DECL)) { out.set(name, { value: value.trim().replace(/\s+/g, ' '), hex }); } return out; } /** Every `--ui-*` primitive, by name. Built once — the file is a static import. */ export const TOKENS: ReadonlyMap = parse(tokensCss); /** * The same table keyed by the authored literal, which is how a semantic role * finds its primitive. * * `theme.css` writes `--primary: var(--ui-color-brand-500)`, but that is not * what the document answers: Tailwind v4 flattens the reference, so * `getPropertyValue('--primary')` returns `oklch(0.392 0.098 244.9)` — the * value, with the name it came from gone. Matching the literal back to a token * is what recovers the name, and with it CBAR's hex; without this a role would * fall through to `paintToHex` and report `#014976` for a colour CBAR publishes * as `#004976`. * * Aliases are skipped (their value is a `var()`, not a literal) and the first * name wins, so a step is credited to the ramp that declares it rather than to * a later copy. */ const BY_VALUE: ReadonlyMap = (() => { const out = new Map(); for (const [name, entry] of TOKENS) { if (VAR_ONLY.test(entry.value)) continue; if (!out.has(entry.value)) out.set(entry.value, name); } return out; })(); export interface Resolved { /** `#004976`, or undefined when the value is not a colour. */ hex?: string; /** The primitive the chain ended on, when the value started as a `var()`. */ token?: string; /** True when the hex was painted rather than read from Figma's own comment. */ derived?: boolean; } /** Aliases are at most two deep today; the cap is only a guard against a cycle. */ const MAX_HOPS = 8; /** * A declaration value → the hex behind it. * * Walks `var()` references through `TOKENS`, because the chains are real: a * role like `--destructive` points at `--ui-color-danger-500`, which is itself * an alias of `--ui-color-red-500`. A hex comment anywhere along the way wins * immediately — including on an alias, which is exactly the * `--ui-color-yellow-surface` case, where the comment is the authoritative * value and the `var()` is the kit tying it to a ramp step. * * Only when no link in the chain carries a hex does it paint the literal it * ended on, and says so with `derived` — those are the steps the kit * interpolated because CBAR's ramps stop at 900, not CBAR values. */ export function resolveToken(value: string): Resolved { let current = value.trim(); let token: string | undefined; const seen = new Set(); for (let hop = 0; hop < MAX_HOPS; hop += 1) { const ref = VAR_ONLY.exec(current); if (!ref) break; const name = ref[1]; if (seen.has(name)) return { token }; seen.add(name); const entry = TOKENS.get(name); if (!entry) return { token }; token = name; if (entry.hex) return { hex: entry.hex.toUpperCase(), token }; current = entry.value; } /* `current` is now a literal. If some primitive is authored with exactly that literal, the role is pointing at it and inherits its hex — the usual case, since the browser hands back a flattened value rather than the `var()`. */ const named = BY_VALUE.get(current); if (named) { const entry = TOKENS.get(named); if (entry?.hex) return { hex: entry.hex.toUpperCase(), token: token ?? named }; token = token ?? named; } /* Nothing on either side carries a hex, so painting is the only way left. */ const painted = paintToHex(current); if (!painted || painted === 'transparent') return { token }; return { hex: painted.toUpperCase(), token, derived: true }; } /** * What a swatch shows, given the live value the page read off the document. * * A ramp step is a literal with its own comment, so it is looked up by name; a * role is a `var()` and has to be walked. Both end here. */ export function hexFor(variable: string, liveValue: string): Resolved { const own = TOKENS.get(variable); if (own?.hex) return { hex: own.hex.toUpperCase() }; return resolveToken(liveValue || own?.value || ''); }