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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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)
|
||||
|
||||
|
||||
@@ -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
173
adapters/lit/parity.mjs
Normal 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();
|
||||
118
adapters/lit/parity/_parity.json
Normal file
118
adapters/lit/parity/_parity.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user