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>
This commit is contained in:
2026-06-30 09:16:13 +02:00
parent 552d8fe926
commit e4e3fe069c
5 changed files with 327 additions and 2 deletions

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

@@ -34,6 +34,9 @@ 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
recent-changes: ## Regenerate RecentChanges.md (alias of the reporter; --range supported).
$(NODE) scripts/designbook-sync.mjs $(ARGS)

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

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

@@ -0,0 +1,173 @@
// =============================================================
// 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 } 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 results = [];
for (const c of contracts) {
const tag = c.tag;
const drift = componentDrift(c, byTag);
const attrMismatch = drift.issues.filter((i) => i.kind === "attribute-mismatch");
const missing = drift.issues.filter((i) => i.kind === "prop-missing");
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,118 @@
{
"stack": "lit",
"generatedAt": "2026-06-30T07:15:06.747Z",
"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": "coverage (drift, not gated): missing actions"
},
{
"name": "PipelineStrip",
"contract": "skip",
"visual": "skip",
"diffRatio": null,
"notes": "no <wn-pipeline-strip> element (see adapters/lit/drift/PipelineStrip.md)"
},
{
"name": "Sidebar",
"contract": "pass",
"visual": "pass",
"diffRatio": null,
"box": {
"w": 273,
"h": 592
},
"notes": "coverage (drift, not gated): missing current"
},
{
"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": 1
}
}