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>
This commit is contained in:
2026-06-30 09:21:16 +02:00
parent 7cf524137f
commit 36f7b9f7b9
15 changed files with 367 additions and 0 deletions

View File

@@ -40,6 +40,9 @@ parity-lit: ## Confirm Lit elements honour the IR contract + render (browser). E
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

@@ -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:20:50.544Z",
"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-strip 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-strip { /* 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);
}