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

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