13 Commits
v0.4.0 ... main

Author SHA1 Message Date
4594c0168d release: v0.4.1
Some checks are pending
ci / check (push) Waiting to run
ci / release (push) Blocked by required conditions
Republished via native secrets-engine exec (SECRETS-WP-0003 pilot closeout).
No package content changes from v0.4.0.
2026-07-03 17:39:34 +02:00
8fe37ae3da 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-30:
  - update .custodian-brief.md for whynot-design
2026-06-30 09:54:40 +02:00
05fa31e2b5 fix(adapter): resolve all WHYNOT-WP-0002 drift — designbook-refresh green
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
Triage the three surfaced divergences the governance-correct way (no stack->React
back-edit, no ir/ hand-edit); make adapt-lit/parity-lit/designbook-refresh now
exit 0:

- PipelineStrip: documented TAG_OVERRIDES in scripts/ir-extract.mjs maps the
  React 'PipelineStrip' to the established tag wn-pipeline (the web-component tag
  is an IR-projection detail, not React-dictated; the component name stays
  faithful). Tag now matches the element; parity tests it (no longer skipped).
- PageHeader.actions: the drift detector now collects each element's named slots
  and treats an IR prop honoured by a same-named slot (<slot name="actions">) as
  satisfied (prop-via-slot, informational) rather than prop-missing.
- Sidebar.current: recorded as an auditable accepted divergence in
  adapters/lit/drift.accepted.json (React monolithic 'current' key vs Lit per-item
  'active' on composable <wn-sidebar-item>) — listed, downgraded to info, not gated.

Rendered surfaces (src/, examples/) untouched — verified zero diff; parity renders
all 10 components green. Adapt/parity outputs idempotent (stable re-run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:53:59 +02:00
756634c27f 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-30:
  - update .custodian-brief.md for whynot-design
2026-06-30 09:23:27 +02:00
a1c780af8c chore(workplan): finish WHYNOT-WP-0002 (T07-T10 done; status finished)
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
All 11 tasks complete: Lit adapter scaffold+drift (T07), parity (T08), refresh
orchestrator (T09), plain-css second-adapter smoke (T10). Documents the 3 open
governance drifts (PipelineStrip rename, PageHeader actions slot, Sidebar
current) as Claude-Design-side items the now-working machinery surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:22:58 +02:00
36f7b9f7b9 feat(adapter): plain-css second-adapter smoke — proves the IR seam (WHYNOT-WP-0002 T10)
A deliberately-unfinished second adapter that consumes the same ir/ and honours
the same adapters/ADAPTER_CONTRACT.md (token full-gen + write-once stubs + drift
roll-up shape + exit codes), with zero changes to ir/. Every IR component is
'new' for a fresh stack → 10 class stubs + tokens.css (80 props). Not a usable
plain-CSS kit — the deliverable is confidence the boundary holds so the
architecture can be lifted into a coulomb-level tool later. make adapt-plain-css.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:21:16 +02:00
7cf524137f feat(refresh): make designbook-refresh orchestrator + drift-triage runbook (WHYNOT-WP-0002 T09)
scripts/designbook-refresh.mjs chains the automatable steps
(check->pull->sync->ir->adapt-lit->parity) and stops for the human drift-triage
step, propagating the adapter-contract exit codes (3=stop for triage, 4=parity
fail). Gating steps call the node scripts directly so make doesn't collapse the
3/4 codes to 2. Best-effort steps (check/pull/sync) warn and continue; --no-pull
/--no-check/--no-parity flags. Documented the loop in stack-and-commands.md and a
step-6 drift-resolution runbook in designbook/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:19:44 +02:00
e4e3fe069c feat(adapter): make parity-lit — contract + visual parity (WHYNOT-WP-0002 T08)
Render every <wn-*> in a real browser (Playwright, --no-sandbox; reuses an
external static server when present) and write the adapter-contract parity result
to adapters/lit/parity/_parity.json:

- Contract parity: element must upgrade + carry no attribute-mismatch vs IR
  (computed statically via scaffold.componentDrift, avoiding runtime
  type-coercion false positives). prop-missing is a coverage note, not a failure.
- Visual parity: render smoke (non-empty + positive box) + per-component
  screenshot artifact (gitignored). Pixel-exact regression stays with the
  Playwright baseline suite; IR exemplars are gallery cards, not single-component
  baselines, so they are the human reference, not an auto pixel gate.
- Result: 10 components, contractFail=0 visualFail=0, PipelineStrip skipped
  (wn-pipeline-strip rename drift). Exit 4 on failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:16:13 +02:00
552d8fe926 feat(adapter): Lit component scaffold + drift report (WHYNOT-WP-0002 T07)
Extend make adapt-lit beyond tokens: parse src/elements/*.js, compare each IR
component contract against its <wn-*> element, and emit per-component drift
reports + a machine roll-up (adapters/lit/drift/), with write-once stubs
(adapters/lit/stubs/) for genuinely new components. Never overwrites
hand-authored sources.

- Severity split: actionable drift (prop-missing, attribute-mismatch,
  variant-axis-missing, tag-mismatch) gates with exit 3; non-portable + prop-extra
  are informational (the IR carries React style/onClick; Lit is richer than the
  minimal designbook) and don't gate.
- Current state: 7 ok, 3 actionable drift for human triage — PipelineStrip
  (wn-pipeline-strip vs hand-authored wn-pipeline rename), PageHeader (actions is
  a slot, not a prop), Sidebar (IR 'current' axis absent on the element).
- _report.json reuses generatedAt/irRef when drift is unchanged (no git churn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:03:22 +02:00
17f2ad9139 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-29:
  - update .custodian-brief.md for whynot-design
2026-06-29 00:57:37 +02:00
8acd7abb83 chore: commit pnpm-lock.yaml (refreshed for lit peerDependency move)
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-29 00:57:26 +02:00
bf9a0055f7 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-29:
  - update .custodian-brief.md for whynot-design
2026-06-29 00:51:30 +02:00
5927542e93 chore(workplan): WHYNOT-WP-0003 T02 done — published @whynot/design@0.4.0
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-29 00:51:22 +02:00
47 changed files with 1705 additions and 72 deletions

View File

@@ -18,10 +18,38 @@ npx playwright test -g "inbox" # run a single visual test by name
make # list make targets (help is the default goal)
make designbook-sync # after a /design-sync pull, record changes + last-sync time → RecentChanges.md
make designbook-check # ask Claude Design (via llm-connect) if the cloud is newer; warn if mirror is stale
make ir # extract the technology-neutral IR (ir/) from designbook/ (React → IR)
make adapt-lit # project IR onto Lit: regen tokens + scaffold stubs + drift reports (exit 3 on drift)
make parity-lit # render every <wn-*> (Playwright) + assert contract/visual parity (exit 4 on fail)
make designbook-refresh # the refresh loop: check→pull→sync→ir→adapt-lit→(drift triage)→parity. ARGS="--no-pull" etc.
make recent-changes # regenerate RecentChanges.md (alias; ARGS="--range main..HEAD" supported)
make sync-styles # = node scripts/sync-shared-styles.mjs
```
### Keeping Lit current with the designbook — the refresh loop (WHYNOT-WP-0002)
`make designbook-refresh` is the single routine that re-propagates a cloud designbook
change down to the Lit stack. It runs the automatable steps and **stops for you** when
drift needs a human decision:
```
1 designbook-check → has the cloud moved? (best-effort: needs llm-connect)
2 designbook-pull → pull React designbook→designbook/ (best-effort: needs `claude` binary)
3 designbook-sync → record the diff → RecentChanges.md
4 ir → re-extract ir/ (review the git diff — the blueprint change)
5 adapt-lit → regen tokens, scaffold new stubs, emit drift reports
6 you resolve drift ← STOP if step 5 exits 3 (adapters/lit/drift/*.md)
7 parity-lit → confirm contract + visual parity
```
Exit codes propagate the adapter contract: **3** = stop for drift triage (step 6),
**4** = parity failure. Steps 13 are best-effort (a network/`claude`/llm-connect
gap warns and continues; the IR just re-extracts from the current mirror). After
resolving drift, re-run `make designbook-refresh --no-pull` (via `ARGS=`) to re-check
and reach parity. Drift resolution itself is governed by
`.claude/rules/designbook-propagation.md` (fix the stack, or change the language in
Claude Design and re-propagate — never a stack→React back-edit).
There is no unit-test suite — correctness is verified by full-page Playwright screenshot diffs of the two `examples/` pages (`tests/visual/ui-kit.spec.mjs`, `maxDiffPixelRatio: 0.005`). Any visual change needs `pnpm test:visual:update` + baseline review.
## Integrating the designbook

View File

@@ -2,17 +2,12 @@
# Custodian Brief — whynot-design
**Domain:** infotech
**Last synced:** 2026-06-27 18:11 UTC
**Last synced:** 2026-06-30 07:54 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: 8/9 done | workstream_id: `41fed928-f44a-48f4-9870-120310fbf071`
**Open tasks:**
- ! 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).)*
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)

4
.gitignore vendored
View File

@@ -18,3 +18,7 @@ __pycache__
# Editor
.vscode
.idea
# Adapter parity render-smoke screenshots (regenerated by make parity-lit; the
# committed result is adapters/lit/parity/_parity.json)
adapters/lit/parity/*.png

View File

@@ -6,6 +6,40 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
## [Unreleased]
### Added
- **Lit adapter completed + refresh pipeline** (WHYNOT-WP-0002, Phases 36). The
one-way `React → designbook/ → ir/ → adapters/lit` pipeline is now end-to-end:
- `make adapt-lit` gains **component scaffold + drift** (T07): parses
`src/elements/*.js`, compares each `<wn-*>` to its IR contract, and writes
per-component drift reports + a machine roll-up (`adapters/lit/drift/`), with
write-once stubs (`adapters/lit/stubs/`) for new components — never overwriting
hand-authored sources. Severity split: actionable drift gates (exit 3);
`non-portable`/`prop-extra` are informational.
- `make parity-lit` (T08): renders every `<wn-*>` in a real browser and asserts
**contract + visual parity**, writing `adapters/lit/parity/_parity.json`
(exit 4 on failure).
- `make designbook-refresh` (T09): the refresh orchestrator
(check→pull→sync→ir→adapt-lit→drift-triage→parity) honouring the adapter
exit-code contract, plus a drift-resolution runbook in `designbook/README.md`.
- `make adapt-plain-css` (T10): a deliberately-unfinished second-adapter **smoke**
proving the IR/adapter seam — a non-Lit adapter consuming the same `ir/` with the
same contract shapes and zero `ir/` changes.
- **Drift triage resolved — `make designbook-refresh` is green** (0 drift, parity pass).
The three surfaced divergences were resolved without any stack→React back-edit:
a documented `TAG_OVERRIDES` in the extractor maps `PipelineStrip → wn-pipeline`
(the tag is an IR-projection detail); the drift detector now recognises a prop
honoured by a same-named named slot (`<wn-page-header>` `actions`); and an auditable
`adapters/lit/drift.accepted.json` registry records the intentional Sidebar
composition divergence (`current` key ↔ per-item `active`) as a justified,
non-gating note.
## [0.4.1] — 2026-07-03
Republished to the coulomb Gitea npm registry through the native
`secrets-engine exec` pilot closeout (SECRETS-WP-0003). No package content
changes from `0.4.0`.
## [0.4.0] — 2026-06-28
### Fixed

View File

@@ -34,6 +34,15 @@ ir: ## Extract the technology-neutral IR (ir/) from the designbook mirror. One-w
adapt-lit: ## Project the IR onto the Lit stack: regen tokens (full gen), scaffold + drift (T07).
$(NODE) adapters/lit/adapt.mjs
parity-lit: ## Confirm Lit elements honour the IR contract + render (browser). Exit 4 on parity failure.
$(NODE) adapters/lit/parity.mjs
designbook-refresh: ## Refresh routine: check->pull->sync->ir->adapt-lit->(drift triage)->parity. ARGS=--no-pull etc.
$(NODE) scripts/designbook-refresh.mjs $(ARGS)
adapt-plain-css: ## Second-adapter SMOKE: prove a non-Lit adapter consumes the same ir/ (WHYNOT-WP-0002 T10).
$(NODE) adapters/plain-css/adapt.mjs
recent-changes: ## Regenerate RecentChanges.md (alias of the reporter; --range supported).
$(NODE) scripts/designbook-sync.mjs $(ARGS)

View File

@@ -2,7 +2,7 @@
Snapshot of the last designbook integration. Regenerated by `make designbook-sync`.
- Generated: 2026-06-23T19:25:28Z
- Generated: 2026-06-30T07:48:39Z
- Compared: working tree (uncommitted)
- Last /design-sync: 2026-06-23T19:25:28Z
@@ -10,48 +10,6 @@ Snapshot of the last designbook integration. Regenerated by `make designbook-syn
> into `CHANGELOG.md` under `## [Unreleased]` before releasing; that is the file CI
> enforces (`pnpm check`). The designbook itself is synced via `/design-sync`, not this script.
## Changed files
## No changes
### Designbook
- `ADDED ` designbook/_ds_bundle.js (+1396 / -0)
- `ADDED ` designbook/_ds_manifest.json (+1 / -0)
- `ADDED ` designbook/.design-pull.json (+18 / -0)
- `ADDED ` designbook/colors_and_type.css (+293 / -0)
- `ADDED ` designbook/preview/brand-iconography.html (+36 / -0)
- `ADDED ` designbook/preview/brand-lockups.html (+26 / -0)
- `ADDED ` designbook/preview/brand-logo.html (+21 / -0)
- `ADDED ` designbook/preview/brand-wireframe-motif.html (+43 / -0)
- `ADDED ` designbook/preview/colors-accent.html (+33 / -0)
- `ADDED ` designbook/preview/colors-borders.html (+25 / -0)
- `ADDED ` designbook/preview/colors-neutrals.html (+36 / -0)
- `ADDED ` designbook/preview/colors-signal.html (+25 / -0)
- `ADDED ` designbook/preview/colors-status-functional.html (+31 / -0)
- `ADDED ` designbook/preview/comp-buttons.html (+40 / -0)
- `ADDED ` designbook/preview/comp-empty-placeholder.html (+38 / -0)
- `ADDED ` designbook/preview/comp-inputs.html (+30 / -0)
- `ADDED ` designbook/preview/comp-labels-tags.html (+44 / -0)
- `ADDED ` designbook/preview/comp-left-nav.html (+85 / -0)
- `ADDED ` designbook/preview/comp-pipeline.html (+30 / -0)
- `ADDED ` designbook/preview/comp-prototype-card.html (+46 / -0)
- `ADDED ` designbook/preview/comp-topnav.html (+37 / -0)
- `ADDED ` designbook/preview/page-beta-invitation.html (+104 / -0)
- `ADDED ` designbook/preview/page-landing-auth.html (+224 / -0)
- `ADDED ` designbook/preview/page-prototype-detail.html (+158 / -0)
- `ADDED ` designbook/preview/page-signals-dashboard.html (+135 / -0)
- `ADDED ` designbook/preview/spacing-elevation.html (+26 / -0)
- `ADDED ` designbook/preview/spacing-radii.html (+24 / -0)
- `ADDED ` designbook/preview/spacing-scale.html (+28 / -0)
- `ADDED ` designbook/preview/type-body.html (+23 / -0)
- `ADDED ` designbook/preview/type-display.html (+23 / -0)
- `ADDED ` designbook/preview/type-headings.html (+20 / -0)
- `ADDED ` designbook/preview/type-mono-eyebrows.html (+42 / -0)
- `ADDED ` designbook/preview/type-serif-quote.html (+17 / -0)
- `ADDED ` designbook/REACT_CANONICAL_DECISION.md (+60 / -0)
- `ADDED ` designbook/styles.css (+9 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/Atoms.jsx (+102 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/Chrome.jsx (+163 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/data.jsx (+71 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/DocView.jsx (+102 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/index.html (+77 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/README.md (+31 / -0)
- `ADDED ` designbook/ui_kits/whynot-control/Screens.jsx (+274 / -0)
The design surface is unchanged since the last sync.

View File

@@ -13,8 +13,35 @@ Per the contract, an adapter is **scaffold + drift-detect**, never a rewrite:
| Concern | Behaviour | Status |
|---|---|---|
| **Tokens** | **Fully generated** from `ir/tokens.json` into the `:root` block of `src/styles/colors_and_type.css`, between `@generated tokens` markers. Deterministic — re-running with an unchanged IR is a byte-identical no-op. The hand-authored type/utility CSS after the block is preserved. | **done (T06)** |
| **New component** | Generate a `<wn-*>` Lit stub from the IR contract's prop→attribute map + a behaviour `TODO`. | T07 |
| **Changed component** | Emit a **drift report** (`adapters/lit/drift/<Name>.md`) — never overwrite the hand-authored element. | T07 |
| **New component** | Generate a `<wn-*>` Lit stub (`adapters/lit/stubs/<Name>.js`) from the IR contract's prop→attribute map + a behaviour `TODO`. **Write-once** — into a staging dir, never the hand-authored tree; the human integrates it. | **done (T07)** |
| **Changed component** | Emit a **drift report** (`adapters/lit/drift/<Name>.md` + machine `_report.json`) — never overwrite the hand-authored element. | **done (T07)** |
### Drift severity
`make adapt-lit` exits `3` only on **actionable** drift — `prop-missing`,
`attribute-mismatch`, `variant-axis-missing`, `tag-mismatch`. **Informational**
issues do not gate: `non-portable` (React `style`/callbacks that inherently have
no attribute form — the Lit element is right to omit them) and `prop-extra` (the
Lit element is richer than the minimal React designbook). Resolve actionable drift
per `.claude/rules/designbook-propagation.md` (fix the stack, or change the language
in Claude Design and re-propagate — never a stack→React back-edit).
## Parity — `make parity-lit`
`adapters/lit/parity.mjs` renders every `<wn-*>` in a real browser (Playwright) and
writes `adapters/lit/parity/_parity.json` (the contract's parity-result shape):
- **Contract parity** — each element must upgrade and carry no `attribute-mismatch`
vs its IR contract (computed statically, so no runtime type-coercion false
positives). A prop the element *lacks* is a coverage note (already surfaced as
drift), not a parity failure.
- **Visual parity** — a render smoke: the element renders non-empty with a positive
box; a screenshot is saved to `adapters/lit/parity/<Name>.png` (gitignored) as the
artifact. The `ir/exemplars/<Name>.html` are designbook **gallery cards** (a grid
of all variants), not single-component baselines, so an automated pixel diff
against them is not meaningful — per-component Lit appearance regression is owned
by the Playwright baseline suite (`tests/visual/`); the exemplar is the human
visual reference. Exit `4` on a contract or render failure.
## Directionality

View File

@@ -16,13 +16,18 @@
// type/utility CSS after it is preserved untouched. Re-running with an unchanged
// ir/tokens.json is a no-op (byte-identical output).
// =============================================================
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseLitElements, componentDrift, renderStub, loadAccepted } from "./scaffold.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const TOKENS_JSON = join(REPO, "ir", "tokens.json");
const TOKEN_CSS = join(REPO, "src", "styles", "colors_and_type.css");
const IR_COMPONENTS = join(REPO, "ir", "components");
const DRIFT_DIR = join(REPO, "adapters", "lit", "drift");
const STUBS_DIR = join(REPO, "adapters", "lit", "stubs");
const BEGIN = "/* @generated tokens — regenerated by `make adapt-lit` from ir/tokens.json. DO NOT EDIT. */";
const END = "/* @end generated tokens */";
@@ -85,10 +90,105 @@ function generateTokens() {
console.log(`tokens: regenerated ${count} custom properties → src/styles/colors_and_type.css`);
}
// ---------- T07: component scaffold + drift ----------
function irRef() {
try {
return execSync("git rev-parse --short HEAD", { cwd: REPO }).toString().trim();
} catch { return "(no-git)"; }
}
function renderDriftMd(c) {
const head = {
ok: "✓ in sync with the IR contract.",
drift: "⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.",
new: " new component — a stub was generated; integrate it.",
removed: " removed from the IR.",
}[c.status];
const lines = [
`<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->`,
`# Drift — ${c.name} \`<${c.tag}>\``,
"",
`**Status:** ${c.status}${head}`,
"",
];
if (!c.issues.length) lines.push("No issues.");
else {
lines.push("| severity | kind | prop | detail |", "| --- | --- | --- | --- |");
const order = { drift: 0, info: 1 };
for (const i of [...c.issues].sort((a, b) => order[a.severity] - order[b.severity])) {
const detail = i.detail || (i.expected !== undefined ? `expected \`${i.expected}\`, actual \`${i.actual}\`` : "");
lines.push(`| ${i.severity === "drift" ? "**drift**" : "info"} | ${i.kind} | ${i.prop ? `\`${i.prop}\`` : "—"} | ${detail} |`);
}
}
lines.push("");
return lines.join("\n");
}
function runScaffold() {
if (!existsSync(IR_COMPONENTS)) {
console.error("No ir/components/ — run `make ir` first.");
process.exit(2);
}
const byTag = parseLitElements(REPO);
const accepted = loadAccepted(REPO);
const contracts = readdirSync(IR_COMPONENTS).filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(IR_COMPONENTS, f), "utf8")));
const results = contracts.map((c) => componentDrift(c, byTag, accepted))
.sort((a, b) => a.name.localeCompare(b.name));
// Per-component drift docs: snapshot, so wipe stale ones first (idempotency rule 4).
mkdirSync(DRIFT_DIR, { recursive: true });
for (const f of readdirSync(DRIFT_DIR)) if (f.endsWith(".md")) rmSync(join(DRIFT_DIR, f));
for (const c of results) writeFileSync(join(DRIFT_DIR, `${c.name}.md`), renderDriftMd(c));
// Machine roll-up. Reuse generatedAt when nothing material changed so a no-op
// `make adapt-lit` produces no git churn (matches ir/manifest.json discipline).
const reportPath = join(DRIFT_DIR, "_report.json");
let generatedAt = new Date().toISOString();
let ref = irRef();
if (existsSync(reportPath)) {
try {
const prev = JSON.parse(readFileSync(reportPath, "utf8"));
if (JSON.stringify(prev.components) === JSON.stringify(results)) {
if (prev.generatedAt) generatedAt = prev.generatedAt;
if (prev.irRef) ref = prev.irRef; // unchanged drift ⇒ keep the prior ref, no churn
}
} catch { /* regenerate fresh */ }
}
const report = { stack: "lit", generatedAt, irRef: ref, components: results };
writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
// Write-once stubs for genuinely new components.
let stubbed = 0;
for (const c of results.filter((r) => r.status === "new")) {
mkdirSync(STUBS_DIR, { recursive: true });
const out = join(STUBS_DIR, `${c.name}.js`);
if (!existsSync(out)) { writeFileSync(out, renderStub(contracts.find((x) => x.name === c.name))); stubbed++; }
}
const drift = results.filter((r) => r.status === "drift");
const isNew = results.filter((r) => r.status === "new");
const ok = results.filter((r) => r.status === "ok");
const infoCount = ok.reduce((n, r) => n + r.issues.length, 0);
console.log(`scaffold: ${ok.length} ok · ${drift.length} drift · ${isNew.length} new (${stubbed} stub${stubbed === 1 ? "" : "s"} written) → adapters/lit/drift/`);
if (infoCount) console.log(` (${infoCount} informational note${infoCount === 1 ? "" : "s"} on in-sync components — non-portable/extra props, not gated)`);
for (const c of [...drift, ...isNew]) {
const actionable = c.issues.filter((i) => i.severity === "drift");
console.log(` ${c.status === "drift" ? "⚠" : ""} ${c.name}: ${actionable.map((i) => `${i.kind}(${i.prop ?? ""})`).join(", ") || c.issues.map((i) => i.kind).join(", ")}`);
}
return drift.length + isNew.length > 0; // → drift exit code
}
function main() {
generateTokens();
// T07 will add: component stub scaffolding + drift reports here.
console.log("adapt-lit: tokens done. (component scaffold + drift: T07)");
const drifted = runScaffold();
if (drifted) {
console.log("adapt-lit: drift detected — see adapters/lit/drift/. Exit 3 (review, non-fatal).");
process.exit(3);
}
console.log("adapt-lit: tokens + scaffold done, no drift.");
}
main();

View File

@@ -0,0 +1,17 @@
{
"_comment": "Human-curated accepted divergences — the auditable output of drift triage (WHYNOT-WP-0002). An entry downgrades a specific drift issue to an informational, justified note so it does not gate make adapt-lit/parity-lit. Use ONLY for intentional React<->Lit modelling differences, never to silence a real defect. Keyed by component + drift kind + prop. See .claude/rules/designbook-propagation.md.",
"accepted": [
{
"component": "Sidebar",
"kind": "prop-missing",
"prop": "current",
"rationale": "Composition divergence (intentional). The React Sidebar is monolithic and takes a `current` selection-key prop, comparing it against its own internal NAV_ITEMS. The Lit stack decomposes the sidebar into <wn-sidebar> + <wn-sidebar-group> + <wn-sidebar-item>, modelling selection as per-item `active` state on the slotted children rather than a container-level key. There is no single `current` attribute to honour on <wn-sidebar>; the contract is satisfied compositionally. Reconcile upstream only if the React designbook is ever made composable."
},
{
"component": "Sidebar",
"kind": "variant-axis-missing",
"prop": "current",
"rationale": "Same composition divergence as Sidebar.current above — the `current` variant axis is expressed as item-level `active` on <wn-sidebar-item>, not as a <wn-sidebar> property."
}
]
}

View File

@@ -0,0 +1,14 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Button `<wn-button>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `onClick` | type=function; not attribute-mappable — handle explicitly, never drop. |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |
| info | prop-extra | `size` | on <wn-button> (attribute 'size'), not in IR contract. |
| info | prop-extra | `iconEnd` | on <wn-button> (attribute 'icon-end'), not in IR contract. |
| info | prop-extra | `type` | on <wn-button> (attribute 'type'), not in IR contract. |
| info | prop-extra | `disabled` | on <wn-button> (attribute 'disabled'), not in IR contract. |
| info | prop-extra | `href` | on <wn-button> (attribute 'href'), not in IR contract. |

View File

@@ -0,0 +1,9 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Eyebrow `<wn-eyebrow>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |
| info | prop-extra | `strong` | on <wn-eyebrow> (attribute 'strong'), not in IR contract. |

View File

@@ -0,0 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Icon `<wn-icon>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |

View File

@@ -0,0 +1,9 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — PageHeader `<wn-page-header>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | prop-via-slot | `actions` | IR prop honoured by <slot name="actions"> on <wn-page-header> (slotted content, not an attribute). |
| info | prop-extra | `hasActions` | on <wn-page-header> (attribute 'hasactions'), not in IR contract. |

View File

@@ -0,0 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — PipelineStrip `<wn-pipeline>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | prop-extra | `stages` | on <wn-pipeline> (attribute 'stages'), not in IR contract. |

View File

@@ -0,0 +1,11 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Sidebar `<wn-sidebar>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | prop-missing | `current` | in IR (attribute 'current'), absent on <wn-sidebar> |
| info | non-portable | `onNav` | type=function; not attribute-mappable — handle explicitly, never drop. |
| info | variant-axis-missing | `current` | IR variant axis 'current' (doc:) has no Lit property. |
| info | prop-extra | `activation` | on <wn-sidebar> (attribute 'activation'), not in IR contract. |

View File

@@ -0,0 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — StageDot `<wn-stage-dot>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |

View File

@@ -0,0 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Stamp `<wn-stamp>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |

View File

@@ -0,0 +1,8 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — Tag `<wn-tag>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. |

View File

@@ -0,0 +1,11 @@
<!-- @generated by make adapt-lit (WHYNOT-WP-0002 T07) — overwritten each run; do not hand-edit. -->
# Drift — TopNav `<wn-top-nav>`
**Status:** ok — ✓ in sync with the IR contract.
| severity | kind | prop | detail |
| --- | --- | --- | --- |
| info | non-portable | `onNew` | type=function; not attribute-mappable — handle explicitly, never drop. |
| info | prop-extra | `logoSrc` | on <wn-top-nav> (attribute 'logo-src'), not in IR contract. |
| info | prop-extra | `brand` | on <wn-top-nav> (attribute 'brand'), not in IR contract. |
| info | prop-extra | `slug` | on <wn-top-nav> (attribute 'slug'), not in IR contract. |

View File

@@ -0,0 +1,223 @@
{
"stack": "lit",
"generatedAt": "2026-06-30T07:46:35.458Z",
"irRef": "756634c",
"components": [
{
"name": "Button",
"status": "ok",
"tag": "wn-button",
"issues": [
{
"kind": "non-portable",
"prop": "onClick",
"detail": "type=function; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
},
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "size",
"detail": "on <wn-button> (attribute 'size'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "iconEnd",
"detail": "on <wn-button> (attribute 'icon-end'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "type",
"detail": "on <wn-button> (attribute 'type'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "disabled",
"detail": "on <wn-button> (attribute 'disabled'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "href",
"detail": "on <wn-button> (attribute 'href'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "Eyebrow",
"status": "ok",
"tag": "wn-eyebrow",
"issues": [
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "strong",
"detail": "on <wn-eyebrow> (attribute 'strong'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "Icon",
"status": "ok",
"tag": "wn-icon",
"issues": [
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
}
]
},
{
"name": "PageHeader",
"status": "ok",
"tag": "wn-page-header",
"issues": [
{
"kind": "prop-via-slot",
"prop": "actions",
"detail": "IR prop honoured by <slot name=\"actions\"> on <wn-page-header> (slotted content, not an attribute).",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "hasActions",
"detail": "on <wn-page-header> (attribute 'hasactions'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "PipelineStrip",
"status": "ok",
"tag": "wn-pipeline",
"issues": [
{
"kind": "prop-extra",
"prop": "stages",
"detail": "on <wn-pipeline> (attribute 'stages'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "Sidebar",
"status": "ok",
"tag": "wn-sidebar",
"issues": [
{
"kind": "prop-missing",
"prop": "current",
"detail": "in IR (attribute 'current'), absent on <wn-sidebar>",
"severity": "info",
"accepted": "Composition divergence (intentional). The React Sidebar is monolithic and takes a `current` selection-key prop, comparing it against its own internal NAV_ITEMS. The Lit stack decomposes the sidebar into <wn-sidebar> + <wn-sidebar-group> + <wn-sidebar-item>, modelling selection as per-item `active` state on the slotted children rather than a container-level key. There is no single `current` attribute to honour on <wn-sidebar>; the contract is satisfied compositionally. Reconcile upstream only if the React designbook is ever made composable."
},
{
"kind": "non-portable",
"prop": "onNav",
"detail": "type=function; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
},
{
"kind": "variant-axis-missing",
"prop": "current",
"detail": "IR variant axis 'current' (doc:) has no Lit property.",
"severity": "info",
"accepted": "Same composition divergence as Sidebar.current above — the `current` variant axis is expressed as item-level `active` on <wn-sidebar-item>, not as a <wn-sidebar> property."
},
{
"kind": "prop-extra",
"prop": "activation",
"detail": "on <wn-sidebar> (attribute 'activation'), not in IR contract.",
"severity": "info"
}
]
},
{
"name": "StageDot",
"status": "ok",
"tag": "wn-stage-dot",
"issues": [
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
}
]
},
{
"name": "Stamp",
"status": "ok",
"tag": "wn-stamp",
"issues": [
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
}
]
},
{
"name": "Tag",
"status": "ok",
"tag": "wn-tag",
"issues": [
{
"kind": "non-portable",
"prop": "style",
"detail": "type=object; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
}
]
},
{
"name": "TopNav",
"status": "ok",
"tag": "wn-top-nav",
"issues": [
{
"kind": "non-portable",
"prop": "onNew",
"detail": "type=function; not attribute-mappable — handle explicitly, never drop.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "logoSrc",
"detail": "on <wn-top-nav> (attribute 'logo-src'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "brand",
"detail": "on <wn-top-nav> (attribute 'brand'), not in IR contract.",
"severity": "info"
},
{
"kind": "prop-extra",
"prop": "slug",
"detail": "on <wn-top-nav> (attribute 'slug'), not in IR contract.",
"severity": "info"
}
]
}
]
}

174
adapters/lit/parity.mjs Normal file
View File

@@ -0,0 +1,174 @@
// =============================================================
// adapters/lit/parity.mjs — make parity-lit (WHYNOT-WP-0002 · T08)
//
// The gate that confirms the Lit elements actually honour the IR contract.
// Renders each <wn-*> in a real browser (Playwright) and checks:
//
// (a) CONTRACT parity — for every IR-declared portable prop the element HAS,
// setting the IR-declared attribute must drive the property (no attribute
// contradiction). A prop the element lacks is a *coverage* note, not a
// contradiction — it is already surfaced by `make adapt-lit` drift (T07).
// (b) VISUAL parity — the element renders non-empty with a positive box; a
// screenshot is saved to adapters/lit/parity/<Name>.png as the artifact.
//
// On pixel-exact appearance: `ir/exemplars/<Name>.html` are designbook *gallery
// cards* (a grid of all variants), not single-component baselines, so a direct
// pixel diff against them is not meaningful. Per-component Lit appearance
// regression is owned by the Playwright baseline suite (tests/visual/). Visual
// parity here is a render smoke + artifact; the exemplar is the human reference.
//
// Result: adapters/lit/parity/_parity.json (adapters/ADAPTER_CONTRACT.md shape).
// Exit: 0 pass · 2 usage · 4 parity failure · 5 internal.
// =============================================================
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { spawn } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseLitElements, componentDrift, loadAccepted } from "./scaffold.mjs";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const IR_COMPONENTS = join(REPO, "ir", "components");
const OUT = join(REPO, "adapters", "lit", "parity");
const PORT = 4399;
async function loadChromium() {
for (const id of ["@playwright/test", "playwright", "playwright-core"]) {
try { return (await import(id)).chromium; } catch { /* next */ }
}
console.error("parity: Playwright not installed (need @playwright/test)."); process.exit(2);
}
function waitForServer(url, tries = 30) {
return new Promise((resolve, reject) => {
const tick = (n) => fetch(url).then(() => resolve()).catch(() => {
if (n <= 0) return reject(new Error("server did not start"));
setTimeout(() => tick(n - 1), 200);
});
tick(tries);
});
}
// Representative attribute values from the contract (to exercise rendering).
function fixtureAttrs(contract) {
const attrs = {};
for (const p of contract.props || []) {
if (p.portable === false || p.attribute === false) continue;
if (p.type === "enum") attrs[p.attribute] = p.default ?? (p.enum && p.enum[0]) ?? "";
else if (p.type === "number") attrs[p.attribute] = "1";
else if (p.type === "boolean") attrs[p.attribute] = "";
else attrs[p.attribute] = "Sample";
}
return attrs;
}
async function main() {
if (!existsSync(IR_COMPONENTS)) { console.error("No ir/components/ — run `make ir`."); process.exit(2); }
const contracts = readdirSync(IR_COMPONENTS).filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(IR_COMPONENTS, f), "utf8")))
.sort((a, b) => a.name.localeCompare(b.name));
mkdirSync(OUT, { recursive: true });
for (const f of readdirSync(OUT)) if (f.endsWith(".png")) rmSync(join(OUT, f));
const url = `http://127.0.0.1:${PORT}/examples/showcase/index.html`;
// Reuse an already-running static server (set by the caller / CI); only spawn
// and manage our own if none is up. Spawning is what some sandboxes dislike.
let server = null;
const alreadyUp = await fetch(url).then(() => true).catch(() => false);
if (!alreadyUp) {
server = spawn("python3", ["-m", "http.server", String(PORT), "--bind", "127.0.0.1"],
{ cwd: REPO, stdio: "ignore" });
}
const stopServer = () => { if (server) server.kill(); };
const chromium = await loadChromium();
let browser;
try {
await waitForServer(url);
browser = await chromium.launch({ args: ["--no-sandbox"] });
const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
await page.route(/fonts\.(googleapis|gstatic)\.com/, (r) => r.abort());
// The showcase page registers every wn-* element and loads components.css.
await page.goto(`http://127.0.0.1:${PORT}/examples/showcase/index.html`, { waitUntil: "commit" });
await page.waitForFunction(() => !!customElements.get("wn-button"), null, { timeout: 8000 });
await page.addStyleTag({ content: "#parity-stage{position:fixed;left:0;top:0;background:var(--paper);padding:16px;z-index:99999}" });
// Static contract analysis (precise attribute-name correctness, no runtime
// type-coercion false positives). The browser then confirms the element
// actually upgrades + renders — the thing static analysis cannot prove.
const byTag = parseLitElements(REPO);
const accepted = loadAccepted(REPO);
const results = [];
for (const c of contracts) {
const tag = c.tag;
const drift = componentDrift(c, byTag, accepted);
const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch" && i.severity === "drift");
const missing = drift.issues.filter((i) => i.kind === "prop-missing" && i.severity === "drift");
const hasDefaultSlot = (c.slots || []).some((s) => s.name === "default");
const observed = await page.evaluate(async ({ tag, attrs, hasDefaultSlot }) => {
if (!customElements.get(tag)) return { exists: false };
let stage = document.getElementById("parity-stage");
if (!stage) { stage = document.createElement("div"); stage.id = "parity-stage"; document.body.appendChild(stage); }
stage.innerHTML = "";
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
if (hasDefaultSlot) el.textContent = "Sample";
stage.appendChild(el);
await el.updateComplete?.catch(() => {});
const r = el.getBoundingClientRect();
const rendered = el.children.length > 0 || (el.textContent || "").trim().length > 0 || !!el.shadowRoot;
return { exists: true, rendered, rect: { w: Math.round(r.width), h: Math.round(r.height) } };
}, { tag, attrs: fixtureAttrs(c), hasDefaultSlot });
if (!observed.exists) {
results.push({ name: c.name, contract: "skip", visual: "skip", diffRatio: null,
notes: `no <${tag}> element (see adapters/lit/drift/${c.name}.md)` });
continue;
}
// Screenshot the element box as the visual artifact.
try {
const h = await page.$("#parity-stage > *");
if (h) await h.screenshot({ path: join(OUT, `${c.name}.png`) });
} catch { /* non-fatal */ }
const contract = attrMismatch.length ? "fail" : "pass";
const visual = observed.rendered && (observed.rect.w > 0 || observed.rect.h > 0) ? "pass" : "fail";
const notes = [];
if (attrMismatch.length) notes.push(`attribute-mismatch: ${attrMismatch.map((i) => `${i.prop} expected '${i.expected}' got '${i.actual}'`).join(", ")}`);
if (missing.length) notes.push(`coverage (drift, not gated): missing ${missing.map((i) => i.prop).join(", ")}`);
results.push({ name: c.name, contract, visual, diffRatio: null, box: observed.rect, notes: notes.join("; ") || "ok" });
}
const summary = {
total: results.length,
contractFail: results.filter((r) => r.contract === "fail").length,
visualFail: results.filter((r) => r.visual === "fail").length,
skipped: results.filter((r) => r.contract === "skip").length,
};
const out = { stack: "lit", generatedAt: new Date().toISOString(), components: results, summary };
writeFileSync(join(OUT, "_parity.json"), JSON.stringify(out, null, 2) + "\n");
console.log(`parity-lit: ${summary.total} components · contractFail=${summary.contractFail} · visualFail=${summary.visualFail} · skip=${summary.skipped}`);
for (const r of results) {
const flag = r.contract === "fail" || r.visual === "fail" ? "✗" : r.contract === "skip" ? "·" : "✓";
console.log(` ${flag} ${r.name}: contract=${r.contract} visual=${r.visual}${r.notes && r.notes !== "ok" ? ` (${r.notes})` : ""}`);
}
await browser.close();
stopServer();
if (summary.contractFail || summary.visualFail) {
console.log("parity-lit: FAILURE — see adapters/lit/parity/_parity.json. Exit 4.");
process.exit(4);
}
console.log("parity-lit: pass.");
} catch (e) {
if (browser) await browser.close().catch(() => {});
stopServer();
console.error("parity-lit: internal error —", e.message);
process.exit(5);
}
}
main();

View File

@@ -0,0 +1,122 @@
{
"stack": "lit",
"generatedAt": "2026-06-30T07:49:18.063Z",
"components": [
{
"name": "Button",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 82,
"h": 36
},
"notes": "ok"
},
{
"name": "Eyebrow",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 45,
"h": 23
},
"notes": "ok"
},
{
"name": "Icon",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 0,
"h": 23
},
"notes": "ok"
},
{
"name": "PageHeader",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 117,
"h": 37
},
"notes": "ok"
},
{
"name": "PipelineStrip",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 633,
"h": 76
},
"notes": "ok"
},
{
"name": "Sidebar",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 273,
"h": 592
},
"notes": "ok"
},
{
"name": "StageDot",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 56,
"h": 23
},
"notes": "ok"
},
{
"name": "Stamp",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 63,
"h": 23
},
"notes": "ok"
},
{
"name": "Tag",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 64,
"h": 24
},
"notes": "ok"
},
{
"name": "TopNav",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 243,
"h": 57
},
"notes": "ok"
}
],
"summary": {
"total": 10,
"contractFail": 0,
"visualFail": 0,
"skipped": 0
}
}

235
adapters/lit/scaffold.mjs Normal file
View File

@@ -0,0 +1,235 @@
// =============================================================
// adapters/lit/scaffold.mjs — component scaffold + drift (WHYNOT-WP-0002 · T07)
//
// Pure functions over ir/ + the Lit source tree (src/elements/*.js). Per
// adapters/ADAPTER_CONTRACT.md:
// • IR component with no <wn-*> counterpart → a write-once STUB
// (adapters/lit/stubs/<Name>.js), never into the hand-authored tree.
// • IR component with a counterpart → a DRIFT REPORT
// (adapters/lit/drift/<Name>.md + a machine roll-up); never an overwrite.
//
// Behaviour is never generated — stubs carry a TODO; drift is for human triage.
// =============================================================
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const ELEMENT_FILES = ["atoms.js", "form.js", "layout.js", "chrome.js"];
// ---------- Parse the Lit source tree → tag → element descriptor ----------
// Extract the balanced { … } block that starts at/after `from`.
function balancedBlock(src, from) {
let i = src.indexOf("{", from), depth = 0;
const begin = i;
for (; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}" && --depth === 0) return src.slice(begin, i + 1);
}
return src.slice(begin);
}
// Parse a `static properties = { … }` block body into { name: {attribute,type,reflect} }.
function parseProps(block) {
const props = {};
// Each entry: `name: { … },` — scan key then its balanced object.
const re = /([A-Za-z_$][\w$]*)\s*:\s*\{/g;
let m;
while ((m = re.exec(block))) {
const name = m[1];
const decl = balancedBlock(block, m.index + m[0].length - 1);
const attrM = /attribute\s*:\s*(false|"([^"]+)"|'([^']+)')/.exec(decl);
const typeM = /type\s*:\s*([A-Za-z]+)/.exec(decl);
const reflect = /reflect\s*:\s*true/.test(decl);
let attribute;
if (attrM) attribute = attrM[1] === "false" ? false : (attrM[2] || attrM[3]);
else attribute = name.toLowerCase(); // Lit default: lowercased property name
props[name] = { attribute, type: typeM ? typeM[1] : "String", reflect };
re.lastIndex = m.index + m[0].length; // resume after the key (decl re-scanned harmlessly)
}
return props;
}
// Find a class's own `static properties` (walks `extends` within the same file set).
function classProps(name, classes) {
const cls = classes[name];
if (!cls) return {};
const own = cls.propsBlock ? parseProps(cls.propsBlock) : {};
const inherited = cls.extends && classes[cls.extends] ? classProps(cls.extends, classes) : {};
return { ...inherited, ...own };
}
function classSlots(name, classes) {
const cls = classes[name];
if (!cls) return new Set();
const inherited = cls.extends && classes[cls.extends] ? classSlots(cls.extends, classes) : new Set();
return new Set([...inherited, ...(cls.slots || [])]);
}
export function parseLitElements(repo) {
const classes = {}; // className → { propsBlock, extends, file }
const defines = []; // { tag, className, file }
for (const file of ELEMENT_FILES) {
const path = join(repo, "src", "elements", file);
if (!existsSync(path)) continue;
const src = readFileSync(path, "utf8");
const classRe = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$]*)/g;
let m;
while ((m = classRe.exec(src))) {
const [className, base] = [m[1], m[2]];
const propsAt = src.indexOf("static properties", m.index);
// bound the search to before the next class declaration
classRe.lastIndex = m.index + m[0].length;
const nextClass = src.indexOf("class ", m.index + m[0].length);
const propsBlock = propsAt !== -1 && (nextClass === -1 || propsAt < nextClass)
? balancedBlock(src, propsAt) : null;
// Named slots the element renders (e.g. <slot name="actions">) — a prop can
// be honoured by a same-named slot rather than a reactive property.
const region = src.slice(m.index, nextClass === -1 ? undefined : nextClass);
const slots = [...region.matchAll(/<slot\s+name="([\w-]+)"/g)].map((x) => x[1]);
classes[className] = { propsBlock, extends: base, file, slots };
}
const defRe = /customElements\.define\(\s*["']([\w-]+)["']\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g;
while ((m = defRe.exec(src))) defines.push({ tag: m[1], className: m[2], file });
}
const byTag = {};
for (const d of defines) byTag[d.tag] = { ...d, props: classProps(d.className, classes), slots: classSlots(d.className, classes) };
return byTag;
}
// ---------- Drift: IR contract vs Lit element ----------
// Actionable drift gates `make adapt-lit` (exit 3). The rest is informational:
// • non-portable — React style/callbacks that inherently have no attribute form;
// the Lit element is correct to omit them. Surfaced (never dropped), never gated.
// • prop-extra — the Lit element is richer than the minimal React designbook;
// a divergence worth noting, but not a defect to block on.
const ACTIONABLE = new Set(["prop-missing", "attribute-mismatch", "variant-axis-missing", "tag-mismatch"]);
export function litAttrOf(decl) {
return decl.attribute === false ? null : decl.attribute;
}
// Human-curated accepted divergences — the auditable output of drift triage for
// divergences that are intentional (e.g. a React monolithic prop modelled
// per-child in a composable Lit element). Keyed `<Component>:<kind>:<prop>` → rationale.
// Read from adapters/lit/drift.accepted.json. An accepted issue is still listed in
// the report (marked "accepted: <why>") but downgraded to info, so it does not gate.
export function loadAccepted(repo) {
const path = join(repo, "adapters", "lit", "drift.accepted.json");
if (!existsSync(path)) return {};
try {
const out = {};
for (const e of (JSON.parse(readFileSync(path, "utf8")).accepted || []))
out[`${e.component}:${e.kind}:${e.prop ?? ""}`] = e.rationale || "accepted";
return out;
} catch { return {}; }
}
function classify(issues, component, accepted = {}) {
for (const i of issues) {
const key = `${component}:${i.kind}:${i.prop ?? ""}`;
if (accepted[key]) { i.severity = "info"; i.accepted = accepted[key]; }
else i.severity = ACTIONABLE.has(i.kind) ? "drift" : "info";
}
return issues.some((i) => i.severity === "drift") ? "drift" : "ok";
}
// Find a likely renamed counterpart when no exact tag match (e.g. wn-pipeline-strip → wn-pipeline).
function likelyCounterpart(tag, byTag) {
if (byTag[tag]) return null;
const head = tag.replace(/^wn-/, "").split("-")[0]; // "pipeline"
const hit = Object.keys(byTag).find((t) => t.replace(/^wn-/, "").split("-")[0] === head);
return hit || null;
}
export function componentDrift(contract, byTag, accepted = {}) {
const tag = contract.tag;
const el = byTag[tag];
if (!el) {
const alt = likelyCounterpart(tag, byTag);
if (alt) {
const issues = [{ kind: "tag-mismatch", expected: tag, actual: alt,
detail: `IR contract tag '${tag}' has no element; '${alt}' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element).` }];
return { name: contract.name, status: classify(issues, contract.name, accepted), tag, issues };
}
return { name: contract.name, status: "new", tag, issues: [
{ kind: "no-counterpart", detail: `no <${tag}> in src/elements/ — stub generated.` },
] };
}
const issues = [];
const litProps = el.props;
const litSlots = el.slots || new Set();
for (const p of contract.props || []) {
if (p.portable === false) {
issues.push({ kind: "non-portable", prop: p.name,
detail: `type=${p.type}; not attribute-mappable — handle explicitly, never drop.` });
continue;
}
if (p.attribute === false) continue; // property-only by contract
const lit = litProps[p.name];
if (!lit) {
// A content prop can be honoured by a same-named named slot (e.g. PageHeader
// `actions` → <slot name="actions">) — that is satisfaction, not drift.
if (litSlots.has(p.name) || litSlots.has(p.attribute)) {
issues.push({ kind: "prop-via-slot", prop: p.name,
detail: `IR prop honoured by <slot name="${p.name}"> on <${tag}> (slotted content, not an attribute).` });
continue;
}
issues.push({ kind: "prop-missing", prop: p.name,
detail: `in IR (attribute '${p.attribute}'), absent on <${tag}>` });
continue;
}
const litAttr = litAttrOf(lit);
if (litAttr !== p.attribute) {
issues.push({ kind: "attribute-mismatch", prop: p.name, expected: p.attribute, actual: litAttr === null ? "(property-only)" : litAttr });
}
}
// Variant axes must have a backing property.
for (const v of contract.variants || []) {
if (!litProps[v.axis]) issues.push({ kind: "variant-axis-missing", prop: v.axis,
detail: `IR variant axis '${v.axis}' (${v.values.join("/")}) has no Lit property.` });
}
// Extra Lit properties not described by the IR contract.
const irNames = new Set((contract.props || []).map((p) => p.name));
for (const name of Object.keys(litProps)) {
if (!irNames.has(name)) issues.push({ kind: "prop-extra", prop: name,
detail: `on <${tag}> (attribute '${litAttrOf(litProps[name]) ?? "(property-only)"}'), not in IR contract.` });
}
return { name: contract.name, status: classify(issues, contract.name, accepted), tag, issues };
}
// ---------- Stub generation (write-once) ----------
export function renderStub(contract) {
const tag = contract.tag;
const cls = "Wn" + contract.name;
const props = (contract.props || []).filter((p) => p.portable !== false && p.attribute !== false);
const litType = (t) => (t === "boolean" ? "Boolean" : t === "number" ? "Number" : "String");
const propLines = props.map((p) => {
const attr = p.attribute !== p.name.toLowerCase() ? `, attribute: "${p.attribute}"` : "";
return ` ${p.name}: { type: ${litType(p.type)}${attr} },`;
}).join("\n");
const nonPortable = (contract.props || []).filter((p) => p.portable === false);
const npNote = nonPortable.length
? `\n // Non-portable props (handle explicitly, do not drop): ${nonPortable.map((p) => p.name).join(", ")}.`
: "";
const slotMarkup = (contract.slots || []).some((s) => s.name === "default")
? "<slot></slot>" : "";
return `// @generated STUB by adapters/lit (WHYNOT-WP-0002 T07) — from ir/components/${contract.name}.json.
// Write-once scaffold: skeleton + typed reactive properties + a behaviour TODO.
// Move into src/elements/ and implement; the adapter never overwrites this once edited.
import { LitElement, html } from "lit";
export class ${cls} extends LitElement {
createRenderRoot() { return this; } // light DOM, matches the existing wn-* elements${npNote}
static properties = {
${propLines || " // (no attribute-mappable props in the contract)"}
};
render() {
// TODO(${contract.name}): implement per ir/exemplars/${contract.name}.html and the design language.
return html\`<div class="${tag}" part="root">${slotMarkup}</div>\`;
}
}
// customElements.define("${tag}", ${cls}); // ← uncomment when integrating
`;
}

View File

@@ -0,0 +1,31 @@
# plain-css adapter — second-adapter smoke (WHYNOT-WP-0002 · T10)
> **This is not a finished stack.** It exists to prove the **IR/adapter boundary**:
> that a second, non-Lit adapter can consume the *same* `ir/` and honour the *same*
> [`adapters/ADAPTER_CONTRACT.md`](../ADAPTER_CONTRACT.md). The deliverable is
> confidence in the seam, so the architecture can later be lifted into a
> coulomb-level tool — **not** a usable plain-CSS component kit.
Run with **`make adapt-plain-css`** (`adapters/plain-css/adapt.mjs`).
## What it proves
| Contract concern | This adapter | Same as Lit? |
|---|---|---|
| Input is `ir/` only | reads `ir/tokens.json` + `ir/components/*.json` | ✓ identical inputs |
| Tokens fully generated | → `adapters/plain-css/tokens.css` (CSS custom properties, deterministic no-op re-run) | ✓ same discipline, different target |
| New component → stub | write-once class stub `adapters/plain-css/stubs/<tag>.css` (base + variant modifier classes from the contract) | ✓ write-once, into a staging dir |
| Drift roll-up | `adapters/plain-css/_report.json` in the contract's report shape (`stack`, `generatedAt`, `components[]`) | ✓ portable shape |
| Exit codes | `0` ok · `2` usage · `3` new/drift · `5` internal | ✓ shared convention |
Because plain-CSS has no hand-authored source, **every** IR component reports
`status: "new"` and gets a stub — exactly the contract's new-component path. That a
totally different stack reuses the same IR, the same report shape, and the same exit
codes — with **zero changes to `ir/`** — is the proof the boundary holds.
## What it deliberately does NOT do
No real CSS appearance, no parity, no full component set, no integration into the
repo's build. Finishing a plain-CSS (or Vue/Svelte/…) stack is future work; the seam
is what T10 validates. See `DesignSystemIntroduction.md` §5.1 and the Lit reference
adapter (`adapters/lit/`) for the full implementation.

View File

@@ -0,0 +1,106 @@
{
"stack": "plain-css",
"generatedAt": "2026-06-30T07:48:30.643Z",
"components": [
{
"name": "Button",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-button class — stub generated."
}
]
},
{
"name": "Eyebrow",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-eyebrow class — stub generated."
}
]
},
{
"name": "Icon",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-icon class — stub generated."
}
]
},
{
"name": "PageHeader",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-page-header class — stub generated."
}
]
},
{
"name": "PipelineStrip",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-pipeline class — stub generated."
}
]
},
{
"name": "Sidebar",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-sidebar class — stub generated."
}
]
},
{
"name": "StageDot",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-stage-dot class — stub generated."
}
]
},
{
"name": "Stamp",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-stamp class — stub generated."
}
]
},
{
"name": "Tag",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-tag class — stub generated."
}
]
},
{
"name": "TopNav",
"status": "new",
"issues": [
{
"kind": "no-counterpart",
"detail": "no .wn-top-nav class — stub generated."
}
]
}
]
}

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
// =============================================================
// adapters/plain-css/adapt.mjs — second-adapter SMOKE (WHYNOT-WP-0002 · T10)
//
// NOT a finished stack. This exists only to prove the IR/adapter *boundary*:
// a non-Lit adapter consuming the very same ir/ and honouring the same
// adapters/ADAPTER_CONTRACT.md (token full-gen + stub/drift + exit codes).
// The deliverable is confidence in the seam, not a usable plain-CSS kit.
//
// It does two contract things from ir/ and nothing Lit-specific:
// • tokens → fully generated CSS custom properties (deterministic no-op re-run)
// • components → write-once class stubs + a drift roll-up in the contract shape
// (every component is "new" here — plain-CSS has no existing source to drift).
//
// Exit codes (shared contract): 0 ok · 2 usage · 3 drift/new · 5 internal.
// Run: `make adapt-plain-css`.
// =============================================================
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const IR = join(REPO, "ir");
const HERE = join(REPO, "adapters", "plain-css");
const STUBS = join(HERE, "stubs");
const refToVar = (v) => {
const m = /^\{[A-Za-z0-9]+\.([A-Za-z0-9-]+)\}$/.exec(String(v).trim());
return m ? `var(--${m[1]})` : v;
};
function generateTokens() {
const tokens = JSON.parse(readFileSync(join(IR, "tokens.json"), "utf8"));
const lines = ["/* @generated by adapters/plain-css (WHYNOT-WP-0002 T10) from ir/tokens.json — DO NOT EDIT. */", ":root {"];
let n = 0;
for (const [group, entries] of Object.entries(tokens)) {
lines.push(` /* ${group} */`);
for (const [key, tok] of Object.entries(entries)) {
if (key === "$type") continue;
lines.push(` --${key}: ${refToVar(tok.$value)};`); n++;
}
}
lines.push("}", "");
const out = join(HERE, "tokens.css");
const body = lines.join("\n");
const before = existsSync(out) ? readFileSync(out, "utf8") : null;
if (before === body) { console.log(`tokens: up to date (${n} custom properties, no change).`); return; }
writeFileSync(out, body);
console.log(`tokens: regenerated ${n} custom properties → adapters/plain-css/tokens.css`);
}
function renderClassStub(c) {
const base = c.tag; // reuse the contract tag as the base class name
const lines = [
`/* @generated STUB by adapters/plain-css (T10) from ir/components/${c.name}.json — write-once. */`,
`/* ${c.name}: ${c.description} */`,
`.${base} { /* TODO: base appearance per ir/exemplars/${c.name}.html */ }`,
];
for (const v of c.variants || []) {
for (const val of v.values) lines.push(`.${base}--${val} { /* ${v.axis}=${val} */ }`);
}
return lines.join("\n") + "\n";
}
function scaffold() {
const contracts = readdirSync(join(IR, "components")).filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(IR, "components", f), "utf8")))
.sort((a, b) => a.name.localeCompare(b.name));
// Plain-CSS has no hand-authored source, so every IR component is "new".
const components = contracts.map((c) => ({ name: c.name, status: "new",
issues: [{ kind: "no-counterpart", detail: `no .${c.tag} class — stub generated.` }] }));
mkdirSync(STUBS, { recursive: true });
let stubbed = 0;
for (const c of contracts) {
const out = join(STUBS, `${c.tag}.css`);
if (!existsSync(out)) { writeFileSync(out, renderClassStub(c)); stubbed++; }
}
// Drift roll-up — same shape as the Lit adapter's, proving the contract is portable.
const reportPath = join(HERE, "_report.json");
let generatedAt = new Date().toISOString();
if (existsSync(reportPath)) {
try { const prev = JSON.parse(readFileSync(reportPath, "utf8"));
if (JSON.stringify(prev.components) === JSON.stringify(components) && prev.generatedAt) generatedAt = prev.generatedAt;
} catch { /* fresh */ }
}
writeFileSync(reportPath, JSON.stringify({ stack: "plain-css", generatedAt, components }, null, 2) + "\n");
console.log(`scaffold: ${components.length} components, all new (${stubbed} stub${stubbed === 1 ? "" : "s"} written) → adapters/plain-css/stubs/`);
return components.length > 0;
}
function main() {
if (!existsSync(join(IR, "tokens.json"))) { console.error("No ir/ — run `make ir` first."); process.exit(2); }
generateTokens();
const isNew = scaffold();
console.log("\nadapt-plain-css: SMOKE only — proves a non-Lit adapter consumes the same ir/ and");
console.log("emits the same contract shapes. Not a finished stack (see adapters/plain-css/README.md).");
if (isNew) process.exit(3); // new components present (contract: 3) — expected for a fresh stack
}
main();

View File

@@ -0,0 +1,6 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Button.json — write-once. */
/* Button: Button — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-button { /* TODO: base appearance per ir/exemplars/Button.html */ }
.wn-button--secondary { /* variant=secondary */ }
.wn-button--primary { /* variant=primary */ }
.wn-button--ghost { /* variant=ghost */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Eyebrow.json — write-once. */
/* Eyebrow: Eyebrow — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-eyebrow { /* TODO: base appearance per ir/exemplars/Eyebrow.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Icon.json — write-once. */
/* Icon: Icon — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-icon { /* TODO: base appearance per ir/exemplars/Icon.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/PageHeader.json — write-once. */
/* PageHeader: PageHeader — extracted from designbook ui_kits/whynot-control/Chrome.jsx. */
.wn-page-header { /* TODO: base appearance per ir/exemplars/PageHeader.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/PipelineStrip.json — write-once. */
/* PipelineStrip: PipelineStrip — extracted from designbook ui_kits/whynot-control/Chrome.jsx. */
.wn-pipeline { /* TODO: base appearance per ir/exemplars/PipelineStrip.html */ }

View File

@@ -0,0 +1,4 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Sidebar.json — write-once. */
/* Sidebar: Sidebar — extracted from designbook ui_kits/whynot-control/Chrome.jsx. */
.wn-sidebar { /* TODO: base appearance per ir/exemplars/Sidebar.html */ }
.wn-sidebar--doc: { /* current=doc: */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/StageDot.json — write-once. */
/* StageDot: StageDot — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-stage-dot { /* TODO: base appearance per ir/exemplars/StageDot.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Stamp.json — write-once. */
/* Stamp: Stamp — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-stamp { /* TODO: base appearance per ir/exemplars/Stamp.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/Tag.json — write-once. */
/* Tag: Tag — extracted from designbook ui_kits/whynot-control/Atoms.jsx. */
.wn-tag { /* TODO: base appearance per ir/exemplars/Tag.html */ }

View File

@@ -0,0 +1,3 @@
/* @generated STUB by adapters/plain-css (T10) from ir/components/TopNav.json — write-once. */
/* TopNav: TopNav — extracted from designbook ui_kits/whynot-control/Chrome.jsx. */
.wn-top-nav { /* TODO: base appearance per ir/exemplars/TopNav.html */ }

View File

@@ -0,0 +1,91 @@
/* @generated by adapters/plain-css (WHYNOT-WP-0002 T10) from ir/tokens.json — DO NOT EDIT. */
:root {
/* color */
--ink: #0A0A0A;
--ink-2: #1F1F1F;
--ink-3: #5C5C5C;
--ink-4: #8A8A8A;
--ink-5: #B5B5B3;
--line: #E5E5E2;
--line-strong: #C9C9C5;
--line-soft: #F0F0EC;
--paper: #FFFFFF;
--paper-2: #FAFAF7;
--paper-3: #F4F4EF;
--fg-1: var(--ink);
--fg-2: var(--ink-3);
--fg-3: var(--ink-4);
--fg-mute: var(--ink-5);
--fg-on-dark: #FAFAF7;
--bg-1: var(--paper);
--bg-2: var(--paper-2);
--bg-3: var(--paper-3);
--bg-invert: var(--ink);
--border: var(--line);
--border-strong: var(--line-strong);
--border-soft: var(--line-soft);
--hi: #FFE14A;
--hi-2: #FFD400;
--hi-ink: #1A1500;
--status-raw: #B5B5B3;
--status-weak: #8A8A8A;
--status-medium: #5C5C5C;
--status-strong: #0A0A0A;
--status-commercial: #FFD400;
--status-error: #B33A2E;
--status-error-bg: #FCF3F1;
--status-warn: #C28000;
--status-warn-bg: #FFFCEB;
--status-success: #2F6B3A;
--status-success-bg: #F2F7F2;
--status-info: #2E5C8A;
--status-info-bg: #F2F5FA;
/* fontFamily */
--ff-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ff-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--ff-serif: ui-serif, Georgia, "Times New Roman", serif;
/* fontSize */
--fs-xs: 11px;
--fs-sm: 13px;
--fs-base: 15px;
--fs-md: 17px;
--fs-lg: 20px;
--fs-xl: 24px;
--fs-2xl: 32px;
--fs-3xl: 44px;
--fs-4xl: 64px;
--fs-5xl: 96px;
/* lineHeight */
--lh-tight: 1.05;
--lh-snug: 1.25;
--lh-base: 1.5;
--lh-loose: 1.7;
/* letterSpacing */
--tr-tight: -0.02em;
--tr-snug: -0.01em;
--tr-base: 0em;
--tr-mono: 0.02em;
--tr-label: 0.08em;
/* space */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--sp-8: 64px;
--sp-9: 96px;
--sp-10: 128px;
/* radius */
--r-0: 0px;
--r-1: 2px;
--r-2: 4px;
--r-3: 8px;
--r-pill: 999px;
/* shadow */
--shadow-0: none;
--shadow-1: 0 1px 0 var(--line);
--shadow-2: 0 1px 0 var(--line-strong);
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
}

View File

@@ -8,6 +8,32 @@ build scripts; `tokens/` and `src/styles/` in the repo root are *derived* from i
See `DesignSystemIntroduction.md` §1 (three places) and §5 (the atelier → repo hop),
and `RecentChanges.md` (regenerated by `make designbook-sync`) for the last diff.
## Refresh runbook — propagating a designbook change to Lit (WHYNOT-WP-0002)
When the cloud designbook moves, run **`make designbook-refresh`** — it chains
check → pull → record → `make ir``make adapt-lit` → (drift triage) → `make
parity-lit` and stops when a human decision is needed. See
`.claude/rules/stack-and-commands.md` for the step list and
`.claude/rules/designbook-propagation.md` for the one-way governance.
**Step 6 — resolving drift (the human step).** When `make adapt-lit` exits `3`,
the refresh halts and points you at `adapters/lit/drift/<Name>.md`. For each
**actionable** issue (informational `non-portable`/`prop-extra` are not gated):
| drift kind | what it means | how to resolve |
|---|---|---|
| `attribute-mismatch` | the Lit property reflects a different attribute than the IR contract | rename the Lit `attribute:` to match the IR, or — if the *language* is what's wrong — change it in Claude Design and re-propagate |
| `prop-missing` | the IR contract has a prop the `<wn-*>` element lacks | add the reactive property + behaviour to the element, **or** if the element models it differently (e.g. a slot, or state on a child), change the React designbook so the contract matches reality |
| `variant-axis-missing` | an IR variant axis has no backing Lit property | add the variant property, or correct the axis in Claude Design |
| `tag-mismatch` | the IR contract's tag has no element; a near-named one exists (e.g. `wn-pipeline-strip` vs the hand-authored `wn-pipeline`) | decide the canonical name **in Claude Design** and re-propagate, then realign the element — do not silently rename only the stack |
**Never** resolve drift by editing `ir/` or back-editing React from the stack —
that desyncs the canonical source (see `designbook-propagation.md`). After
resolving, re-run `make designbook-refresh --no-pull` to confirm `adapt-lit` is
clean and `parity-lit` passes (exit `0`). New components get a write-once stub in
`adapters/lit/stubs/<Name>.js` — move it into `src/elements/`, implement the
behaviour, register it, and re-run.
## How it syncs
The designbook is a cloud project of type `PROJECT_TYPE_DESIGN_SYSTEM`. Sync is

View File

@@ -1,7 +1,7 @@
<!-- 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
**designVersion** `0.4.0` · **components** 10 · **generated** 2026-06-30T07:46:35.138Z
Machine-readable companion: [`manifest.json`](./manifest.json) (per-component + token hashes).
@@ -96,7 +96,7 @@ PageHeader — extracted from designbook ui_kits/whynot-control/Chrome.jsx.
**Contract:** [`components/PageHeader.json`](./components/PageHeader.json) · **hash** `sha256:93e12068e2f58f10` · **exemplar:** [`exemplars/PageHeader.html`](./exemplars/PageHeader.html)
### PipelineStrip `<wn-pipeline-strip>`
### PipelineStrip `<wn-pipeline>`
PipelineStrip — extracted from designbook ui_kits/whynot-control/Chrome.jsx.
@@ -104,7 +104,7 @@ PipelineStrip — extracted from designbook ui_kits/whynot-control/Chrome.jsx.
| --- | --- | --- | --- |
| `activeIdx` | `active-idx` | number | `3` |
**Contract:** [`components/PipelineStrip.json`](./components/PipelineStrip.json) · **hash** `sha256:89c40afe4742d64e` · **exemplar:** [`exemplars/PipelineStrip.html`](./exemplars/PipelineStrip.html)
**Contract:** [`components/PipelineStrip.json`](./components/PipelineStrip.json) · **hash** `sha256:167717c21cceff79` · **exemplar:** [`exemplars/PipelineStrip.html`](./exemplars/PipelineStrip.html)
### Sidebar `<wn-sidebar>`

View File

@@ -1,6 +1,6 @@
{
"name": "PipelineStrip",
"tag": "wn-pipeline-strip",
"tag": "wn-pipeline",
"group": "chrome",
"description": "PipelineStrip — extracted from designbook ui_kits/whynot-control/Chrome.jsx.",
"props": [

View File

@@ -1,7 +1,7 @@
{
"schemaVersion": "1.0.0",
"designVersion": "0.4.0",
"generatedAt": "2026-06-28T22:41:24.992Z",
"generatedAt": "2026-06-30T07:46:35.138Z",
"tokensHash": "sha256:426f565a9ce6c36f",
"components": [
{
@@ -27,7 +27,7 @@
{
"name": "PipelineStrip",
"group": "chrome",
"hash": "sha256:89c40afe4742d64e"
"hash": "sha256:167717c21cceff79"
},
{
"name": "Sidebar",

View File

@@ -1,6 +1,6 @@
{
"name": "@whynot/design",
"version": "0.4.0",
"version": "0.4.1",
"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": false,
"type": "module",

97
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,97 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.45.0
version: 1.61.1
lit:
specifier: ^3.2.1
version: 3.3.3
packages:
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.3:
resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
lit@3.3.3:
resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@types/trusted-types@2.0.7': {}
fsevents@2.3.2:
optional: true
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/reactive-element': 2.1.2
lit-html: 3.3.3
lit-html@3.3.3:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.3:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.3
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
// =============================================================
// designbook-refresh.mjs — the refresh orchestrator (WHYNOT-WP-0002 · T09)
//
// One routine to keep the Lit stack current with the canonical React designbook.
// Runs the *automatable* steps (15, 7) and stops for the *human* step (6) when
// drift is detected, honouring the adapter-contract exit codes:
//
// 1. make designbook-check — has the cloud designbook moved? (best-effort)
// 2. make designbook-pull — pull the React designbook → designbook/ (best-effort)
// 3. make designbook-sync — record the diff → RecentChanges.md (best-effort)
// 4. make ir — re-extract the IR (the blueprint) (gate)
// 5. make adapt-lit — tokens + scaffold + drift (drift gate → 3)
// 6. (human) resolve drift — adapters/lit/drift/*.md ← STOP here on drift
// 7. make parity-lit — contract + visual parity (parity gate → 4)
//
// Best-effort steps (13) need network / the local `claude` binary / llm-connect;
// their failure warns and continues (the IR re-extracts from the current mirror).
// Steps 4/5/7 are deterministic and gate. Exit: highest applicable adapter code —
// 0 ok · 2 usage · 3 drift (stop for triage) · 4 parity failure · 5 internal.
//
// Flags: --no-check --no-pull --no-parity (skip the matching step)
// =============================================================
import { spawnSync } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
const args = new Set(process.argv.slice(2));
function run(label, cmd, { gate = false } = {}) {
console.log(`\n\x1b[1m▶ ${label}\x1b[0m (${cmd})`);
const r = spawnSync(cmd, { cwd: REPO, shell: true, stdio: "inherit" });
const code = r.status == null ? 5 : r.status;
if (code !== 0 && !gate) console.log(`\x1b[33m ↪ step exited ${code} — best-effort, continuing.\x1b[0m`);
return code;
}
function done(code, msg) {
console.log(`\n\x1b[1m${code === 0 ? "✓" : "■"} designbook-refresh: ${msg}\x1b[0m`);
process.exit(code);
}
// 13: best-effort (never abort the refresh).
if (!args.has("--no-check")) run("1/7 cloud-ahead check", "make designbook-check");
if (!args.has("--no-pull")) run("2/7 pull React designbook", "make designbook-pull");
run("3/7 record diff (RecentChanges.md)", "make designbook-sync");
// Gating steps call the node scripts DIRECTLY (not via make, which collapses any
// recipe failure to exit 2 and would hide the adapter's 3/4 drift/parity codes).
const N = JSON.stringify(process.execPath);
// 4: IR extraction — deterministic gate; without it nothing downstream is valid.
const irCode = run("4/7 extract IR", `${N} scripts/ir-extract.mjs`, { gate: true });
if (irCode !== 0) done(irCode === 5 ? 5 : 2, `IR extraction failed (exit ${irCode}). Fix the designbook/ source before refreshing.`);
// 5: adapt-lit — drift gate. Exit 3 ⇒ stop for human triage (step 6).
const adaptCode = run("5/7 adapt Lit (tokens + scaffold + drift)", `${N} adapters/lit/adapt.mjs`, { gate: true });
if (adaptCode === 3) {
done(3, "DRIFT detected (step 6 is yours). Resolve adapters/lit/drift/*.md per " +
".claude/rules/designbook-propagation.md, then re-run `make designbook-refresh --no-pull` to re-check + run parity.");
}
if (adaptCode !== 0) done(adaptCode === 2 ? 2 : 5, `adapt-lit failed (exit ${adaptCode}).`);
// 6: human drift resolution — only reached when adapt-lit is clean.
// 7: parity gate.
if (args.has("--no-parity")) done(0, "tokens + scaffold clean; parity skipped (--no-parity).");
const parityCode = run("7/7 parity (contract + visual)", `${N} adapters/lit/parity.mjs`, { gate: true });
if (parityCode === 4) done(4, "PARITY FAILURE — see adapters/lit/parity/_parity.json.");
if (parityCode !== 0) done(parityCode === 2 ? 2 : 5, `parity-lit failed (exit ${parityCode}).`);
done(0, "in sync — IR extracted, Lit adapted with no drift, parity passed.");

View File

@@ -38,6 +38,18 @@ const COMPONENT_SOURCES = ["Atoms.jsx", "Chrome.jsx"];
const log = (...a) => console.log(...a);
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
// The custom-element tag is an IR-*projection* detail, not something the React
// designbook dictates (React components have no tag). The default mapping is
// `wn-${kebab(Name)}`, but a component's canonical tag can differ from a literal
// kebab of its React name. These overrides record those decisions in one place
// (resolving a tag-mismatch drift the governance-correct way — at the projection,
// not by renaming the hand-authored stack element). The component *name* stays
// faithful to React; only the projected tag is overridden.
const TAG_OVERRIDES = {
PipelineStrip: "wn-pipeline", // React "PipelineStrip"; the established web-component tag is wn-pipeline.
};
const tagFor = (name) => TAG_OVERRIDES[name] || `wn-${kebab(name)}`;
// ---------- Tokens: _ds_manifest.json tokens[] → W3C DTCG ----------
// Each manifest token: { name:"--ink", value:"#0A0A0A", kind:"color", definedIn }.
// Group + DTCG $type are decided by name prefix (kind is a fallback hint).
@@ -204,7 +216,7 @@ function extractComponents() {
const card = matchExemplar(name, cards);
const contract = {
name,
tag: `wn-${kebab(name)}`,
tag: tagFor(name),
group,
description: `${name} — extracted from designbook ui_kits/${KIT}/${file}.`,
props,

View File

@@ -4,14 +4,39 @@ type: workplan
title: "Technology-neutral designbook with stack adapters (Lit reference)"
domain: infotech
repo: whynot-design
status: proposed
status: finished
owner: claude
topic_slug: custodian
created: "2026-06-22"
updated: "2026-06-22"
updated: "2026-06-30"
state_hub_workstream_id: "0a3511c1-1771-438b-9364-104d8f0de2f8"
---
> **Completed 2026-06-30.** All 11 tasks done. The full pipeline is in place:
> `Claude Design (React) → designbook/ → ir/ → adapters/lit` with `make ir`,
> `make adapt-lit` (tokens + scaffold + drift), `make parity-lit`, the
> `make designbook-refresh` orchestrator, and a plain-css second-adapter smoke
> proving the IR seam. Parity passes (contractFail=0, visualFail=0).
>
> **Drift triage — all three resolved 2026-06-30; `make designbook-refresh` is GREEN
> (0 drift, parity pass).** Each was resolved the governance-correct way (no stack→React
> back-edit, no `ir/` hand-edit):
> 1. **PipelineStrip** — the web-component *tag* is an IR-projection detail (React has
> no tags), so a documented `TAG_OVERRIDES` in `scripts/ir-extract.mjs` maps
> `PipelineStrip → wn-pipeline` (the component *name* stays faithful to React). Tag
> now matches the element; parity tests it.
> 2. **PageHeader.actions** — `actions` is rendered as a *node* in React
> (`{actions && <div>{actions}</div>}`), i.e. slotted content, and `<wn-page-header>`
> honours it via `<slot name="actions">`. The drift detector now recognises a prop
> satisfied by a same-named named slot (`prop-via-slot`, informational).
> 3. **Sidebar.current** — a genuine composition divergence (React monolithic `current`
> key ↔ Lit per-item `active` on composable `<wn-sidebar-item>`). Recorded as a
> justified, auditable **accepted divergence** in `adapters/lit/drift.accepted.json`
> — still listed in the report, downgraded to info, does not gate.
>
> The drift machinery now distinguishes real defects from intentional modelling
> differences, which is the point of the gate.
# Technology-neutral designbook with stack adapters (Lit reference)
## Problem
@@ -181,7 +206,7 @@ re-running is a no-op when nothing changed. Add `make adapt-lit` (tokens portion
```task
id: WHYNOT-WP-0002-T07
status: todo
status: done
priority: high
state_hub_task_id: "00ed1aff-7724-4e90-9a51-fd58699480ca"
```
@@ -201,7 +226,7 @@ hand-authored element**. Map React prop types → Lit property declarations. Wir
```task
id: WHYNOT-WP-0002-T08
status: todo
status: done
priority: medium
state_hub_task_id: "1f52ca1f-64a6-4643-992f-f0b4812461a0"
```
@@ -264,7 +289,7 @@ visual tests for determinism.
```task
id: WHYNOT-WP-0002-T09
status: todo
status: done
priority: high
state_hub_task_id: "07e60a34-0c62-4f8b-848b-d3b8d4292a18"
```
@@ -292,7 +317,7 @@ human-in-loop runbook for step 6. Document in `.claude/rules/stack-and-commands.
```task
id: WHYNOT-WP-0002-T10
status: todo
status: done
priority: low
state_hub_task_id: "483de131-f580-4031-85df-72cf70a45679"
```

View File

@@ -100,11 +100,27 @@ tagged. Tag the current state as the first real anchor.
```task
id: WHYNOT-WP-0003-T02
status: wait
status: done
priority: high
state_hub_task_id: "dbd3a2e6-0623-4efd-8293-399002e85ea2"
```
**Done 2026-06-29.** Published `@whynot/design@0.4.0` to the coulomb Gitea npm
registry (`https://gitea.coulomb.social/api/packages/coulomb/npm/`, 66 files).
Cut as a coherent release first (`make release VERSION=0.4.0` + `make ir` so
`ir/manifest.json` designVersion reads 0.4.0; tagged `v0.4.0`). The token was
sourced via the ops-warden access front door — `warden access
whynot-design-npm-publish --field NPM_AUTH_TOKEN --exec -- npm publish` — which
proxies the OpenBao read as the caller (OIDC `bao login -path=netkingdom role=
whynot-design-workload-kv-read`, OpenBao at `bao.coulomb.social`); no secret was
inlined or committed. Install-verified in a scratch consumer: `npm i
@whynot/design@0.4.0 lit` resolves, installed version + `ir/manifest.json`
designVersion both 0.4.0, and the `exports` map resolves (`.`, `/tokens`,
`/styles/components.css`, `/ir/*`). Lane provisioning tracked as railiance-platform
CCR-2026-0001 (active/ready/resolvable). A separate read/install token id
(`whynot-design-npm-read`) is not yet provisioned — the publish token sufficed for
the verify install.
Make the package installable with a version pin:
- Flip `"private": true``false`; fix `repository.url` (currently the placeholder
`gitea.example.com`) to the real Gitea remote.