feat(designbook): technology-neutral IR + stack-adapter pipeline (WHYNOT-WP-0002 T01-T06)

Author the design language once in the canonical React designbook and project it
one-way onto each stack: React -> designbook/ -> ir/ -> adapters/<stack>/.

Phase 0 — contracts & governance (T01-T03):
- ir/SCHEMA.md + ir/schema/{component,tokens}.schema.json — neutral IR contract
  (W3C DTCG tokens; React prop -> HTML attribute mapping; non-portable props flagged).
- adapters/ADAPTER_CONTRACT.md — inputs, drift-report + parity-result shapes,
  idempotency rules, CI exit codes (0 ok / 2 usage / 3 drift / 4 parity / 5 internal).
- .claude/rules/designbook-propagation.md + DesignSystemIntroduction.md §5.1 —
  one-way directionality + drift-resolution workflow.

T04 — canonical React designbook + the missing pull tool:
- The bundled /design-sync skill only PUSHES repo->cloud; it cannot populate
  designbook/. Added scripts/designbook_pull.py + `make designbook-pull`, which drives
  the local claude binary headless (acceptEdits) so DesignSync fetch+write runs in a
  subprocess (contents never hit the orchestrator's context). Pulled 44 files;
  excludes the _whynot-design-seed/ self-copy. Corrected the docs that wrongly called
  /design-sync the pull.

T05 — IR extractor (scripts/ir-extract.mjs + `make ir`):
- ir/tokens.json (80 tokens, DTCG, var() -> {ref} alias resolution); ir/components/*.json
  (10 contracts parsed from .jsx signatures: enum/boolean/number inference, prop->attr
  map, style/callback marked non-portable); ir/exemplars/*.

T06 — Lit token adapter (adapters/lit/ + `make adapt-lit`):
- Full-gen tokens into src/styles/colors_and_type.css :root (marker-bounded, idempotent
  no-op on re-run; hand-authored type CSS preserved).

NOTE: token regen synced Lit to canonical React — fonts IBM Plex -> system stacks and 8
status tokens added. This is a VISUAL change: review and run `pnpm test:visual:update`
before merge. Remaining: T07 scaffold+drift, T08 parity, T09 runbook, T10 2nd-adapter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:36:24 +02:00
parent d149f965a3
commit 0d688ca94a
80 changed files with 6439 additions and 106 deletions

271
scripts/ir-extract.mjs Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env node
// =============================================================
// ir-extract.mjs — the pivot (WHYNOT-WP-0002 · T05)
//
// Reads the local React designbook mirror (designbook/) and emits the
// technology-neutral IR (ir/): tokens, per-component contracts, and exemplars.
// One-way only: React → IR. Never hand-edit ir/tokens.json, ir/components/*,
// or ir/exemplars/* — they are regenerated here (`make ir`) and committed so a
// blueprint change shows up as a git diff. See ir/SCHEMA.md and
// .claude/rules/designbook-propagation.md.
//
// Source layout (a bundled .jsx ui-kit, not per-component .d.ts — see
// designbook/REACT_CANONICAL_DECISION.md):
// designbook/_ds_manifest.json → structured tokens[] + preview cards[]
// designbook/ui_kits/<kit>/Atoms.jsx → component fn signatures (props/defaults)
// designbook/ui_kits/<kit>/Chrome.jsx → component fn signatures
// designbook/preview/comp-*.html → exemplar renders
// =============================================================
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
const DESIGNBOOK = join(REPO, "designbook");
const IR = join(REPO, "ir");
const KIT = "whynot-control";
// Which ui-kit files hold reusable design-system components (NOT app screens/demo data).
const COMPONENT_SOURCES = ["Atoms.jsx", "Chrome.jsx"];
const log = (...a) => console.log(...a);
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
// ---------- Tokens: _ds_manifest.json tokens[] → W3C DTCG ----------
// Each manifest token: { name:"--ink", value:"#0A0A0A", kind:"color", definedIn }.
// Group + DTCG $type are decided by name prefix (kind is a fallback hint).
const GROUP_BY_PREFIX = [
["--ff-", "fontFamily", "fontFamily"],
["--fs-", "fontSize", "dimension"],
["--lh-", "lineHeight", "number"],
["--tr-", "letterSpacing", "dimension"],
["--sp-", "space", "dimension"],
["--r-", "radius", "dimension"],
["--shadow-", "shadow", "shadow"],
];
function classify(name) {
for (const [prefix, group, type] of GROUP_BY_PREFIX) {
if (name.startsWith(prefix)) return { group, type };
}
return { group: "color", type: "color" }; // every remaining token is a colour
}
function extractTokens() {
const manifest = JSON.parse(readFileSync(join(DESIGNBOOK, "_ds_manifest.json"), "utf8"));
const tokens = manifest.tokens || [];
// First pass: map each css var → its DTCG reference path {group.key}.
const refOf = new Map();
for (const t of tokens) {
const { group } = classify(t.name);
refOf.set(t.name, `${group}.${t.name.slice(2)}`);
}
// Second pass: build the nested DTCG tree.
const out = {};
for (const t of tokens) {
const { group, type } = classify(t.name);
const key = t.name.slice(2);
out[group] ||= { $type: type };
// Resolve `var(--x)` aliases to DTCG references; literals pass through.
const aliasMatch = /^var\(\s*(--[A-Za-z0-9-]+)\s*\)$/.exec(t.value.trim());
let value = t.value.replace(/\s+/g, " ").trim();
if (aliasMatch && refOf.has(aliasMatch[1])) value = `{${refOf.get(aliasMatch[1])}}`;
out[group][key] = { $value: value };
}
return out;
}
// ---------- Components: parse .jsx function signatures ----------
// Matches: function Name({ a, b = 'x', children, onClick, style }) { … }
const FN_RE = /function\s+([A-Z][A-Za-z0-9]*)\s*\(\s*\{([^}]*)\}\s*\)\s*\{/g;
function parseParams(raw) {
// Split top-level commas (defaults here have no nested commas/objects).
return raw.split(",").map((s) => s.trim()).filter(Boolean).map((p) => {
const eq = p.indexOf("=");
if (eq === -1) return { name: p.trim(), default: undefined };
return { name: p.slice(0, eq).trim(), default: p.slice(eq + 1).trim() };
});
}
// Collect enum values from `name === 'x'` / `name !== 'x'` comparisons in the body.
function enumValuesFor(name, body) {
const re = new RegExp(`${name}\\s*[=!]==\\s*'([^']+)'`, "g");
const vals = new Set();
let m;
while ((m = re.exec(body))) vals.add(m[1]);
return [...vals];
}
function usedAsBoolean(name, body) {
return new RegExp(`(if\\s*\\(\\s*${name}\\b|\\b${name}\\s*\\?|\\b${name}\\s*&&)`).test(body)
&& !new RegExp(`${name}\\s*===`).test(body);
}
function defaultLiteral(raw) {
if (raw === undefined) return undefined;
if (/^'[^']*'$/.test(raw) || /^"[^"]*"$/.test(raw)) return raw.slice(1, -1);
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
if (raw === "true" || raw === "false") return raw === "true";
return raw; // expression default; keep verbatim
}
function propFor(param, body) {
const { name, default: dflt } = param;
const def = defaultLiteral(dflt);
// children → slot, handled by caller. style/object & callbacks → non-portable.
if (name === "style") {
return { name, type: "object", attribute: false, portable: false,
description: "React inline style override — not portable to an attribute." };
}
if (/^on[A-Z]/.test(name)) {
return { name, type: "function", attribute: false, portable: false,
description: "React callback prop — surface as an event on attribute-driven stacks." };
}
const prop = { name, attribute: kebab(name) };
const enums = enumValuesFor(name, body);
if (typeof def === "string" && enums.length) {
prop.type = "enum";
prop.enum = [...new Set([def, ...enums])];
} else if (enums.length) {
prop.type = "enum";
prop.enum = enums;
} else if (typeof def === "number") {
prop.type = "number";
} else if (typeof def === "boolean" || usedAsBoolean(name, body)) {
prop.type = "boolean";
} else {
prop.type = "string";
}
if (def !== undefined) prop.default = def;
return prop;
}
function bodyOf(src, startIdx) {
// Return the balanced { … } body beginning at the first { at/after startIdx.
let depth = 0, i = src.indexOf("{", startIdx);
const begin = i;
for (; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}" && --depth === 0) return src.slice(begin, i + 1);
}
return src.slice(begin);
}
// Map a component to a preview exemplar via the manifest cards (best-effort).
function buildExemplarIndex() {
const manifest = JSON.parse(readFileSync(join(DESIGNBOOK, "_ds_manifest.json"), "utf8"));
return (manifest.cards || []).filter((c) => c.group === "Components");
}
function matchExemplar(name, cards) {
const n = name.toLowerCase();
// direct hints: Button→comp-buttons, Tag/StageDot→comp-labels-tags, etc.
const hint = { stagedot: "labels-tags", tag: "labels-tags", eyebrow: "labels-tags",
pipelinestrip: "pipeline", topnav: "topnav", sidebar: "left-nav",
pageheader: "topnav", button: "buttons" }[n];
const want = hint || n;
return cards.find((c) => c.path.includes(want)) || null;
}
function extractComponents() {
const cards = buildExemplarIndex();
const components = [];
for (const file of COMPONENT_SOURCES) {
const path = join(DESIGNBOOK, "ui_kits", KIT, file);
if (!existsSync(path)) { log(` (skip ${file} — not present)`); continue; }
const src = readFileSync(path, "utf8");
const group = file.replace(".jsx", "").toLowerCase();
let m;
FN_RE.lastIndex = 0;
while ((m = FN_RE.exec(src))) {
const name = m[1];
const params = parseParams(m[2]);
// FN_RE ends at the function body's opening brace; start the body scan there
// (not at m.index, whose first `{` is the param-destructuring brace).
const body = bodyOf(src, m.index + m[0].length - 1);
const props = [];
const slots = [];
for (const p of params) {
if (p.name === "children") { slots.push({ name: "default", description: "Default content." }); continue; }
props.push(propFor(p, body));
}
const events = props.filter((p) => /^on[A-Z]/.test(p.name))
.map((p) => ({ name: kebab(p.name.replace(/^on/, "wn-")), description: `Emitted for ${p.name}.` }));
const variants = props.filter((p) => p.type === "enum")
.map((p) => ({ axis: p.name, values: p.enum, ...(p.default ? { default: p.default } : {}) }));
const card = matchExemplar(name, cards);
const contract = {
name,
tag: `wn-${kebab(name)}`,
group,
description: `${name} — extracted from designbook ui_kits/${KIT}/${file}.`,
props,
...(slots.length ? { slots } : {}),
...(events.length ? { events } : {}),
...(variants.length ? { variants } : {}),
docsRef: `designbook/ui_kits/${KIT}/${file}`,
...(card ? { exemplarRef: `ir/exemplars/${name}.html` } : {}),
};
components.push({ contract, card });
}
}
return components;
}
// ---------- Lightweight contract validation (invariants from ir/schema) ----------
function validateContract(c) {
const errs = [];
if (!/^[A-Z][A-Za-z0-9]*$/.test(c.name)) errs.push(`bad name ${c.name}`);
if (!c.group || !c.description) errs.push(`${c.name}: missing group/description`);
for (const p of c.props) {
if (!p.name) errs.push(`${c.name}: prop without name`);
if (p.type === "enum" && !(p.enum && p.enum.length)) errs.push(`${c.name}.${p.name}: enum without values`);
if (!("attribute" in p)) errs.push(`${c.name}.${p.name}: missing attribute mapping`);
}
return errs;
}
// ---------- Emit ----------
function resetDir(dir) {
if (existsSync(dir)) for (const f of readdirSync(dir)) rmSync(join(dir, f), { recursive: true, force: true });
else mkdirSync(dir, { recursive: true });
}
function main() {
if (!existsSync(join(DESIGNBOOK, "_ds_manifest.json"))) {
console.error("No designbook/_ds_manifest.json — run `make designbook-pull` first.");
process.exit(2);
}
mkdirSync(IR, { recursive: true });
log("Tokens → ir/tokens.json");
const tokens = extractTokens();
writeFileSync(join(IR, "tokens.json"), JSON.stringify(tokens, null, 2) + "\n");
const tokenCount = Object.values(tokens).reduce((n, g) => n + Object.keys(g).filter((k) => k !== "$type").length, 0);
log(` ${tokenCount} tokens in ${Object.keys(tokens).length} groups`);
log("Components → ir/components/<Name>.json");
const comps = extractComponents();
resetDir(join(IR, "components"));
resetDir(join(IR, "exemplars"));
const allErrs = [];
for (const { contract, card } of comps) {
allErrs.push(...validateContract(contract));
writeFileSync(join(IR, "components", `${contract.name}.json`), JSON.stringify(contract, null, 2) + "\n");
if (card) {
const srcHtml = join(DESIGNBOOK, card.path);
if (existsSync(srcHtml)) copyFileSync(srcHtml, join(IR, "exemplars", `${contract.name}.html`));
}
}
log(` ${comps.length} components, ${comps.filter((c) => c.card).length} with exemplars`);
if (allErrs.length) {
console.error("\nContract validation errors:");
for (const e of allErrs) console.error(" - " + e);
process.exit(5);
}
log("\nIR extracted. Review the ir/ git diff (the blueprint change).");
}
main();