diff --git a/adapters/lit/adapt.mjs b/adapters/lit/adapt.mjs index c696d36..26bc388 100644 --- a/adapters/lit/adapt.mjs +++ b/adapters/lit/adapt.mjs @@ -16,13 +16,18 @@ // type/utility CSS after it is preserved untouched. Re-running with an unchanged // ir/tokens.json is a no-op (byte-identical output). // ============================================================= -import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { execSync } from "node:child_process"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseLitElements, componentDrift, renderStub } from "./scaffold.mjs"; const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const TOKENS_JSON = join(REPO, "ir", "tokens.json"); const TOKEN_CSS = join(REPO, "src", "styles", "colors_and_type.css"); +const IR_COMPONENTS = join(REPO, "ir", "components"); +const DRIFT_DIR = join(REPO, "adapters", "lit", "drift"); +const STUBS_DIR = join(REPO, "adapters", "lit", "stubs"); const BEGIN = "/* @generated tokens — regenerated by `make adapt-lit` from ir/tokens.json. DO NOT EDIT. */"; const END = "/* @end generated tokens */"; @@ -85,10 +90,104 @@ function generateTokens() { console.log(`tokens: regenerated ${count} custom properties → src/styles/colors_and_type.css`); } +// ---------- T07: component scaffold + drift ---------- +function irRef() { + try { + return execSync("git rev-parse --short HEAD", { cwd: REPO }).toString().trim(); + } catch { return "(no-git)"; } +} + +function renderDriftMd(c) { + const head = { + ok: "✓ in sync with the IR contract.", + drift: "⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`.", + new: "+ new component — a stub was generated; integrate it.", + removed: "- removed from the IR.", + }[c.status]; + const lines = [ + ``, + `# Drift — ${c.name} \`<${c.tag}>\``, + "", + `**Status:** ${c.status} — ${head}`, + "", + ]; + if (!c.issues.length) lines.push("No issues."); + else { + lines.push("| severity | kind | prop | detail |", "| --- | --- | --- | --- |"); + const order = { drift: 0, info: 1 }; + for (const i of [...c.issues].sort((a, b) => order[a.severity] - order[b.severity])) { + const detail = i.detail || (i.expected !== undefined ? `expected \`${i.expected}\`, actual \`${i.actual}\`` : ""); + lines.push(`| ${i.severity === "drift" ? "**drift**" : "info"} | ${i.kind} | ${i.prop ? `\`${i.prop}\`` : "—"} | ${detail} |`); + } + } + lines.push(""); + return lines.join("\n"); +} + +function runScaffold() { + if (!existsSync(IR_COMPONENTS)) { + console.error("No ir/components/ — run `make ir` first."); + process.exit(2); + } + const byTag = parseLitElements(REPO); + const contracts = readdirSync(IR_COMPONENTS).filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(IR_COMPONENTS, f), "utf8"))); + + const results = contracts.map((c) => componentDrift(c, byTag)) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Per-component drift docs: snapshot, so wipe stale ones first (idempotency rule 4). + mkdirSync(DRIFT_DIR, { recursive: true }); + for (const f of readdirSync(DRIFT_DIR)) if (f.endsWith(".md")) rmSync(join(DRIFT_DIR, f)); + for (const c of results) writeFileSync(join(DRIFT_DIR, `${c.name}.md`), renderDriftMd(c)); + + // Machine roll-up. Reuse generatedAt when nothing material changed so a no-op + // `make adapt-lit` produces no git churn (matches ir/manifest.json discipline). + const reportPath = join(DRIFT_DIR, "_report.json"); + let generatedAt = new Date().toISOString(); + let ref = irRef(); + if (existsSync(reportPath)) { + try { + const prev = JSON.parse(readFileSync(reportPath, "utf8")); + if (JSON.stringify(prev.components) === JSON.stringify(results)) { + if (prev.generatedAt) generatedAt = prev.generatedAt; + if (prev.irRef) ref = prev.irRef; // unchanged drift ⇒ keep the prior ref, no churn + } + } catch { /* regenerate fresh */ } + } + const report = { stack: "lit", generatedAt, irRef: ref, components: results }; + writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); + + // Write-once stubs for genuinely new components. + let stubbed = 0; + for (const c of results.filter((r) => r.status === "new")) { + mkdirSync(STUBS_DIR, { recursive: true }); + const out = join(STUBS_DIR, `${c.name}.js`); + if (!existsSync(out)) { writeFileSync(out, renderStub(contracts.find((x) => x.name === c.name))); stubbed++; } + } + + const drift = results.filter((r) => r.status === "drift"); + const isNew = results.filter((r) => r.status === "new"); + const ok = results.filter((r) => r.status === "ok"); + const infoCount = ok.reduce((n, r) => n + r.issues.length, 0); + console.log(`scaffold: ${ok.length} ok · ${drift.length} drift · ${isNew.length} new (${stubbed} stub${stubbed === 1 ? "" : "s"} written) → adapters/lit/drift/`); + if (infoCount) console.log(` (${infoCount} informational note${infoCount === 1 ? "" : "s"} on in-sync components — non-portable/extra props, not gated)`); + for (const c of [...drift, ...isNew]) { + const actionable = c.issues.filter((i) => i.severity === "drift"); + console.log(` ${c.status === "drift" ? "⚠" : "+"} ${c.name}: ${actionable.map((i) => `${i.kind}(${i.prop ?? ""})`).join(", ") || c.issues.map((i) => i.kind).join(", ")}`); + } + + return drift.length + isNew.length > 0; // → drift exit code +} + function main() { generateTokens(); - // T07 will add: component stub scaffolding + drift reports here. - console.log("adapt-lit: tokens done. (component scaffold + drift: T07)"); + const drifted = runScaffold(); + if (drifted) { + console.log("adapt-lit: drift detected — see adapters/lit/drift/. Exit 3 (review, non-fatal)."); + process.exit(3); + } + console.log("adapt-lit: tokens + scaffold done, no drift."); } main(); diff --git a/adapters/lit/drift/Button.md b/adapters/lit/drift/Button.md new file mode 100644 index 0000000..c878c74 --- /dev/null +++ b/adapters/lit/drift/Button.md @@ -0,0 +1,14 @@ + +# Drift — Button `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `onClick` | type=function; not attribute-mappable — handle explicitly, never drop. | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | +| info | prop-extra | `size` | on (attribute 'size'), not in IR contract. | +| info | prop-extra | `iconEnd` | on (attribute 'icon-end'), not in IR contract. | +| info | prop-extra | `type` | on (attribute 'type'), not in IR contract. | +| info | prop-extra | `disabled` | on (attribute 'disabled'), not in IR contract. | +| info | prop-extra | `href` | on (attribute 'href'), not in IR contract. | diff --git a/adapters/lit/drift/Eyebrow.md b/adapters/lit/drift/Eyebrow.md new file mode 100644 index 0000000..c33047f --- /dev/null +++ b/adapters/lit/drift/Eyebrow.md @@ -0,0 +1,9 @@ + +# Drift — Eyebrow `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | +| info | prop-extra | `strong` | on (attribute 'strong'), not in IR contract. | diff --git a/adapters/lit/drift/Icon.md b/adapters/lit/drift/Icon.md new file mode 100644 index 0000000..5d6ea4d --- /dev/null +++ b/adapters/lit/drift/Icon.md @@ -0,0 +1,8 @@ + +# Drift — Icon `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | diff --git a/adapters/lit/drift/PageHeader.md b/adapters/lit/drift/PageHeader.md new file mode 100644 index 0000000..16c3706 --- /dev/null +++ b/adapters/lit/drift/PageHeader.md @@ -0,0 +1,9 @@ + +# Drift — PageHeader `` + +**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| **drift** | prop-missing | `actions` | in IR (attribute 'actions'), absent on | +| info | prop-extra | `hasActions` | on (attribute 'hasactions'), not in IR contract. | diff --git a/adapters/lit/drift/PipelineStrip.md b/adapters/lit/drift/PipelineStrip.md new file mode 100644 index 0000000..25d2765 --- /dev/null +++ b/adapters/lit/drift/PipelineStrip.md @@ -0,0 +1,8 @@ + +# Drift — PipelineStrip `` + +**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| **drift** | tag-mismatch | — | IR contract tag 'wn-pipeline-strip' has no element; 'wn-pipeline' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element). | diff --git a/adapters/lit/drift/Sidebar.md b/adapters/lit/drift/Sidebar.md new file mode 100644 index 0000000..c52633d --- /dev/null +++ b/adapters/lit/drift/Sidebar.md @@ -0,0 +1,11 @@ + +# Drift — Sidebar `` + +**Status:** drift — ⚠ drift detected — resolve per `.claude/rules/designbook-propagation.md`. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| **drift** | prop-missing | `current` | in IR (attribute 'current'), absent on | +| **drift** | variant-axis-missing | `current` | IR variant axis 'current' (doc:) has no Lit property. | +| info | non-portable | `onNav` | type=function; not attribute-mappable — handle explicitly, never drop. | +| info | prop-extra | `activation` | on (attribute 'activation'), not in IR contract. | diff --git a/adapters/lit/drift/StageDot.md b/adapters/lit/drift/StageDot.md new file mode 100644 index 0000000..a85ac53 --- /dev/null +++ b/adapters/lit/drift/StageDot.md @@ -0,0 +1,8 @@ + +# Drift — StageDot `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | diff --git a/adapters/lit/drift/Stamp.md b/adapters/lit/drift/Stamp.md new file mode 100644 index 0000000..3727399 --- /dev/null +++ b/adapters/lit/drift/Stamp.md @@ -0,0 +1,8 @@ + +# Drift — Stamp `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | diff --git a/adapters/lit/drift/Tag.md b/adapters/lit/drift/Tag.md new file mode 100644 index 0000000..a946994 --- /dev/null +++ b/adapters/lit/drift/Tag.md @@ -0,0 +1,8 @@ + +# Drift — Tag `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `style` | type=object; not attribute-mappable — handle explicitly, never drop. | diff --git a/adapters/lit/drift/TopNav.md b/adapters/lit/drift/TopNav.md new file mode 100644 index 0000000..c7213c9 --- /dev/null +++ b/adapters/lit/drift/TopNav.md @@ -0,0 +1,11 @@ + +# Drift — TopNav `` + +**Status:** ok — ✓ in sync with the IR contract. + +| severity | kind | prop | detail | +| --- | --- | --- | --- | +| info | non-portable | `onNew` | type=function; not attribute-mappable — handle explicitly, never drop. | +| info | prop-extra | `logoSrc` | on (attribute 'logo-src'), not in IR contract. | +| info | prop-extra | `brand` | on (attribute 'brand'), not in IR contract. | +| info | prop-extra | `slug` | on (attribute 'slug'), not in IR contract. | diff --git a/adapters/lit/drift/_report.json b/adapters/lit/drift/_report.json new file mode 100644 index 0000000..9f5fda7 --- /dev/null +++ b/adapters/lit/drift/_report.json @@ -0,0 +1,222 @@ +{ + "stack": "lit", + "generatedAt": "2026-06-30T07:01:40.219Z", + "irRef": "17f2ad9", + "components": [ + { + "name": "Button", + "status": "ok", + "tag": "wn-button", + "issues": [ + { + "kind": "non-portable", + "prop": "onClick", + "detail": "type=function; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + }, + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "size", + "detail": "on (attribute 'size'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "iconEnd", + "detail": "on (attribute 'icon-end'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "type", + "detail": "on (attribute 'type'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "disabled", + "detail": "on (attribute 'disabled'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "href", + "detail": "on (attribute 'href'), not in IR contract.", + "severity": "info" + } + ] + }, + { + "name": "Eyebrow", + "status": "ok", + "tag": "wn-eyebrow", + "issues": [ + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "strong", + "detail": "on (attribute 'strong'), not in IR contract.", + "severity": "info" + } + ] + }, + { + "name": "Icon", + "status": "ok", + "tag": "wn-icon", + "issues": [ + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + } + ] + }, + { + "name": "PageHeader", + "status": "drift", + "tag": "wn-page-header", + "issues": [ + { + "kind": "prop-missing", + "prop": "actions", + "detail": "in IR (attribute 'actions'), absent on ", + "severity": "drift" + }, + { + "kind": "prop-extra", + "prop": "hasActions", + "detail": "on (attribute 'hasactions'), not in IR contract.", + "severity": "info" + } + ] + }, + { + "name": "PipelineStrip", + "status": "drift", + "tag": "wn-pipeline-strip", + "issues": [ + { + "kind": "tag-mismatch", + "expected": "wn-pipeline-strip", + "actual": "wn-pipeline", + "detail": "IR contract tag 'wn-pipeline-strip' has no element; 'wn-pipeline' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element).", + "severity": "drift" + } + ] + }, + { + "name": "Sidebar", + "status": "drift", + "tag": "wn-sidebar", + "issues": [ + { + "kind": "prop-missing", + "prop": "current", + "detail": "in IR (attribute 'current'), absent on ", + "severity": "drift" + }, + { + "kind": "non-portable", + "prop": "onNav", + "detail": "type=function; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + }, + { + "kind": "variant-axis-missing", + "prop": "current", + "detail": "IR variant axis 'current' (doc:) has no Lit property.", + "severity": "drift" + }, + { + "kind": "prop-extra", + "prop": "activation", + "detail": "on (attribute 'activation'), not in IR contract.", + "severity": "info" + } + ] + }, + { + "name": "StageDot", + "status": "ok", + "tag": "wn-stage-dot", + "issues": [ + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + } + ] + }, + { + "name": "Stamp", + "status": "ok", + "tag": "wn-stamp", + "issues": [ + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + } + ] + }, + { + "name": "Tag", + "status": "ok", + "tag": "wn-tag", + "issues": [ + { + "kind": "non-portable", + "prop": "style", + "detail": "type=object; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + } + ] + }, + { + "name": "TopNav", + "status": "ok", + "tag": "wn-top-nav", + "issues": [ + { + "kind": "non-portable", + "prop": "onNew", + "detail": "type=function; not attribute-mappable — handle explicitly, never drop.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "logoSrc", + "detail": "on (attribute 'logo-src'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "brand", + "detail": "on (attribute 'brand'), not in IR contract.", + "severity": "info" + }, + { + "kind": "prop-extra", + "prop": "slug", + "detail": "on (attribute 'slug'), not in IR contract.", + "severity": "info" + } + ] + } + ] +} diff --git a/adapters/lit/scaffold.mjs b/adapters/lit/scaffold.mjs new file mode 100644 index 0000000..65cb3e0 --- /dev/null +++ b/adapters/lit/scaffold.mjs @@ -0,0 +1,196 @@ +// ============================================================= +// adapters/lit/scaffold.mjs — component scaffold + drift (WHYNOT-WP-0002 · T07) +// +// Pure functions over ir/ + the Lit source tree (src/elements/*.js). Per +// adapters/ADAPTER_CONTRACT.md: +// • IR component with no counterpart → a write-once STUB +// (adapters/lit/stubs/.js), never into the hand-authored tree. +// • IR component with a counterpart → a DRIFT REPORT +// (adapters/lit/drift/.md + a machine roll-up); never an overwrite. +// +// Behaviour is never generated — stubs carry a TODO; drift is for human triage. +// ============================================================= +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const ELEMENT_FILES = ["atoms.js", "form.js", "layout.js", "chrome.js"]; + +// ---------- Parse the Lit source tree → tag → element descriptor ---------- +// Extract the balanced { … } block that starts at/after `from`. +function balancedBlock(src, from) { + let i = src.indexOf("{", from), depth = 0; + const begin = i; + for (; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(begin, i + 1); + } + return src.slice(begin); +} + +// Parse a `static properties = { … }` block body into { name: {attribute,type,reflect} }. +function parseProps(block) { + const props = {}; + // Each entry: `name: { … },` — scan key then its balanced object. + const re = /([A-Za-z_$][\w$]*)\s*:\s*\{/g; + let m; + while ((m = re.exec(block))) { + const name = m[1]; + const decl = balancedBlock(block, m.index + m[0].length - 1); + const attrM = /attribute\s*:\s*(false|"([^"]+)"|'([^']+)')/.exec(decl); + const typeM = /type\s*:\s*([A-Za-z]+)/.exec(decl); + const reflect = /reflect\s*:\s*true/.test(decl); + let attribute; + if (attrM) attribute = attrM[1] === "false" ? false : (attrM[2] || attrM[3]); + else attribute = name.toLowerCase(); // Lit default: lowercased property name + props[name] = { attribute, type: typeM ? typeM[1] : "String", reflect }; + re.lastIndex = m.index + m[0].length; // resume after the key (decl re-scanned harmlessly) + } + return props; +} + +// Find a class's own `static properties` (walks `extends` within the same file set). +function classProps(name, classes) { + const cls = classes[name]; + if (!cls) return {}; + const own = cls.propsBlock ? parseProps(cls.propsBlock) : {}; + const inherited = cls.extends && classes[cls.extends] ? classProps(cls.extends, classes) : {}; + return { ...inherited, ...own }; +} + +export function parseLitElements(repo) { + const classes = {}; // className → { propsBlock, extends, file } + const defines = []; // { tag, className, file } + for (const file of ELEMENT_FILES) { + const path = join(repo, "src", "elements", file); + if (!existsSync(path)) continue; + const src = readFileSync(path, "utf8"); + const classRe = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$]*)/g; + let m; + while ((m = classRe.exec(src))) { + const [className, base] = [m[1], m[2]]; + const propsAt = src.indexOf("static properties", m.index); + // bound the search to before the next class declaration + classRe.lastIndex = m.index + m[0].length; + const nextClass = src.indexOf("class ", m.index + m[0].length); + const propsBlock = propsAt !== -1 && (nextClass === -1 || propsAt < nextClass) + ? balancedBlock(src, propsAt) : null; + classes[className] = { propsBlock, extends: base, file }; + } + const defRe = /customElements\.define\(\s*["']([\w-]+)["']\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g; + while ((m = defRe.exec(src))) defines.push({ tag: m[1], className: m[2], file }); + } + const byTag = {}; + for (const d of defines) byTag[d.tag] = { ...d, props: classProps(d.className, classes) }; + return byTag; +} + +// ---------- Drift: IR contract vs Lit element ---------- +// Actionable drift gates `make adapt-lit` (exit 3). The rest is informational: +// • non-portable — React style/callbacks that inherently have no attribute form; +// the Lit element is correct to omit them. Surfaced (never dropped), never gated. +// • prop-extra — the Lit element is richer than the minimal React designbook; +// a divergence worth noting, but not a defect to block on. +const ACTIONABLE = new Set(["prop-missing", "attribute-mismatch", "variant-axis-missing", "tag-mismatch"]); + +export function litAttrOf(decl) { + return decl.attribute === false ? null : decl.attribute; +} + +function classify(issues) { + for (const i of issues) i.severity = ACTIONABLE.has(i.kind) ? "drift" : "info"; + return issues.some((i) => i.severity === "drift") ? "drift" : "ok"; +} + +// Find a likely renamed counterpart when no exact tag match (e.g. wn-pipeline-strip → wn-pipeline). +function likelyCounterpart(tag, byTag) { + if (byTag[tag]) return null; + const head = tag.replace(/^wn-/, "").split("-")[0]; // "pipeline" + const hit = Object.keys(byTag).find((t) => t.replace(/^wn-/, "").split("-")[0] === head); + return hit || null; +} + +export function componentDrift(contract, byTag) { + const tag = contract.tag; + const el = byTag[tag]; + + if (!el) { + const alt = likelyCounterpart(tag, byTag); + if (alt) { + const issues = [{ kind: "tag-mismatch", expected: tag, actual: alt, + detail: `IR contract tag '${tag}' has no element; '${alt}' looks like the hand-authored counterpart (rename — resolve in Claude Design or realign the element).` }]; + return { name: contract.name, status: classify(issues), tag, issues }; + } + return { name: contract.name, status: "new", tag, issues: [ + { kind: "no-counterpart", detail: `no <${tag}> in src/elements/ — stub generated.` }, + ] }; + } + + const issues = []; + const litProps = el.props; + for (const p of contract.props || []) { + if (p.portable === false) { + issues.push({ kind: "non-portable", prop: p.name, + detail: `type=${p.type}; not attribute-mappable — handle explicitly, never drop.` }); + continue; + } + if (p.attribute === false) continue; // property-only by contract + const lit = litProps[p.name]; + if (!lit) { + issues.push({ kind: "prop-missing", prop: p.name, + detail: `in IR (attribute '${p.attribute}'), absent on <${tag}>` }); + continue; + } + const litAttr = litAttrOf(lit); + if (litAttr !== p.attribute) { + issues.push({ kind: "attribute-mismatch", prop: p.name, expected: p.attribute, actual: litAttr === null ? "(property-only)" : litAttr }); + } + } + // Variant axes must have a backing property. + for (const v of contract.variants || []) { + if (!litProps[v.axis]) issues.push({ kind: "variant-axis-missing", prop: v.axis, + detail: `IR variant axis '${v.axis}' (${v.values.join("/")}) has no Lit property.` }); + } + // Extra Lit properties not described by the IR contract. + const irNames = new Set((contract.props || []).map((p) => p.name)); + for (const name of Object.keys(litProps)) { + if (!irNames.has(name)) issues.push({ kind: "prop-extra", prop: name, + detail: `on <${tag}> (attribute '${litAttrOf(litProps[name]) ?? "(property-only)"}'), not in IR contract.` }); + } + + return { name: contract.name, status: classify(issues), tag, issues }; +} + +// ---------- Stub generation (write-once) ---------- +export function renderStub(contract) { + const tag = contract.tag; + const cls = "Wn" + contract.name; + const props = (contract.props || []).filter((p) => p.portable !== false && p.attribute !== false); + const litType = (t) => (t === "boolean" ? "Boolean" : t === "number" ? "Number" : "String"); + const propLines = props.map((p) => { + const attr = p.attribute !== p.name.toLowerCase() ? `, attribute: "${p.attribute}"` : ""; + return ` ${p.name}: { type: ${litType(p.type)}${attr} },`; + }).join("\n"); + const nonPortable = (contract.props || []).filter((p) => p.portable === false); + const npNote = nonPortable.length + ? `\n // Non-portable props (handle explicitly, do not drop): ${nonPortable.map((p) => p.name).join(", ")}.` + : ""; + const slotMarkup = (contract.slots || []).some((s) => s.name === "default") + ? "" : ""; + return `// @generated STUB by adapters/lit (WHYNOT-WP-0002 T07) — from ir/components/${contract.name}.json. +// Write-once scaffold: skeleton + typed reactive properties + a behaviour TODO. +// Move into src/elements/ and implement; the adapter never overwrites this once edited. +import { LitElement, html } from "lit"; + +export class ${cls} extends LitElement { + createRenderRoot() { return this; } // light DOM, matches the existing wn-* elements${npNote} + static properties = { +${propLines || " // (no attribute-mappable props in the contract)"} + }; + render() { + // TODO(${contract.name}): implement per ir/exemplars/${contract.name}.html and the design language. + return html\`
${slotMarkup}
\`; + } +} +// customElements.define("${tag}", ${cls}); // ← uncomment when integrating +`; +}