import spec from '../registry/figma-spec.json'; import type { Entry } from '../registry/types'; /** * The captured shape of CBAR's Figma file, and the diff run against it. * * `figma-spec.json` is written by `scripts/gen-figma-spec.mjs` from a * `figma_sets --page "*"` answer: every component set on the canvas, reduced to * its component *properties*. It is data, not a schema — re-run the generator * after the design file moves and both the parity page and every component's * Figma panel re-read it. * * Everything here works with the bridge switched off. The live half lives in * `bridge.ts` / `use-figma.ts` and only ever adds to what this file states. */ export type FigmaPropType = 'VARIANT' | 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP'; /** * One component property as Figma declares it. * * A `VARIANT` is an axis: it has `values` and a default one. The other three * are slots — a BOOLEAN turns one on (`iconLeft?`), an INSTANCE_SWAP fills it, * a TEXT seeds its copy. Those are the easiest thing in the file to miss: * Button carries `iconLeft?` *and* `iconRight?`, both on by default, which is * four icon arrangements no variant name mentions. */ export interface FigmaProp { /** Figma's property name with the `#nodeid` suffix stripped. */ name: string; /** The verbatim key, suffix and all — what the plugin API wants. */ key: string; type: FigmaPropType; /** `defaultValue`: the variant Figma shows first, or a boolean, or seed text. */ default?: string | boolean; /** VARIANT only. */ values?: string[]; } export interface FigmaSet { /** Node id of the component set — every live call starts from this. */ id: string; set: string; page: string; variants: number; size?: { width: number; height: number }; props: FigmaProp[]; } export interface FigmaSpec { capturedAt: string; file: string | null; sets: FigmaSet[]; } /* TypeScript infers a union of one exact object shape per entry from the JSON, and a property absent from a given set is typed `undefined` — which is not assignable to the interfaces above. The capture is data, so widen it once here rather than annotating the file. */ export const SPEC = spec as unknown as FigmaSpec; export const SETS = SPEC.sets; export const norm = (s: string) => s.trim().toLowerCase(); /** Set names are inconsistent in the file (`Button`, `avatar`, `tabsList`). */ export function findSet(name: string | undefined): FigmaSet | undefined { if (!name) return undefined; return SETS.find((s) => norm(s.set) === norm(name)); } /** The VARIANT properties, in the `{ axis: values }` shape the diff wants. */ export function variantAxes(set: FigmaSet): Record { const out: Record = {}; for (const p of set.props) if (p.type === 'VARIANT') out[p.name] = p.values ?? []; return out; } /** Everything that is not an axis: the BOOLEAN / INSTANCE_SWAP / TEXT slots. */ export const slotProps = (set: FigmaSet) => set.props.filter((p) => p.type !== 'VARIANT'); export const propByName = (set: FigmaSet, name: string) => set.props.find((p) => norm(p.name) === norm(name)); /** * `state` is a CSS concern, never a prop: hover, active, focus and disabled are * drawn by the component, so a Figma `state` axis has nothing to line up with. * Same for the boolean mirrors of those (`.isDisabled?`, `.focusVisible?`). */ export const STATE_AXES = /^(state|\.is(disabled|checked|invalid|filled)\?|\.focusvisible\?)$/; export type RowKind = 'match' | 'diff' | 'state' | 'no-prop' | 'extra'; export interface AxisRow { kind: RowKind; figmaAxis?: string; kitAxis?: string; figmaValues: string[]; kitValues: string[]; onlyFigma: string[]; onlyKit: string[]; /** The variant Figma shows first — present whenever `figmaAxis` is. */ figmaDefault?: string; } /** * One row per axis, from either side. * * The point is that a divergence stops being something you notice by eye on a * particular component and becomes a row in a table. */ export function compare(figma: FigmaSet, entry: Entry | undefined): AxisRow[] { const rows: AxisRow[] = []; const kitAxes = entry?.axes ?? {}; const kitNames = Object.keys(kitAxes); const claimed = new Set(); for (const prop of figma.props) { if (prop.type !== 'VARIANT') continue; const figmaAxis = prop.name; const figmaValues = prop.values ?? []; const figmaDefault = typeof prop.default === 'string' ? prop.default : undefined; /* An entry may name an axis as a value axis despite its name — Toast's `state` is `success`/`error`/…, not `hover`/`disabled`. See `statusAxes` in `registry/types.ts` for why this is declared rather than sniffed. */ const declaredValueAxis = entry?.figma?.statusAxes?.some((a) => norm(a) === norm(figmaAxis)); if (!declaredValueAxis && STATE_AXES.test(norm(figmaAxis))) { rows.push({ kind: 'state', figmaAxis, figmaValues, figmaDefault, kitValues: [], onlyFigma: [], onlyKit: [], }); continue; } const mapped = entry?.figma?.axisMap?.[figmaAxis]; const kitAxis = kitNames.find((n) => norm(n) === norm(mapped ?? figmaAxis)); if (!kitAxis) { rows.push({ kind: 'no-prop', figmaAxis, figmaValues, figmaDefault, kitValues: [], onlyFigma: figmaValues, onlyKit: [], }); continue; } claimed.add(kitAxis); const kitValues = kitAxes[kitAxis].map(String); /* A value the entry renames rather than drops (`tertiary` → `third`) is a match, not a gap — the same map the live variant lookup walks. */ const alias = entry?.figma?.valueMap?.[figmaAxis] ?? {}; const aliased = kitValues.map((v) => alias[v] ?? v); const onlyFigma = figmaValues.filter((v) => !aliased.some((k) => norm(k) === norm(v))); const onlyKit = kitValues.filter( (v) => !figmaValues.some((f) => norm(f) === norm(alias[v] ?? v)) ); rows.push({ kind: onlyFigma.length || onlyKit.length ? 'diff' : 'match', figmaAxis, kitAxis, figmaValues, figmaDefault, kitValues, onlyFigma, onlyKit, }); } for (const kitAxis of kitNames) { if (claimed.has(kitAxis)) continue; if (STATE_AXES.test(norm(kitAxis))) continue; rows.push({ kind: 'extra', kitAxis, figmaValues: [], kitValues: kitAxes[kitAxis].map(String), onlyFigma: [], onlyKit: kitAxes[kitAxis].map(String), }); } return rows; }