diff --git a/.gitignore b/.gitignore index 25bd05c..3b1f325 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index a04c9d6..5713549 100644 --- a/Makefile +++ b/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) diff --git a/adapters/lit/README.md b/adapters/lit/README.md index 828a2dd..9c81187 100644 --- a/adapters/lit/README.md +++ b/adapters/lit/README.md @@ -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 `` Lit stub from the IR contract's prop→attribute map + a behaviour `TODO`. | T07 | -| **Changed component** | Emit a **drift report** (`adapters/lit/drift/.md`) — never overwrite the hand-authored element. | T07 | +| **New component** | Generate a `` Lit stub (`adapters/lit/stubs/.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/.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 `` 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/.png` (gitignored) as the + artifact. The `ir/exemplars/.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 diff --git a/adapters/lit/parity.mjs b/adapters/lit/parity.mjs new file mode 100644 index 0000000..066633b --- /dev/null +++ b/adapters/lit/parity.mjs @@ -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 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/.png as the artifact. +// +// On pixel-exact appearance: `ir/exemplars/.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(); diff --git a/adapters/lit/parity/_parity.json b/adapters/lit/parity/_parity.json new file mode 100644 index 0000000..817922b --- /dev/null +++ b/adapters/lit/parity/_parity.json @@ -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 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 + } +}