12 Commits

Author SHA1 Message Date
bf4a679372 chore(release): regen ir/manifest + INDEX for v0.4.0 designVersion
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
make release bumps package.json but not the IR; regenerate so the manifest
version anchor (and INDEX header) read 0.4.0 to match the published package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:42:17 +02:00
5b8b59597a release: v0.4.0
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
2026-06-29 00:40:09 +02:00
6b0b03690d chore(consistency): sync task status from DB [auto]
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for whynot-design
2026-06-27 20:11:04 +02:00
a89bb563a0 fix(showcase): break wn-breadcrumb slotchange infinite loop (WHYNOT-WP-0002 T11)
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
WnBreadcrumb._onSlot inserted separator <span>s into its own light DOM on
slotchange but cleaned up in the shadow DOM, so they were never removed — each
insertion re-fired slotchange, looping the main thread and wedging the showcase
page. Made _onSlot idempotent: exclude own separators when reading items, and
mutate only when separators are not already correct.

- Un-fixme the showcase visual test; add a warm-up full-page capture so
  deviceScaleFactor-2 sub-pixel snapping settles before the assertion. All 5
  visual tests pass.
- Remove the dead Google-Fonts @import from colors_and_type.css (token stacks are
  system-ui; webfont unused + a CI-flake source; no visual change).
- Unblocks WHYNOT-WP-0003 T08 (showcase = per-version visual catalog); both T11
  and T08 marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:10:41 +02:00
76e516f6d9 chore(consistency): sync task status from DB [auto]
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for whynot-design
2026-06-27 19:36:27 +02:00
2de30beb7b 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>
2026-06-27 19:35:45 +02:00
11684f40f3 chore(consistency): sync task status from DB [auto]
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for whynot-design
2026-06-27 19:26:50 +02:00
13c06ec70a chore(workplan): close ADHOC-2026-06-27 (all tasks done)
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:26:09 +02:00
b00686bd22 chore(consistency): sync task status from DB [auto]
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for whynot-design
2026-06-27 15:17:18 +02:00
e02011905a feat(publish): make package installable from Gitea npm registry (WHYNOT-WP-0003 T02)
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
- package.json: private:false; real repository.url; publishConfig.registry →
  coulomb Gitea npm; lit dependency → peerDependency (^3) + devDependency;
  ship the ir/ consumer contract (files + ./ir/* export); add PUBLISHING.md to files.
- .npmrc: real @whynot scope registry + ${NPM_AUTH_TOKEN} ref (no secret committed).
- PUBLISHING.md: publish flow, token routing (operator/OpenBao — warden route is not
  in this CLI), and consumer install.
- README: registry-install path + lit peer note. CHANGELOG: Added/Changed entries.

Config + packaging validated via `npm pack --dry-run` (ir/, tokens, CSS, PUBLISHING.md
included; .npmrc excluded). Actual `npm publish` + install-verify is gated on a Gitea
NPM_AUTH_TOKEN (operator-owned) and an explicit outward-publish go-ahead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:15:47 +02:00
c538d05434 chore(consistency): sync task status from DB [auto]
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Updated by fix-consistency on 2026-06-27:
  - update .custodian-brief.md for whynot-design
2026-06-27 14:29:09 +02:00
9fe9ff03ba chore(workplan): WHYNOT-WP-0003 T01 done
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:28:22 +02:00
26 changed files with 1228 additions and 58 deletions

View File

@@ -2,26 +2,17 @@
# Custodian Brief — whynot-design
**Domain:** infotech
**Last synced:** 2026-06-27 11:43 UTC
**Last synced:** 2026-06-27 18:11 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Downstream consumption: versioned IR releases + consumer drift-check
Progress: 0/9 done | workstream_id: `41fed928-f44a-48f4-9870-120310fbf071`
Progress: 8/9 done | workstream_id: `41fed928-f44a-48f4-9870-120310fbf071`
**Open tasks:**
- ! Showcase as visual catalog (depends on WP-0002 T11) `a0886a4f`
- · Release tagging + versioning discipline `ac6ee3c1`
- · Publish to the Gitea npm registry `dbd3a2e6`
- · Generate ir/manifest.json `aaa6d20f`
- · Define the consumer sync-point `fe077343`
- · Build the `drift` CLI `db7fcac0`
- · Consumer adoption guide + example fixture `5a3c67d8`
- … and 2 more open tasks
### Ad hoc fixes — 2026-06-27
Progress: 1/1 done | workstream_id: `4945ead3-d54b-438f-963a-2fd687c472d4`
- ! Publish to the Gitea npm registry `dbd3a2e6`
*(wait: Config + docs + packaging done (private:false, publishConfig, lit→peerDep, repository.url, .npmrc, PUBLISHING.md, ir/ in files+exports; npm pack --dry-run validated). Remaining: actual `npm publish` + `npm i` install-verify — blocked on a Gitea NPM_AUTH_TOKEN (operator/OpenBao-owned; warden route CLI not available here) and an explicit outward-publish go-ahead (publish is immutable).)*
---
## MCP Orientation (when available)

11
.npmrc
View File

@@ -1,5 +1,6 @@
# When ready to publish to Gitea Packages, uncomment and set NPM_AUTH_TOKEN
# in your shell or CI secrets.
#
# @whynot:registry=https://gitea.example.com/api/packages/whynot/npm/
# //gitea.example.com/api/packages/whynot/npm/:_authToken=${NPM_AUTH_TOKEN}
# @whynot/* is published to and installed from the coulomb Gitea npm registry.
# The auth token is NOT stored here — set NPM_AUTH_TOKEN in your shell/CI.
# It is operator/OpenBao-owned (credential-routing.md: tokens route, never vend);
# obtain a Gitea package token from the operator. Publish flow: see PUBLISHING.md.
@whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/
//gitea.coulomb.social/api/packages/coulomb/npm/:_authToken=${NPM_AUTH_TOKEN}

View File

@@ -6,6 +6,59 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
## [Unreleased]
## [0.4.0] — 2026-06-28
### Fixed
- **Showcase page no longer wedges the renderer** (WHYNOT-WP-0002 T11). `<wn-breadcrumb>`
inserted separator elements into its own light DOM on `slotchange` while cleaning up
in the shadow DOM, so separators were never removed — each insertion re-fired
`slotchange` and the main thread looped forever, so `examples/showcase/index.html`
never finished rendering. `WnBreadcrumb._onSlot` is now idempotent (excludes its own
separators; mutates only when they are not already correct). The showcase visual test
is un-`fixme`'d and captures a stable `showcase.png`, unblocking WHYNOT-WP-0003 T08
(showcase as per-version visual catalog).
### Removed
- **Dead Google-Fonts `@import`** from `src/styles/colors_and_type.css`. Every token
font stack is system-ui based, so the imported IBM Plex webfont was unused; it was
also a documented source of CI flakiness. No visual change (system fonts unchanged).
### 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 +
token routing). The package now ships the `ir/` consumer contract (added to `files` and
the `./ir/*` export) so consumers can pin a version and track it.
### Changed
- **`lit` is now a `peerDependency`** (`^3`), not a direct dependency. Consumers must
install `lit` alongside `@whynot/design` (`npm i @whynot/design lit`) so their bundler
dedupes to a single `lit` instance.
## [0.3.0] — 2026-06-27
### Added

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

55
PUBLISHING.md Normal file
View File

@@ -0,0 +1,55 @@
# Publishing `@whynot/design`
`@whynot/design` is published to the **coulomb Gitea npm registry** so consuming
repos can pin a version (`npm i @whynot/design@x.y.z`) and track it at their own pace
(WHYNOT-WP-0003). The git tag cut by `make release` (see `DesignSystemIntroduction.md`
§6) is the version; publishing makes that version installable.
- Registry: `https://gitea.coulomb.social/api/packages/coulomb/npm/`
- `package.json` `publishConfig.registry` already points `npm publish` here.
- `lit` is a **peerDependency** — consumers install it themselves so their bundler
dedupes to a single `lit` instance.
## The token (never commit it)
Publishing and installing `@whynot/*` need a Gitea package token. It is **not stored in
this repo** — per `.claude/rules/credential-routing.md`, tokens are routed, not vended:
a Gitea package token is operator/OpenBao-owned (`railiance-platform`). Obtain one from
the operator and export it:
```sh
export NPM_AUTH_TOKEN=# Gitea package token; never paste into git/chat/logs
```
`.npmrc` (committed) references it via `${NPM_AUTH_TOKEN}` — no secret lives in the file.
## Publish (maintainer)
```sh
git checkout main && git pull --ff-only
make release VERSION=x.y.z # bumps, cuts CHANGELOG, commits, tags (§6)
git push --follow-tags origin main
npm publish # uses publishConfig.registry + NPM_AUTH_TOKEN
```
`npm publish` is **outward and immutable** — a published version cannot be silently
replaced. Confirm the tag and `npm pack --dry-run` contents first.
## Install (consumer)
Add an `.npmrc` to the consuming repo so the `@whynot` scope resolves to the registry,
then install the package plus the `lit` peer:
```ini
# .npmrc
@whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/
//gitea.coulomb.social/api/packages/coulomb/npm/:_authToken=${NPM_AUTH_TOKEN}
```
```sh
npm i @whynot/design@x.y.z lit
```
The installed package carries the consumer-facing contract under `ir/` (component
contracts, `tokens.json`, exemplars) reachable via the `./ir/*` export — that is what
the `drift` check (WHYNOT-WP-0003 T05) reads to report changes between versions.

View File

@@ -38,8 +38,18 @@ Framework-agnostic by design. Consumers do **not** re-implement components per f
### Node-tooled consumer (React, Vite, Next, Vue, …)
Install from the coulomb Gitea npm registry (add the scope to your `.npmrc` first — see
[`PUBLISHING.md`](./PUBLISHING.md) for the token). `lit` is a peer dependency:
```ini
# .npmrc
@whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/
//gitea.coulomb.social/api/packages/coulomb/npm/:_authToken=${NPM_AUTH_TOKEN}
```
```sh
pnpm add git+ssh://git@gitea.coulomb.social/coulomb/whynot-design.git#v0.3.0
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
```
```js
@@ -54,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.4.0` · **components** 10 · **generated** 2026-06-28T22:41:24.992Z
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.4.0",
"generatedAt": "2026-06-28T22:41:24.992Z",
"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

@@ -1,11 +1,14 @@
{
"name": "@whynot/design",
"version": "0.3.0",
"version": "0.4.0",
"description": "The neutral, mostly-black-and-white visual language for whynot — prototype cards, signal records, beta plans, decision documents, and any other deliberately-unfinished artefact. Ships tokens, CSS, and Lit-based web components consumable from React, Django, Vue, plain HTML, or anywhere a custom element runs.",
"private": true,
"private": false,
"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",
@@ -21,17 +24,22 @@
"./tokens/type": "./tokens/type.json",
"./tokens/spacing": "./tokens/spacing.json",
"./adapters/django": "./adapters/django/README.md",
"./ir/*": "./ir/*",
"./assets/*": "./assets/*",
"./skill": "./SKILL.md"
},
"files": [
"src",
"bin",
"tokens",
"ir",
"assets",
"adapters",
"CONSUMING.md",
"SKILL.md",
"DesignSystemIntroduction.md",
"MultiFrameworkSupport.md",
"PUBLISHING.md",
"README.md",
"CHANGELOG.md"
],
@@ -47,16 +55,20 @@
"showcase": "npx --yes serve -l 4321 .",
"example": "npx --yes serve -l 4322 examples/whynot-control"
},
"dependencies": {
"lit": "^3.2.1"
"peerDependencies": {
"lit": "^3"
},
"devDependencies": {
"@playwright/test": "^1.45.0"
"@playwright/test": "^1.45.0",
"lit": "^3.2.1"
},
"keywords": ["whynot", "design-system", "wireframe", "prototype", "lit", "web-components", "django", "react"],
"repository": {
"type": "git",
"url": "git+ssh://git@gitea.example.com/whynot/whynot-design.git"
"url": "git+ssh://git@gitea.coulomb.social/coulomb/whynot-design.git"
},
"publishConfig": {
"registry": "https://gitea.coulomb.social/api/packages/coulomb/npm/"
},
"license": "UNLICENSED"
}

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

@@ -236,11 +236,23 @@ export class WnEmptyState extends WnBase {
export class WnBreadcrumb extends WnBase {
_onSlot(e) {
const slot = e.target;
const items = slot.assignedElements({ flatten: true });
// Build the rendered tree: each item + a separator after it.
const wrapper = this.shadowRoot?.querySelector('.wn-breadcrumb__list');
if (!wrapper) return;
wrapper.querySelectorAll('.wn-breadcrumb__sep').forEach(s => s.remove());
// Separators are inserted into the LIGHT DOM (so they sit in document order
// between the slotted items), which re-fires this slotchange. We must
// therefore be idempotent: exclude our own separators when reading items,
// and skip all mutation once the separators are already correct — otherwise
// each insertion retriggers slotchange and the main thread loops forever.
const items = slot.assignedElements({ flatten: true })
.filter((el) => !el.classList.contains("wn-breadcrumb__sep"));
const existing = [...this.querySelectorAll(":scope > .wn-breadcrumb__sep")];
if (existing.length === Math.max(0, items.length - 1)) {
// Structure already correct — only refresh the "current" marker, do not
// touch the child list (no mutation ⇒ no slotchange re-fire ⇒ loop ends).
items.forEach((el, i) => el.classList.toggle("wn-breadcrumb__current", i === items.length - 1));
return;
}
existing.forEach((s) => s.remove());
items.forEach((el, i) => {
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
if (i > 0) {
@@ -248,8 +260,6 @@ export class WnBreadcrumb extends WnBase {
sep.className = "wn-breadcrumb__sep";
sep.setAttribute("aria-hidden", "true");
sep.textContent = "/";
// Use light-DOM-relative insertion: items are still in light DOM,
// so DOM-order separators between them belong in light DOM too.
el.parentNode.insertBefore(sep, el);
}
});

View File

@@ -7,8 +7,11 @@
artefacts over heavy fills.
============================================================ */
/* ---------- Webfonts (Google Fonts, see /fonts for offline) ---------- */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap");
/* No webfont is loaded. Every token font stack is system-ui based
(ui-sans-serif / ui-monospace / ui-serif), so the design ships with zero
network font dependency. (Historically this imported IBM Plex from Google
Fonts; that webfont was unused and a source of CI flakiness, so it was
dropped.) */
/* @generated tokens — regenerated by `make adapt-lit` from ir/tokens.json. DO NOT EDIT. */
:root {

View File

@@ -8,28 +8,30 @@ import { test, expect } from "@playwright/test";
//
// To update intentionally: pnpm test:visual:update
// The design-tokens stylesheet (colors_and_type.css) @imports IBM Plex from
// Google Fonts, but every token font stack is system-ui based — the webfont is
// unused. Left live it intermittently hangs in CI, blocking the page's module
// <script> (a pending stylesheet defers script execution) so custom elements
// never register. Abort the font CDNs so baselines are deterministic & offline.
// Defensive: no webfont is loaded anymore (token font stacks are system-ui
// based), but abort the font CDNs anyway so a re-introduced webfont can never
// make a baseline non-deterministic or network-dependent.
test.beforeEach(async ({ page }) => {
await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort());
});
test.describe("showcase — every component", () => {
// KNOWN BROKEN — tracked as adhoc against WHYNOT-WP-0002. The showcase page
// (every component on one page) wedges the renderer main thread when its
// module executes: components + vendored lit render fine in isolation, but
// one demo composition on this page infinite-loops, so the page never
// reaches `load` and no `showcase.png` baseline can be captured. The four
// whynot-control baselines are unaffected. Remove `.fixme` once the looping
// component is fixed and regenerate the baseline.
test.fixme("renders", async ({ page }) => {
// Previously KNOWN BROKEN (WHYNOT-WP-0002 T11): the page wedged the renderer
// main thread because <wn-breadcrumb> inserted separators into its own light
// DOM on slotchange, re-firing slotchange in an infinite loop. Fixed by making
// WnBreadcrumb._onSlot idempotent (src/elements/layout.js).
test("renders", async ({ page }) => {
await page.goto("/examples/showcase/index.html");
// Wait for custom elements to register + Lit to render.
await page.waitForFunction(() => !!customElements.get("wn-button"));
await page.waitForTimeout(800);
// Warm-up capture: the first full-page screenshot resizes the viewport to
// the full content height, and at deviceScaleFactor 2 that first layout pass
// snaps several sections by ≤4px. Discard it so the page is settled before
// the real assertion — otherwise toHaveScreenshot fails its "two consecutive
// stable screenshots" check on this long page.
await page.screenshot({ fullPage: true });
await page.waitForTimeout(200);
await expect(page).toHaveScreenshot("showcase.png", { fullPage: true });
});
});

View File

@@ -216,11 +216,27 @@ gate that confirms Lit actually matches the designbook appearance.
```task
id: WHYNOT-WP-0002-T11
status: todo
status: done
priority: medium
state_hub_task_id: "7435338d-702a-43d7-9c86-49531fe0d8e4"
```
**Resolved 2026-06-27.** Root cause: `WnBreadcrumb._onSlot` (`src/elements/layout.js`)
inserted separator `<span>`s into its own **light DOM** on `slotchange`, but its
cleanup queried the **shadow** wrapper for separators — so they were never removed.
Each insertion mutated the breadcrumb's children, re-firing `slotchange`, inserting
more separators, looping the main thread forever. (The earlier "specific demo
composition" hypothesis was a misdiagnosis — the minimal repro simply omitted
`<wn-breadcrumb>`; a minimal page *with* it reproduces the wedge, *without* it
registers in ~300ms.) Fix: made `_onSlot` idempotent — it excludes its own
separators when reading items and skips all DOM mutation once separators are
already correct, so the self-triggered `slotchange` is a no-op and the loop ends.
Also removed the dead Google-Fonts `@import` from `colors_and_type.css` (token
font stacks are system-ui; the webfont was unused and a documented CI-flake source).
`test.fixme` removed in `tests/visual/ui-kit.spec.mjs`; the showcase now renders and
captures a stable `showcase.png` (a warm-up full-page capture settles deviceScaleFactor-2
sub-pixel snapping before the assertion). All 5 visual tests pass.
Discovered 2026-06-26 while regenerating visual baselines after the T06 token
regen. The `examples/showcase/index.html` "every component" page wedges the
renderer main thread when its module executes — the page never reaches `load`

View File

@@ -84,7 +84,7 @@ follow up at its own pace → npx @whynot/design drift --update (adopt new sy
```task
id: WHYNOT-WP-0003-T01
status: progress
status: done
priority: high
state_hub_task_id: "ac6ee3c1-859d-49d4-b5dc-71bdcd2821f9"
```
@@ -100,7 +100,7 @@ tagged. Tag the current state as the first real anchor.
```task
id: WHYNOT-WP-0003-T02
status: todo
status: wait
priority: high
state_hub_task_id: "dbd3a2e6-0623-4efd-8293-399002e85ea2"
```
@@ -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"
```
@@ -225,7 +225,7 @@ anything, complementing the machine-readable manifest.
```task
id: WHYNOT-WP-0003-T08
status: wait
status: done
priority: low
state_hub_task_id: "a0886a4f-cf27-44ef-b8c6-8e61ceda1f84"
```
@@ -236,6 +236,13 @@ just to confirm, once T11 lands, that the showcase is deployable/inspectable per
version (e.g. served from a tag) — no new build, reuse the existing page. Blocked on
WP-0002-T11.
**Done 2026-06-27.** WP-0002-T11 landed (breadcrumb infinite-loop fixed); the
showcase page now renders deterministically and is covered by a passing visual test
(`showcase.png` baseline). No new build — the existing page *is* the per-version
visual catalog: served statically from any checkout/tag (`pnpm showcase`), it pairs
with the machine-readable `ir/manifest.json` + `ir/INDEX.md` as the visual side of a
version's inventory. Nothing further to build.
---
## Phase 4 — Deferred: live contract-parity mode (design-only)
@@ -244,7 +251,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"
```

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Ad hoc fixes — 2026-06-27"
domain: infotech
repo: whynot-design
status: active
status: finished
owner: claude
topic_slug: custodian
created: "2026-06-27"