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 <noreply@anthropic.com>
This commit is contained in:
20
CHANGELOG.md
20
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 +
|
||||
|
||||
74
CONSUMER_CONTRACT_PARITY.md
Normal file
74
CONSUMER_CONTRACT_PARITY.md
Normal file
@@ -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.
|
||||
150
CONSUMING.md
Normal file
150
CONSUMING.md
Normal file
@@ -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 <path>` (diff against an
|
||||
explicit manifest, e.g. a fetched newer version on disk), `--version <x.y.z>`
|
||||
(assert the resolved manifest is that version — guards against the wrong install),
|
||||
`--lock <path>` (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).
|
||||
@@ -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
|
||||
|
||||
23
README.md
23
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";
|
||||
<wn-pipeline active-idx="3"></wn-pipeline>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
207
bin/whynot-design.mjs
Executable file
207
bin/whynot-design.mjs
Executable file
@@ -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 <path> Diff against an explicit ir/manifest.json (default: the installed package's)
|
||||
--version <x.y.z> Assert the resolved manifest is this version (guards against the wrong install)
|
||||
--lock <path> 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());
|
||||
36
examples/consumer-fixture/README.md
Normal file
36
examples/consumer-fixture/README.md
Normal file
@@ -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).
|
||||
19
examples/consumer-fixture/adopted.lock
Normal file
19
examples/consumer-fixture/adopted.lock
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
examples/consumer-fixture/run.sh
Executable file
38
examples/consumer-fixture/run.sh
Executable file
@@ -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 $?)"
|
||||
130
ir/INDEX.md
Normal file
130
ir/INDEX.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<!-- GENERATED by scripts/ir-extract.mjs (make ir) — do not hand-edit. -->
|
||||
# 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 `<wn-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 `<wn-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 `<wn-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 `<wn-stage-dot>`
|
||||
|
||||
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 `<wn-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 `<wn-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 `<wn-page-header>`
|
||||
|
||||
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 `<wn-pipeline-strip>`
|
||||
|
||||
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 `<wn-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 `<wn-top-nav>`
|
||||
|
||||
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)
|
||||
11
ir/README.md
11
ir/README.md
@@ -16,8 +16,19 @@ What is committed:
|
||||
- `tokens.json` — all tokens, W3C DTCG format.
|
||||
- `components/<Name>.json` — one contract per component.
|
||||
- `exemplars/<Name>.{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
|
||||
|
||||
```
|
||||
|
||||
39
ir/SCHEMA.md
39
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/<Name>.json
|
||||
manifest.schema.json ← JSON Schema for manifest.json (version + hashes)
|
||||
tokens.json ← all design tokens, W3C DTCG format (emitted by T05)
|
||||
components/<Name>.json ← one contract per component (emitted by T05)
|
||||
exemplars/<Name>.{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
|
||||
|
||||
58
ir/manifest.json
Normal file
58
ir/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
ir/schema/lock.schema.json
Normal file
48
ir/schema/lock.schema.json
Normal file
@@ -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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
ir/schema/manifest.schema.json
Normal file
50
ir/schema/manifest.schema.json
Normal file
@@ -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/<name>.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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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("<!-- GENERATED by scripts/ir-extract.mjs (make ir) — do not hand-edit. -->");
|
||||
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).");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user