feat(consumer): versioned IR manifest + drift-check (WHYNOT-WP-0003 T03-T07,T09)
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled

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:
2026-06-27 19:35:45 +02:00
parent 11684f40f3
commit 2de30beb7b
18 changed files with 1042 additions and 7 deletions

View File

@@ -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 14). 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 +

View 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
View 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).

View File

@@ -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

View File

@@ -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
View 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());

View 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).

View 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"
}
}
}

View 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
View 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)

View File

@@ -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
```

View File

@@ -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
View 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"
}
]
}

View 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}$"
}
}
}

View 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)."
}
}
}

View File

@@ -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",

View File

@@ -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).");
}

View File

@@ -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"
```