From 2de30beb7be2c3b6addd8ae91b54b83e10b0ec82 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 27 Jun 2026 19:35:45 +0200 Subject: [PATCH] feat(consumer): versioned IR manifest + drift-check (WHYNOT-WP-0003 T03-T07,T09) Make ir/ the unit of versioned downstream consumption so consuming repos can pin a version, inspect it, and follow changes at their own pace. - T03 ir/manifest.json: per-version inventory + diff anchor with deterministic sha256-over-canonicalised-JSON hashes; no-churn generatedAt; manifest schema. - T07 ir/INDEX.md: human-readable catalog generated by make ir. - T04 .whynot-design.lock sync-point format + lock schema. - T05 npx @whynot/design drift: consumer drift-check (bin entry), exit 0/2/3, --json/--update/--manifest/--version/--lock. - T06 CONSUMING.md guide + examples/consumer-fixture/ runnable demo; README + MultiFrameworkSupport cross-links; fix README version pin (@0.3.0 not @v0.3.0). - T09 CONSUMER_CONTRACT_PARITY.md design-only note (live-UI parity deferred). T02 (publish) and T08 (showcase, blocked on WP-0002 T11) remain wait. Repo stays in dev mode; no outward publish performed. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 20 ++ CONSUMER_CONTRACT_PARITY.md | 74 +++++++ CONSUMING.md | 150 +++++++++++++ MultiFrameworkSupport.md | 5 + README.md | 23 +- bin/whynot-design.mjs | 207 ++++++++++++++++++ examples/consumer-fixture/README.md | 36 +++ examples/consumer-fixture/adopted.lock | 19 ++ examples/consumer-fixture/run.sh | 38 ++++ ir/INDEX.md | 130 +++++++++++ ir/README.md | 11 + ir/SCHEMA.md | 39 ++++ ir/manifest.json | 58 +++++ ir/schema/lock.schema.json | 48 ++++ ir/schema/manifest.schema.json | 50 +++++ package.json | 5 + scripts/ir-extract.mjs | 124 +++++++++++ .../WHYNOT-WP-0003-downstream-consumption.md | 12 +- 18 files changed, 1042 insertions(+), 7 deletions(-) create mode 100644 CONSUMER_CONTRACT_PARITY.md create mode 100644 CONSUMING.md create mode 100755 bin/whynot-design.mjs create mode 100644 examples/consumer-fixture/README.md create mode 100644 examples/consumer-fixture/adopted.lock create mode 100755 examples/consumer-fixture/run.sh create mode 100644 ir/INDEX.md create mode 100644 ir/manifest.json create mode 100644 ir/schema/lock.schema.json create mode 100644 ir/schema/manifest.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bfb6a..30fe3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Added +- **Versioned IR manifest + consumer drift-check** (WHYNOT-WP-0003, Phase 1–4). The + `ir/` contract is now the unit of versioned downstream consumption: + - `ir/manifest.json` (T03) — per-version inventory + diff anchor: + `{ schemaVersion, designVersion, generatedAt, tokensHash, components:[{name,group,hash}] }`, + each hash a deterministic sha256 over canonicalised JSON (formatting-invariant, + `generatedAt` reused on no-op runs → no git churn). Schema: + `ir/schema/manifest.schema.json`. Emitted by `make ir`. + - `ir/INDEX.md` (T07) — human-readable catalog generated from the contracts; + browse a version without cloning or running anything. Emitted by `make ir`. + - **`npx @whynot/design drift`** (T05) — consumer-side drift-check (`bin/whynot-design.mjs`, + new `bin` entry). Compares a consumer's adopted `.whynot-design.lock` against the + installed package's manifest and reports added/changed/removed components + token + changes (`--json`, `--update`, `--manifest`, `--version`, `--lock`). Exit codes + mirror the adapter contract: `0` in sync · `2` usage error · `3` drift. + - `.whynot-design.lock` sync-point format (T04) — `ir/schema/lock.schema.json` + + documented lifecycle (consumer-side mirror of `designbook/.design-sync.json`). + - `CONSUMING.md` (T06) — pin → inspect → drift → update guide, with a runnable + `examples/consumer-fixture/`; cross-linked from `README.md` and `MultiFrameworkSupport.md`. + - `CONSUMER_CONTRACT_PARITY.md` (T09) — design-only note + recorded go/defer + decision for the heavier live-UI-vs-contract parity mode (deferred). - **Publishable to the coulomb Gitea npm registry** (WHYNOT-WP-0003 T02) — `private:false`, `publishConfig.registry`, real `repository.url`, an `.npmrc` scope + `${NPM_AUTH_TOKEN}` reference (no secret committed), and `PUBLISHING.md` (publish flow + consumer install + diff --git a/CONSUMER_CONTRACT_PARITY.md b/CONSUMER_CONTRACT_PARITY.md new file mode 100644 index 0000000..2e135c7 --- /dev/null +++ b/CONSUMER_CONTRACT_PARITY.md @@ -0,0 +1,74 @@ +# Design note — consumer-side contract parity (WHYNOT-WP-0003 · T09) + +> **Status: design-only. Deferred — do not implement.** This note captures the +> shape of the richer drift mode so the decision to build it is informed, not so +> it gets built now. The must-have is the **snapshot diff** (`drift`, T05); this is +> the heavier, per-stack second mode. + +## What it is + +The shipped `drift` check (T05) compares two **manifests** — the consumer's +adopted `.whynot-design.lock` against a target `ir/manifest.json`. It answers +*"what changed in the contract between the version I adopted and this one?"* It +never looks at the consumer's actual UI. + +**Contract parity** is the inverse question: *"does my live rendered UI still +match the contract I adopted?"* It compares a consumer's **live rendered +elements' observed attributes / properties** against the IR component contracts +(`ir/components/*.json`) — the consumer-side mirror of the upstream adapter parity +(WHYNOT-WP-0002 · T08), which checks the Lit stack against the designbook +exemplars. + +``` +T05 (shipped): .whynot-design.lock ── snapshot diff ──▶ ir/manifest.json + (contract vs contract, version-to-version) + +T09 (this note): live rendered elements ── parity ──▶ ir/components/*.json + (running UI vs contract, per-stack introspection) +``` + +## Why it is heavier (the reason to defer) + +Snapshot diff is pure data: hash vs hash, no runtime, no DOM, network-free, +stack-agnostic. Contract parity needs to **observe a running UI**, which makes it +fundamentally per-stack: + +- **Introspection substrate.** You must render each component and read back its + realised attributes/properties/slots — a browser/JSDOM/per-framework harness + the consumer has to stand up. There is no framework-neutral way to enumerate + "what props did this element actually accept." +- **Coverage problem.** A consumer renders components with *its own* prop + combinations; parity can only check what the consumer actually mounts, so + "no parity failures" ≠ "fully conformant." It needs a fixture/exemplar set, + which re-introduces per-stack authoring. +- **Non-portable props.** React objects / render-props / callbacks + (`portable:false` in the IR) have no attribute form to observe — exactly the + class the adapter contract already surfaces as drift. Parity would have to + decide, per stack, what "matching" even means for them. +- **Maintenance surface.** It is a second, stack-specific tool to keep in step + with every IR shape change, for a check the snapshot diff already covers at the + contract level. + +## Proposed shape (if/when built) + +- A `parity` subcommand alongside `drift`, opt-in, requiring the consumer to + declare a render harness + a fixture set (which elements, which prop combos). +- Reuse of the adapter parity result shape (`adapters/ADAPTER_CONTRACT.md` + "Parity result — minimal machine shape") so upstream and downstream parity read + identically, and the existing exit codes (`0` ok · `4` parity failure). +- Per-element issues drawn from the same vocabulary as adapter drift: + `prop-missing`, `attribute-mismatch`, `variant-missing`, `removed-prop`, + `non-portable`. + +## Decision + +**Defer.** Ship the snapshot diff (`drift`, T05) as the must-have downstream +check; record contract parity as a known, designed-but-unbuilt second mode. It +becomes worth building only when a consuming repo has a real conformance need +(e.g. an automated gate that its live UI has not silently diverged from an adopted +contract) — at which point this note is the starting blueprint. Tracked as the +go/defer decision recorded against this workplan via `record_decision`. + +This also keeps the **one-way constraint** intact: like `drift`, a future +`parity` is read-only against the package and writes nothing back to +whynot-design — it only observes the consumer's own UI. diff --git a/CONSUMING.md b/CONSUMING.md new file mode 100644 index 0000000..45316ba --- /dev/null +++ b/CONSUMING.md @@ -0,0 +1,150 @@ +# Consuming whynot-design from another repo + +whynot-design is the **upstream visual reference** for other repos. It is a +development reference and demo platform — it does not run as a production +workload and handles no critical data. Consuming repos build their production +UIs *from* it, and follow up on changes **at their own pace** — they are never +force-synced. + +A consumer tracks the **IR** (`ir/`), not the Lit internals. The IR is the +technology-neutral contract: per-component contracts (`ir/components/*.json`), +W3C-DTCG tokens (`ir/tokens.json`), exemplars, and the version anchor +`ir/manifest.json`. Three moves make this work: + +1. **Pin** a version — the package + your lockfile. +2. **Inspect** it — `ir/INDEX.md` (browsable) + `ir/manifest.json` (machine). +3. **Get a grip on changes** — `npx @whynot/design drift`. + +This is the inverse of whynot-design's own upstream machinery +(`Claude Design → designbook/ → ir/`), now pointed downstream +(`ir/ → your repo`). It is **one-way**: you read the IR; you never write back. + +--- + +## 1. Pin a version + +`@whynot/design` is published to the coulomb Gitea npm registry. Pin an exact +tagged version; your lockfile becomes the real pin. + +```bash +# .npmrc in your repo (see PUBLISHING.md for the read-token routing) +# @whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/ + +npm i @whynot/design@0.3.0 lit +``` + +`lit` is a **peer dependency** — install it alongside so your bundler dedupes to +a single `lit` instance. + +## 2. Inspect what you pinned + +No clone, no build needed: + +- **`node_modules/@whynot/design/ir/INDEX.md`** — human-readable catalog: every + component, its tag, props/variants/slots/events, and a link to its exemplar. +- **`node_modules/@whynot/design/ir/manifest.json`** — the machine inventory: + `designVersion`, a `tokensHash`, and a content `hash` per component. + +## 3. Adopt a sync-point + +Record which IR state your repo has reconciled against. Run once, in your repo root: + +```bash +npx @whynot/design drift --update +``` + +This writes **`.whynot-design.lock`** — commit it. It is the consumer-side mirror +of whynot-design's own `designbook/.design-sync.json`. + +### `.whynot-design.lock` format + +```json +{ + "designVersion": "0.3.0", + "adoptedAt": "2026-06-27T17:31:08.640Z", + "manifestSchemaVersion": "1.0.0", + "manifestHashes": { + "tokens": "sha256:426f565a9ce6c36f", + "components": { + "Button": "sha256:4a32713049e433dd", + "TopNav": "sha256:32ebc6e46db38f93" + } + } +} +``` + +| field | meaning | +| --- | --- | +| `designVersion` | the `@whynot/design` version you adopted | +| `adoptedAt` | when you adopted it (first adopt, or last `drift --update`) | +| `manifestSchemaVersion` | the manifest's shape version; a mismatch warns that hashes may not be directly comparable | +| `manifestHashes.tokens` | adopted value of the manifest `tokensHash` | +| `manifestHashes.components` | adopted content hash per component | + +**Lifecycle:** created on first `drift --update`, advanced only by a later +`drift --update`. Nothing else writes it. Schema: `ir/schema/lock.schema.json`. + +## 4. Follow up at your own pace + +When you bump `@whynot/design` (or just want to know what moved), run: + +```bash +npx @whynot/design drift +``` + +It compares your adopted `.whynot-design.lock` against the installed package's +`ir/manifest.json` and reports **added / changed / removed** components plus +whether **tokens** changed: + +``` +whynot-design drift + adopted: 0.3.0 (2026-06-27T17:31:08.640Z) + target: 0.4.0 (2026-07-10T09:02:11.400Z) + +Tokens: changed +Components: +1 added · ~1 changed · -0 removed · 11 total + + Banner + ~ Button + +Drift detected vs your adopted sync-point. +Adopt this version: npx @whynot/design drift --update +``` + +Then, when *you* are ready, review the changed contracts in `ir/INDEX.md`, update +your UI, and adopt the new sync-point: + +```bash +npx @whynot/design drift --update +``` + +### Exit codes (CI-friendly) + +Mirrors the adapter contract (`adapters/ADAPTER_CONTRACT.md`): + +| code | meaning | +| --- | --- | +| `0` | in sync — your lock matches the target | +| `2` | usage / config error (bad flag, missing/invalid manifest or lock) | +| `3` | **drift detected** — something changed since your sync-point | + +Add `--json` for automation. Useful flags: `--manifest ` (diff against an +explicit manifest, e.g. a fetched newer version on disk), `--version ` +(assert the resolved manifest is that version — guards against the wrong install), +`--lock ` (non-default lock location). + +> **No network, no writes to the package.** `drift` reads only the +> already-installed package + your lock, and the only file it ever writes is your +> repo's `.whynot-design.lock`. + +--- + +## Try the full loop now + +A copy-pasteable fixture lives at +[`examples/consumer-fixture/`](./examples/consumer-fixture/) — it exercises +pin → inspect → drift → update against a fixed version without needing a real +install. See its `README.md`. + +See also: [`README.md`](./README.md) *Tracking whynot-design* · +[`MultiFrameworkSupport.md`](./MultiFrameworkSupport.md) · +[`ir/SCHEMA.md`](./ir/SCHEMA.md). diff --git a/MultiFrameworkSupport.md b/MultiFrameworkSupport.md index 3e84ca6..8a75e5f 100644 --- a/MultiFrameworkSupport.md +++ b/MultiFrameworkSupport.md @@ -23,6 +23,11 @@ That tag is valid in: You do not write a different component per framework. You write the same custom element. The framework decides how to pass props (`variant="primary"` in HTML, `variant="primary"` in JSX, `:variant="…"` in Vue). +> **Tracking versions, not just using them.** Whatever framework you wire it into, a +> consuming repo also pins a version and follows changes at its own pace via the +> technology-neutral IR + the `npx @whynot/design drift` check. See +> [`CONSUMING.md`](./CONSUMING.md). + --- ## Architecture recap diff --git a/README.md b/README.md index c872041..9147755 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Install from the coulomb Gitea npm registry (add the scope to your `.npmrc` firs ``` ```sh -npm i @whynot/design@v0.3.0 lit +npm i @whynot/design@0.3.0 lit # or pin straight from git: pnpm add git+ssh://git@gitea.coulomb.social/coulomb/whynot-design.git#v0.3.0 ``` @@ -64,6 +64,27 @@ import "@whynot/design"; ``` +### Tracking whynot-design from a consuming repo + +The sections above cover *using* components. Consuming repos also need to follow +whynot-design's evolution **at their own pace** — pin a version, see what it +contains, and get a grip on what changed before adopting a newer one. You track +the technology-neutral **IR** (`ir/`), never the Lit internals: + +```sh +npm i @whynot/design@0.3.0 lit # 1. pin (your lockfile is the real pin) +# inspect: node_modules/@whynot/design/ir/INDEX.md + ir/manifest.json +npx @whynot/design drift --update # 2. adopt a sync-point → .whynot-design.lock (commit it) +# ...later, after bumping the package... +npx @whynot/design drift # 3. report added/changed/removed components + token changes + # exit 0 in sync · 3 drift · 2 usage error +``` + +When you're ready, review the changed contracts in `ir/INDEX.md`, update your UI, +and `npx @whynot/design drift --update` to adopt the new sync-point. Full guide: +**[`CONSUMING.md`](./CONSUMING.md)** · runnable demo: +[`examples/consumer-fixture/`](./examples/consumer-fixture/). + ### Django ```django diff --git a/bin/whynot-design.mjs b/bin/whynot-design.mjs new file mode 100755 index 0000000..76761db --- /dev/null +++ b/bin/whynot-design.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// ============================================================= +// whynot-design CLI — consumer drift-check (WHYNOT-WP-0003 · T05) +// +// Runs IN A CONSUMING REPO: npx @whynot/design drift +// +// Compares the consumer's adopted sync-point (.whynot-design.lock) against the +// installed package's ir/manifest.json (or an explicit --manifest), and reports +// added / changed / removed components + token changes. Read-only against the +// package; the only file it writes is .whynot-design.lock (and only on --update). +// +// This is the DOWNSTREAM mirror of the upstream adapter drift +// (adapters/ADAPTER_CONTRACT.md) — same report shape, same exit codes: +// 0 in sync 2 usage/config error 3 drift detected +// ============================================================= +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join, dirname, resolve, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const EXIT = { OK: 0, USAGE: 2, DRIFT: 3 }; + +function fail(msg) { + process.stderr.write(`whynot-design: ${msg}\n`); + process.exit(EXIT.USAGE); +} + +function readJson(path, label) { + if (!existsSync(path)) fail(`${label} not found at ${path}`); + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + fail(`${label} at ${path} is not valid JSON: ${e.message}`); + } +} + +function parseArgs(argv) { + const args = { _: [], flags: {} }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--json" || a === "--update") args.flags[a.slice(2)] = true; + else if (a === "--lock" || a === "--manifest" || a === "--version") args.flags[a.slice(2)] = argv[++i]; + else if (a.startsWith("--")) fail(`unknown flag ${a}`); + else args._.push(a); + } + return args; +} + +function resolvePath(p, base) { + return isAbsolute(p) ? p : resolve(base, p); +} + +// ---------- drift core ---------- +// Compare an adopted lock against a target manifest. Pure; reused for --json, +// the human report, and --update. Mirrors the adapter drift component shape. +function computeDrift(lock, manifest) { + const adopted = lock.manifestHashes.components || {}; + const current = Object.fromEntries(manifest.components.map((c) => [c.name, c.hash])); + const groupOf = Object.fromEntries(manifest.components.map((c) => [c.name, c.group])); + + const names = [...new Set([...Object.keys(adopted), ...Object.keys(current)])].sort(); + const components = []; + for (const name of names) { + if (!(name in adopted)) components.push({ name, group: groupOf[name], status: "added" }); + else if (!(name in current)) components.push({ name, status: "removed" }); + else if (adopted[name] !== current[name]) components.push({ name, group: groupOf[name], status: "changed", from: adopted[name], to: current[name] }); + else components.push({ name, group: groupOf[name], status: "ok" }); + } + + const tokensChanged = lock.manifestHashes.tokens !== manifest.tokensHash; + const drifted = tokensChanged || components.some((c) => c.status !== "ok"); + + return { + tool: "@whynot/design drift", + generatedAt: new Date().toISOString(), + adopted: { designVersion: lock.designVersion, adoptedAt: lock.adoptedAt }, + target: { designVersion: manifest.designVersion, generatedAt: manifest.generatedAt }, + schemaVersionMismatch: lock.manifestSchemaVersion !== manifest.schemaVersion + ? { adopted: lock.manifestSchemaVersion, target: manifest.schemaVersion } + : null, + tokens: { status: tokensChanged ? "changed" : "ok" }, + components, + drift: drifted, + }; +} + +function lockFromManifest(manifest) { + return { + designVersion: manifest.designVersion, + adoptedAt: new Date().toISOString(), + manifestSchemaVersion: manifest.schemaVersion, + manifestHashes: { + tokens: manifest.tokensHash, + components: Object.fromEntries(manifest.components.map((c) => [c.name, c.hash])), + }, + }; +} + +function printHuman(report) { + const out = []; + out.push(`whynot-design drift`); + out.push(` adopted: ${report.adopted.designVersion} (${report.adopted.adoptedAt})`); + out.push(` target: ${report.target.designVersion} (${report.target.generatedAt})`); + if (report.schemaVersionMismatch) { + out.push(` ! manifest schemaVersion differs (${report.schemaVersionMismatch.adopted} → ${report.schemaVersionMismatch.target}); hashes may not be directly comparable.`); + } + out.push(""); + + const added = report.components.filter((c) => c.status === "added"); + const changed = report.components.filter((c) => c.status === "changed"); + const removed = report.components.filter((c) => c.status === "removed"); + + out.push(`Tokens: ${report.tokens.status === "changed" ? "changed" : "unchanged"}`); + out.push(`Components: +${added.length} added · ~${changed.length} changed · -${removed.length} removed · ${report.components.length} total`); + if (added.length) out.push(` + ${added.map((c) => c.name).join(", ")}`); + if (changed.length) out.push(` ~ ${changed.map((c) => c.name).join(", ")}`); + if (removed.length) out.push(` - ${removed.map((c) => c.name).join(", ")}`); + out.push(""); + + if (report.drift) { + out.push(`Drift detected vs your adopted sync-point.`); + out.push(`Adopt this version: npx @whynot/design drift --update`); + } else { + out.push(`In sync with ${report.target.designVersion}. No drift.`); + } + process.stdout.write(out.join("\n") + "\n"); +} + +// ---------- drift command ---------- +function cmdDrift(args) { + const cwd = process.cwd(); + const lockPath = resolvePath(args.flags.lock || ".whynot-design.lock", cwd); + const manifestPath = args.flags.manifest + ? resolvePath(args.flags.manifest, cwd) + : join(PKG_ROOT, "ir", "manifest.json"); + + const manifest = readJson(manifestPath, "ir/manifest.json"); + if (!Array.isArray(manifest.components) || typeof manifest.tokensHash !== "string") { + fail(`${manifestPath} is not a valid ir/manifest.json`); + } + if (args.flags.version && manifest.designVersion !== args.flags.version) { + fail(`--version ${args.flags.version} does not match the resolved manifest (designVersion ${manifest.designVersion}). Install that version or point --manifest at it.`); + } + + // First adopt: no lock yet. --update bootstraps it; otherwise guide the user. + if (!existsSync(lockPath)) { + if (args.flags.update) { + writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n"); + if (args.flags.json) process.stdout.write(JSON.stringify({ adopted: manifest.designVersion, created: true }, null, 2) + "\n"); + else process.stdout.write(`Adopted ${manifest.designVersion} as the initial sync-point → ${lockPath}\n`); + return EXIT.OK; + } + fail(`no .whynot-design.lock found. Adopt the installed version first:\n npx @whynot/design drift --update`); + } + + const lock = readJson(lockPath, ".whynot-design.lock"); + if (!lock.manifestHashes || !lock.manifestHashes.components) { + fail(`${lockPath} is missing manifestHashes.components`); + } + + const report = computeDrift(lock, manifest); + + if (args.flags.update) { + writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n"); + if (args.flags.json) process.stdout.write(JSON.stringify({ ...report, updated: true }, null, 2) + "\n"); + else { + printHuman(report); + process.stdout.write(`\nAdopted ${manifest.designVersion} → ${lockPath}\n`); + } + return EXIT.OK; // --update reconciles, so it always lands in sync + } + + if (args.flags.json) process.stdout.write(JSON.stringify(report, null, 2) + "\n"); + else printHuman(report); + + return report.drift ? EXIT.DRIFT : EXIT.OK; +} + +// ---------- dispatch ---------- +function main() { + const argv = process.argv.slice(2); + const args = parseArgs(argv); + const cmd = args._[0]; + + if (!cmd || cmd === "help" || args.flags.help) { + process.stdout.write(`whynot-design — consumer-side design-system tooling + +Usage: + npx @whynot/design drift [options] Report changes since your adopted sync-point + +Options: + --update Adopt the target version as the new sync-point (writes .whynot-design.lock) + --json Machine-readable output + --manifest Diff against an explicit ir/manifest.json (default: the installed package's) + --version Assert the resolved manifest is this version (guards against the wrong install) + --lock Path to the consumer lock (default: ./.whynot-design.lock) + +Exit codes: 0 in sync · 2 usage/config error · 3 drift detected +`); + return EXIT.OK; + } + + if (cmd === "drift") return cmdDrift(args); + fail(`unknown command '${cmd}'. Try: npx @whynot/design help`); +} + +process.exit(main()); diff --git a/examples/consumer-fixture/README.md b/examples/consumer-fixture/README.md new file mode 100644 index 0000000..11f30b2 --- /dev/null +++ b/examples/consumer-fixture/README.md @@ -0,0 +1,36 @@ +# consumer-fixture — the drift loop, copy-pasteable + +A tiny stand-in for a repo that **consumes** `@whynot/design`. It exercises the +full downstream loop — **pin → inspect → drift → update** — against this repo's +own `ir/manifest.json`, with no real npm install, so you can see exactly what a +consumer experiences. + +```bash +./run.sh +``` + +What it shows: + +1. **Inspect** — the head of `ir/INDEX.md` (the browsable catalog of the version). +2. **drift** — [`adopted.lock`](./adopted.lock) is a sample `.whynot-design.lock` + pinned to a pretend older `0.2.0` sync-point (Button changed since, TopNav added + since, tokens changed). `drift` reports those and exits `3`. +3. **drift --update** — adopts the current version as the new sync-point. +4. **drift** again — now in sync, exits `0`. + +The run is **non-destructive**: `adopted.lock` is copied into a scratch dir and +only the copy is mutated. + +## In a real consuming repo + +You would **not** pass `--manifest`/`--lock`. The installed package supplies its +own `ir/manifest.json`, and the lock lives at `./.whynot-design.lock`: + +```bash +npm i @whynot/design@0.3.0 lit # pin +npx @whynot/design drift --update # adopt a sync-point (writes .whynot-design.lock) +# ...later, after bumping the version... +npx @whynot/design drift # see what changed; exit 3 on drift +``` + +Full guide: [`../../CONSUMING.md`](../../CONSUMING.md). diff --git a/examples/consumer-fixture/adopted.lock b/examples/consumer-fixture/adopted.lock new file mode 100644 index 0000000..6cb5ce0 --- /dev/null +++ b/examples/consumer-fixture/adopted.lock @@ -0,0 +1,19 @@ +{ + "designVersion": "0.2.0", + "adoptedAt": "2026-05-01T12:00:00.000Z", + "manifestSchemaVersion": "1.0.0", + "manifestHashes": { + "tokens": "sha256:00000000bbbbbbbb", + "components": { + "Button": "sha256:00000000aaaaaaaa", + "Eyebrow": "sha256:56baa59a49b5f32c", + "Icon": "sha256:2557fbffb3aa6ee1", + "PageHeader": "sha256:93e12068e2f58f10", + "PipelineStrip": "sha256:89c40afe4742d64e", + "Sidebar": "sha256:8340b292fff7a80d", + "StageDot": "sha256:f6f7790aa886261e", + "Stamp": "sha256:0b32f43ed19ac470", + "Tag": "sha256:91ee34eac1457016" + } + } +} diff --git a/examples/consumer-fixture/run.sh b/examples/consumer-fixture/run.sh new file mode 100755 index 0000000..ced8c8a --- /dev/null +++ b/examples/consumer-fixture/run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Exercise the consumer drift loop against this repo's own ir/manifest.json, +# without a real npm install. Non-destructive: the committed adopted.lock is +# copied into a scratch dir; only that copy is mutated. +# +# In a REAL consuming repo you would not pass --manifest at all — the installed +# @whynot/design package supplies its own ir/manifest.json, and the lock lives at +# ./.whynot-design.lock. Here we point at the repo copy so the demo is hermetic. +set -euo pipefail +cd "$(dirname "$0")" + +REPO_ROOT="$(cd ../.. && pwd)" +CLI="$REPO_ROOT/bin/whynot-design.mjs" +MANIFEST="$REPO_ROOT/ir/manifest.json" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cp adopted.lock "$WORK/.whynot-design.lock" +LOCK="$WORK/.whynot-design.lock" + +echo "### 1. Inspect — what's in this version" +sed -n '1,6p' "$REPO_ROOT/ir/INDEX.md" +echo + +echo "### 2. drift — what changed since the adopted (0.2.0) sync-point (expect exit 3)" +set +e +node "$CLI" drift --manifest "$MANIFEST" --lock "$LOCK" +echo "(exit $?)" +set -e +echo + +echo "### 3. drift --update — adopt the current version as the new sync-point" +node "$CLI" drift --manifest "$MANIFEST" --lock "$LOCK" --update >/dev/null +echo + +echo "### 4. drift again — now in sync (expect exit 0)" +node "$CLI" drift --manifest "$MANIFEST" --lock "$LOCK" +echo "(exit $?)" diff --git a/ir/INDEX.md b/ir/INDEX.md new file mode 100644 index 0000000..e7e36f4 --- /dev/null +++ b/ir/INDEX.md @@ -0,0 +1,130 @@ + +# whynot-design IR catalog + +**designVersion** `0.3.0` · **components** 10 · **generated** 2026-06-27T17:28:08.913Z + +Machine-readable companion: [`manifest.json`](./manifest.json) (per-component + token hashes). + +## atoms + +### Button `` + +Button — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `variant` | `variant` | enum(secondary \| primary \| ghost) | `secondary` | +| `icon` | `icon` | boolean | — | + +_Non-portable (React-only) props:_ `onClick`, `style`. + +**Slots:** `default` +**Events:** `wn-click` +**Variants:** variant (secondary/primary/ghost) +**Contract:** [`components/Button.json`](./components/Button.json) · **hash** `sha256:4a32713049e433dd` · **exemplar:** [`exemplars/Button.html`](./exemplars/Button.html) + +### Eyebrow `` + +Eyebrow — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +_Non-portable (React-only) props:_ `style`. + +**Slots:** `default` +**Contract:** [`components/Eyebrow.json`](./components/Eyebrow.json) · **hash** `sha256:56baa59a49b5f32c` · **exemplar:** [`exemplars/Eyebrow.html`](./exemplars/Eyebrow.html) + +### Icon `` + +Icon — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `name` | `name` | string | — | +| `size` | `size` | number | `16` | + +_Non-portable (React-only) props:_ `style`. + +**Contract:** [`components/Icon.json`](./components/Icon.json) · **hash** `sha256:2557fbffb3aa6ee1` + +### StageDot `` + +StageDot — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `level` | `level` | string | `S2` | +| `label` | `label` | string | — | + +_Non-portable (React-only) props:_ `style`. + +**Contract:** [`components/StageDot.json`](./components/StageDot.json) · **hash** `sha256:f6f7790aa886261e` · **exemplar:** [`exemplars/StageDot.html`](./exemplars/StageDot.html) + +### Stamp `` + +Stamp — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +_Non-portable (React-only) props:_ `style`. + +**Slots:** `default` +**Contract:** [`components/Stamp.json`](./components/Stamp.json) · **hash** `sha256:0b32f43ed19ac470` + +### Tag `` + +Tag — extracted from designbook ui_kits/whynot-control/Atoms.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `active` | `active` | boolean | — | +| `draft` | `draft` | boolean | — | + +_Non-portable (React-only) props:_ `style`. + +**Slots:** `default` +**Contract:** [`components/Tag.json`](./components/Tag.json) · **hash** `sha256:91ee34eac1457016` · **exemplar:** [`exemplars/Tag.html`](./exemplars/Tag.html) + +## chrome + +### PageHeader `` + +PageHeader — extracted from designbook ui_kits/whynot-control/Chrome.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `eyebrow` | `eyebrow` | boolean | — | +| `title` | `title` | string | — | +| `lede` | `lede` | boolean | — | +| `actions` | `actions` | boolean | — | + +**Contract:** [`components/PageHeader.json`](./components/PageHeader.json) · **hash** `sha256:93e12068e2f58f10` · **exemplar:** [`exemplars/PageHeader.html`](./exemplars/PageHeader.html) + +### PipelineStrip `` + +PipelineStrip — extracted from designbook ui_kits/whynot-control/Chrome.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `activeIdx` | `active-idx` | number | `3` | + +**Contract:** [`components/PipelineStrip.json`](./components/PipelineStrip.json) · **hash** `sha256:89c40afe4742d64e` · **exemplar:** [`exemplars/PipelineStrip.html`](./exemplars/PipelineStrip.html) + +### Sidebar `` + +Sidebar — extracted from designbook ui_kits/whynot-control/Chrome.jsx. + +| prop | attribute | type | default | +| --- | --- | --- | --- | +| `current` | `current` | enum(doc:) | — | + +_Non-portable (React-only) props:_ `onNav`. + +**Events:** `wn-nav` +**Variants:** current (doc:) +**Contract:** [`components/Sidebar.json`](./components/Sidebar.json) · **hash** `sha256:8340b292fff7a80d` · **exemplar:** [`exemplars/Sidebar.html`](./exemplars/Sidebar.html) + +### TopNav `` + +TopNav — extracted from designbook ui_kits/whynot-control/Chrome.jsx. + +_Non-portable (React-only) props:_ `onNew`. + +**Events:** `wn-new` +**Contract:** [`components/TopNav.json`](./components/TopNav.json) · **hash** `sha256:32ebc6e46db38f93` · **exemplar:** [`exemplars/TopNav.html`](./exemplars/TopNav.html) diff --git a/ir/README.md b/ir/README.md index 3921e24..e6dfa4c 100644 --- a/ir/README.md +++ b/ir/README.md @@ -16,8 +16,19 @@ What is committed: - `tokens.json` — all tokens, W3C DTCG format. - `components/.json` — one contract per component. - `exemplars/.{png,html}` — reference renders from the designbook preview. +- `manifest.json` — the per-version inventory + diff anchor: `{ schemaVersion, + designVersion, generatedAt, tokensHash, components: [{ name, group, hash }] }`, + where each hash is a deterministic content hash (sha256 over canonicalised JSON). + This is what a consuming repo pins against and what the `drift` check compares + (WHYNOT-WP-0003). Validated by `schema/manifest.schema.json`. +- `INDEX.md` — the human-readable catalog, generated from the same contracts: + per component its group, description, props/variants/slots/events, and a link + to its exemplar. Browse a version without cloning or running anything. - `schema/` + `SCHEMA.md` — the contract definitions (this is what T01 delivered). +`manifest.json` and `INDEX.md` are generated by the same extractor as the rest of +`ir/` and share its one-way rule — **do not hand-edit them.** + ## Direction of flow ``` diff --git a/ir/SCHEMA.md b/ir/SCHEMA.md index 83f4872..be8c729 100644 --- a/ir/SCHEMA.md +++ b/ir/SCHEMA.md @@ -24,11 +24,50 @@ ir/ schema/ tokens.schema.json ← JSON Schema for tokens.json (W3C DTCG) component.schema.json ← JSON Schema for each components/.json + manifest.schema.json ← JSON Schema for manifest.json (version + hashes) tokens.json ← all design tokens, W3C DTCG format (emitted by T05) components/.json ← one contract per component (emitted by T05) exemplars/.{png,html} ← reference render from the designbook (emitted by T05) + manifest.json ← per-version inventory + diff anchor (WHYNOT-WP-0003 T03) + INDEX.md ← human-readable catalog (WHYNOT-WP-0003 T07) ``` +## Version manifest (`manifest.json`) — the diff anchor + +`ir/manifest.json` is the unit of **versioned consumption**. A consuming repo never +reads the Lit internals; it pins a published `@whynot/design@X.Y.Z` and tracks the +manifest. Shape: + +```json +{ + "schemaVersion": "1.0.0", + "designVersion": "0.3.0", + "generatedAt": "2026-06-27T17:28:08.913Z", + "tokensHash": "sha256:426f565a9ce6c36f", + "components": [ + { "name": "Button", "group": "atoms", "hash": "sha256:4a32713049e433dd" } + ] +} +``` + +- **Each `hash`** is `sha256:` + the first 16 hex chars of a sha256 over the + *canonicalised* contract (keys sorted recursively, no insignificant whitespace). + Canonicalisation makes the hash invariant to formatting and sensitive **only** to + meaningful change — re-running `make ir` after a no-op edit yields the same hash. +- **`tokensHash`** is a single coarse hash of the whole token set. Per-token diff + granularity is a deliberate later refinement (start coarse, refine if consumers ask). +- **`schemaVersion`** governs hash stability: any extractor change that would alter + existing hashes *without* a real design change must bump it, so a consumer can tell + a re-canonicalisation apart from a genuine design move. It is distinct from + `designVersion` (the package/tag version) and from the component schema's own shape. +- **`generatedAt`** is informational and never hashed; it is reused from the prior + manifest when nothing hashed changed, so a no-op `make ir` produces no git churn. + +The downstream consumer `drift` check (WHYNOT-WP-0003 T05) compares a target +manifest against the consumer's adopted `.whynot-design.lock` and reports added / +changed / removed components plus token changes — the mirror of the upstream adapter +drift, sharing the `0` ok · `3` drift exit-code convention. + ## Tokens — `ir/tokens.json` Adopts the **W3C Design Tokens Community Group** format: every token is an object diff --git a/ir/manifest.json b/ir/manifest.json new file mode 100644 index 0000000..19b236a --- /dev/null +++ b/ir/manifest.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": "1.0.0", + "designVersion": "0.3.0", + "generatedAt": "2026-06-27T17:28:08.913Z", + "tokensHash": "sha256:426f565a9ce6c36f", + "components": [ + { + "name": "Button", + "group": "atoms", + "hash": "sha256:4a32713049e433dd" + }, + { + "name": "Eyebrow", + "group": "atoms", + "hash": "sha256:56baa59a49b5f32c" + }, + { + "name": "Icon", + "group": "atoms", + "hash": "sha256:2557fbffb3aa6ee1" + }, + { + "name": "PageHeader", + "group": "chrome", + "hash": "sha256:93e12068e2f58f10" + }, + { + "name": "PipelineStrip", + "group": "chrome", + "hash": "sha256:89c40afe4742d64e" + }, + { + "name": "Sidebar", + "group": "chrome", + "hash": "sha256:8340b292fff7a80d" + }, + { + "name": "StageDot", + "group": "atoms", + "hash": "sha256:f6f7790aa886261e" + }, + { + "name": "Stamp", + "group": "atoms", + "hash": "sha256:0b32f43ed19ac470" + }, + { + "name": "Tag", + "group": "atoms", + "hash": "sha256:91ee34eac1457016" + }, + { + "name": "TopNav", + "group": "chrome", + "hash": "sha256:32ebc6e46db38f93" + } + ] +} diff --git a/ir/schema/lock.schema.json b/ir/schema/lock.schema.json new file mode 100644 index 0000000..9085ee1 --- /dev/null +++ b/ir/schema/lock.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://whynot.design/ir/schema/lock.schema.json", + "title": "whynot-design — Consumer Sync-Point Lock", + "description": ".whynot-design.lock lives in a CONSUMING repo (not in whynot-design) and records which IR state that repo has adopted. It is the consumer-side mirror of designbook/.design-sync.json. Created on first adopt and advanced only by `whynot-design drift --update`. The drift check compares this against a target ir/manifest.json to report what changed since the consumer's adopted sync-point.", + "type": "object", + "required": ["designVersion", "adoptedAt", "manifestSchemaVersion", "manifestHashes"], + "additionalProperties": false, + "properties": { + "designVersion": { + "type": "string", + "description": "The @whynot/design version whose manifest was adopted (e.g. matches the installed package / tag vX.Y.Z)." + }, + "adoptedAt": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp this sync-point was adopted (first adopt or last `drift --update`)." + }, + "manifestSchemaVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "The adopted manifest's schemaVersion. If a target manifest has a different schemaVersion the drift check warns that hashes may not be directly comparable." + }, + "manifestHashes": { + "type": "object", + "description": "The subset of ir/manifest.json the consumer has reconciled against.", + "required": ["tokens", "components"], + "additionalProperties": false, + "properties": { + "tokens": { + "$ref": "#/$defs/hash", + "description": "Adopted value of the manifest's tokensHash." + }, + "components": { + "type": "object", + "description": "Map of component name → adopted content hash.", + "additionalProperties": { "$ref": "#/$defs/hash" } + } + } + } + }, + "$defs": { + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{16}$" + } + } +} diff --git a/ir/schema/manifest.schema.json b/ir/schema/manifest.schema.json new file mode 100644 index 0000000..47d5b98 --- /dev/null +++ b/ir/schema/manifest.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://whynot.design/ir/schema/manifest.schema.json", + "title": "whynot IR — Version Manifest", + "description": "ir/manifest.json — the per-version inventory and diff anchor. Generated only by scripts/ir-extract.mjs (make ir). Each hash is a deterministic content hash (sha256 over canonicalised JSON: keys sorted, no insignificant whitespace) so it is invariant to formatting and sensitive only to meaningful contract/token change. The consumer drift-check (WHYNOT-WP-0003 T05) compares two manifests, or a manifest against a consumer's .whynot-design.lock.", + "type": "object", + "required": ["schemaVersion", "designVersion", "generatedAt", "tokensHash", "components"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "description": "Shape version of THIS manifest. Bumped when the manifest layout or the hashing scheme changes, so a consumer can tell a re-canonicalisation apart from a real design change.", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "designVersion": { + "type": "string", + "description": "The package.json version this manifest was extracted at (e.g. the semver tied to a git tag vX.Y.Z)." + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp of extraction. Reused from the prior manifest when nothing hashed changed, so a no-op `make ir` does not churn the committed file. Informational only — never part of any hash." + }, + "tokensHash": { + "$ref": "#/$defs/hash", + "description": "Content hash of the full canonicalised ir/tokens.json. A single coarse hash by design (token-level diff granularity is a deferred refinement)." + }, + "components": { + "type": "array", + "description": "One entry per component contract, sorted by name.", + "items": { + "type": "object", + "required": ["name", "group", "hash"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9]*$" }, + "group": { "type": "string", "description": "The component's group (atoms, chrome, …)." }, + "hash": { "$ref": "#/$defs/hash", "description": "Content hash of the canonicalised ir/components/.json contract." } + } + } + } + }, + "$defs": { + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{16}$", + "description": "Algorithm-prefixed truncated digest: 'sha256:' + first 16 hex chars of sha256(canonical JSON)." + } + } +} diff --git a/package.json b/package.json index 88f149d..0d36dc6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "type": "module", "main": "./src/index.js", "module": "./src/index.js", + "bin": { + "whynot-design": "./bin/whynot-design.mjs" + }, "exports": { ".": "./src/index.js", "./atoms": "./src/elements/atoms.js", @@ -27,10 +30,12 @@ }, "files": [ "src", + "bin", "tokens", "ir", "assets", "adapters", + "CONSUMING.md", "SKILL.md", "DesignSystemIntroduction.md", "MultiFrameworkSupport.md", diff --git a/scripts/ir-extract.mjs b/scripts/ir-extract.mjs index 5c7e22a..5158d0f 100644 --- a/scripts/ir-extract.mjs +++ b/scripts/ir-extract.mjs @@ -17,6 +17,7 @@ // designbook/preview/comp-*.html → exemplar renders // ============================================================= import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { createHash } from "node:crypto"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -25,6 +26,12 @@ const DESIGNBOOK = join(REPO, "designbook"); const IR = join(REPO, "ir"); const KIT = "whynot-control"; +// Bump when the SHAPE of ir/manifest.json changes (consumers branch on this). +// Hash stability is governed here: any change that would alter existing hashes +// without a meaningful contract change must bump this so a consumer can tell a +// re-canonicalisation apart from a real design change. See ir/SCHEMA.md. +const MANIFEST_SCHEMA_VERSION = "1.0.0"; + // Which ui-kit files hold reusable design-system components (NOT app screens/demo data). const COMPONENT_SOURCES = ["Atoms.jsx", "Chrome.jsx"]; @@ -226,6 +233,113 @@ function validateContract(c) { return errs; } +// ---------- Manifest: deterministic content hashes (WHYNOT-WP-0003 · T03) ---------- +// Canonicalise before hashing so the hash is invariant to key order and +// whitespace and sensitive ONLY to meaningful contract/token change. +function canonicalise(value) { + if (Array.isArray(value)) return value.map(canonicalise); + if (value && typeof value === "object") { + return Object.keys(value).sort().reduce((acc, k) => { + acc[k] = canonicalise(value[k]); + return acc; + }, {}); + } + return value; +} + +function contentHash(value) { + return "sha256:" + createHash("sha256") + .update(JSON.stringify(canonicalise(value))) + .digest("hex") + .slice(0, 16); +} + +// Build ir/manifest.json. `generatedAt` is reused from the prior manifest when +// nothing hashed changed, so a no-op `make ir` does not churn the committed file. +function buildManifest(tokens, contracts) { + const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")); + const components = contracts + .map((c) => ({ name: c.name, group: c.group, hash: contentHash(c) })) + .sort((a, b) => a.name.localeCompare(b.name)); + const tokensHash = contentHash(tokens); + + const manifestPath = join(IR, "manifest.json"); + let generatedAt = new Date().toISOString(); + if (existsSync(manifestPath)) { + try { + const prev = JSON.parse(readFileSync(manifestPath, "utf8")); + const unchanged = + prev.schemaVersion === MANIFEST_SCHEMA_VERSION && + prev.designVersion === pkg.version && + prev.tokensHash === tokensHash && + JSON.stringify(prev.components) === JSON.stringify(components); + if (unchanged && prev.generatedAt) generatedAt = prev.generatedAt; + } catch { /* malformed prior manifest — regenerate fresh */ } + } + + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + designVersion: pkg.version, + generatedAt, + tokensHash, + components, + }; +} + +// ---------- Index: human-readable catalog (WHYNOT-WP-0003 · T07) ---------- +// A browsable view of a version — no clone or run needed. Generated from the +// same contracts the manifest hashes, so the two never disagree. +function buildIndex(manifest, contracts) { + const byGroup = new Map(); + for (const c of contracts) { + if (!byGroup.has(c.group)) byGroup.set(c.group, []); + byGroup.get(c.group).push(c); + } + const hashOf = new Map(manifest.components.map((m) => [m.name, m.hash])); + + const lines = []; + lines.push(""); + lines.push(`# whynot-design IR catalog`); + lines.push(""); + lines.push(`**designVersion** \`${manifest.designVersion}\` · **components** ${contracts.length} · **generated** ${manifest.generatedAt}`); + lines.push(""); + lines.push("Machine-readable companion: [`manifest.json`](./manifest.json) (per-component + token hashes)."); + lines.push(""); + + for (const group of [...byGroup.keys()].sort()) { + lines.push(`## ${group}`); + lines.push(""); + for (const c of byGroup.get(group).sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(`### ${c.name} \`<${c.tag}>\``); + lines.push(""); + lines.push(c.description); + lines.push(""); + const portableProps = (c.props || []).filter((p) => p.portable !== false); + if (portableProps.length) { + lines.push("| prop | attribute | type | default |"); + lines.push("| --- | --- | --- | --- |"); + for (const p of portableProps) { + const type = p.type === "enum" ? `enum(${(p.enum || []).join(" \\| ")})` : p.type; + lines.push(`| \`${p.name}\` | \`${p.attribute}\` | ${type} | ${p.default !== undefined ? `\`${p.default}\`` : "—"} |`); + } + lines.push(""); + } + const nonPortable = (c.props || []).filter((p) => p.portable === false); + if (nonPortable.length) { + lines.push(`_Non-portable (React-only) props:_ ${nonPortable.map((p) => `\`${p.name}\``).join(", ")}.`); + lines.push(""); + } + if (c.slots) lines.push(`**Slots:** ${c.slots.map((s) => `\`${s.name}\``).join(", ")} `); + if (c.events) lines.push(`**Events:** ${c.events.map((e) => `\`${e.name}\``).join(", ")} `); + if (c.variants) lines.push(`**Variants:** ${c.variants.map((v) => `${v.axis} (${v.values.join("/")})`).join(", ")} `); + lines.push(`**Contract:** [\`components/${c.name}.json\`](./components/${c.name}.json) · **hash** \`${hashOf.get(c.name)}\`` + + (c.exemplarRef ? ` · **exemplar:** [\`exemplars/${c.name}.html\`](./exemplars/${c.name}.html)` : "")); + lines.push(""); + } + } + return lines.join("\n"); +} + // ---------- Emit ---------- function resetDir(dir) { if (existsSync(dir)) for (const f of readdirSync(dir)) rmSync(join(dir, f), { recursive: true, force: true }); @@ -265,6 +379,16 @@ function main() { for (const e of allErrs) console.error(" - " + e); process.exit(5); } + + log("Manifest → ir/manifest.json"); + const manifest = buildManifest(tokens, comps.map((c) => c.contract)); + writeFileSync(join(IR, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); + log(` designVersion ${manifest.designVersion}, ${manifest.components.length} components, tokensHash ${manifest.tokensHash}`); + + log("Index → ir/INDEX.md"); + writeFileSync(join(IR, "INDEX.md"), buildIndex(manifest, comps.map((c) => c.contract))); + log(` catalog for ${manifest.components.length} components`); + log("\nIR extracted. Review the ir/ git diff (the blueprint change)."); } diff --git a/workplans/WHYNOT-WP-0003-downstream-consumption.md b/workplans/WHYNOT-WP-0003-downstream-consumption.md index 3994c8b..1f65be3 100644 --- a/workplans/WHYNOT-WP-0003-downstream-consumption.md +++ b/workplans/WHYNOT-WP-0003-downstream-consumption.md @@ -126,7 +126,7 @@ Make the package installable with a version pin: ```task id: WHYNOT-WP-0003-T03 -status: todo +status: done priority: high state_hub_task_id: "aaa6d20f-23d3-4467-ac6e-2c24067f1723" ``` @@ -148,7 +148,7 @@ changes is governed by `schemaVersion` (bump on shape changes). ```task id: WHYNOT-WP-0003-T04 -status: todo +status: done priority: medium state_hub_task_id: "fe077343-8b6e-48e7-8eb7-a36cc96366c5" ``` @@ -163,7 +163,7 @@ consumer-side equivalent of `designbook/.design-sync.json`. ```task id: WHYNOT-WP-0003-T05 -status: todo +status: done priority: high state_hub_task_id: "db7fcac0-f3fa-4df3-8f54-e0be731381aa" ``` @@ -184,7 +184,7 @@ downstream drift read the same. ```task id: WHYNOT-WP-0003-T06 -status: todo +status: done priority: medium state_hub_task_id: "5a3c67d8-fd40-4847-a79f-e6fc6a608a1f" ``` @@ -211,7 +211,7 @@ registry coordinates as those land. ```task id: WHYNOT-WP-0003-T07 -status: todo +status: done priority: medium state_hub_task_id: "7159dcdc-55cf-4815-9ba2-0361266a7b8f" ``` @@ -244,7 +244,7 @@ WP-0002-T11. ```task id: WHYNOT-WP-0003-T09 -status: todo +status: done priority: low state_hub_task_id: "e7704a1f-2011-41cb-9e77-c7a6bb2a05ac" ```