From 470b62b24990a859ac07c1a30e554953a2eedd42 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 5 Aug 2026 17:56:05 -0400 Subject: [PATCH 1/2] test(shadcn): assert emitted-prop parity against the real emitted corpus A2UI's A3 gate validates a message against the catalog, so it passes a renderer that ignores every prop it is handed, and a screenshot cannot say which emitted prop produced which pixel. This suite closes that gap at the prop level, against real emitter output only: every distinct A2UI instance in packages/contracts/out and in the recorded replay fixtures, folded last-write-wins, with nothing hand-listed. Four properties, each grounded in how the stack runs: - consumption: perturbing an emitted prop must change the markup; - distinguishability: every legal value of an emitted enum prop must render distinguishably (a projection may restyle, not collapse); - default fidelity: A2UI's GenericBinder resolves raw properties and never runs the zod schema's .default(), so an omitted prop reaches the renderer as undefined and the renderer's fallback is the only thing that can honor the catalog default; - content: slots built exactly once, repeated items and table rows preserved, blank output failed rather than silently passed. Landed fail-first, against the pre-fix renderers: 4 of 13 fail, naming 13 ignored-prop instances, 8 collapsed enum vocabularies, and 2 wrong catalog defaults (AlertDialog dresses the contract's `primary` default as a destructive confirm). The structural detectors are additionally turned on deliberately broken visuals so no guard is vacuous. Co-Authored-By: Claude Opus 5 --- packages/shadcn-renderers/package.json | 3 + .../shadcn-renderers/src/emitted-corpus.ts | 160 +++++++ .../src/emitted-prop-parity.test.tsx | 398 ++++++++++++++++++ pnpm-lock.yaml | 12 +- 4 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 packages/shadcn-renderers/src/emitted-corpus.ts create mode 100644 packages/shadcn-renderers/src/emitted-prop-parity.test.tsx diff --git a/packages/shadcn-renderers/package.json b/packages/shadcn-renderers/package.json index cdc9524..65b46b7 100644 --- a/packages/shadcn-renderers/package.json +++ b/packages/shadcn-renderers/package.json @@ -26,6 +26,9 @@ "devDependencies": { "@tailwindcss/cli": "^4.1.0", "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "tailwindcss": "^4.1.0", "typescript": "^5.7.2", "vitest": "^3.0.0" diff --git a/packages/shadcn-renderers/src/emitted-corpus.ts b/packages/shadcn-renderers/src/emitted-corpus.ts new file mode 100644 index 0000000..44baf7a --- /dev/null +++ b/packages/shadcn-renderers/src/emitted-corpus.ts @@ -0,0 +1,160 @@ +/** + * The EMITTED corpus: every A2UI component instance dspack-emit has actually + * produced in this repo, plus the catalog shapes those instances are validated + * against. Test-only (never exported from the package index) — it exists so the + * parity suite can compare what the emitter EMITS against what a renderer + * CONSUMES, rather than against a hand-written list that can drift with it. + * + * Two sources, both real emitter output: + * - packages/contracts/out/*.surface.json — surfaces emitted by the contracts + * build (`emitSurface`) from the authored .dsurface.json / worked example. + * - packages/replay/fixtures/*.json — recorded runs; their A2UI operations + * ride inside AG-UI TOOL_CALL_RESULT payloads. + * + * Instances are folded LAST-WRITE-WINS per (file, surfaceId, component id), so + * the corpus is the state a renderer is finally asked to draw, not an + * intermediate delivery. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +export const CONTRACTS_OUT = fileURLToPath(new URL("../../contracts/out/", import.meta.url)); +export const FIXTURES_DIR = fileURLToPath(new URL("../../replay/fixtures/", import.meta.url)); + +export const catalog: Record = JSON.parse( + readFileSync(join(CONTRACTS_OUT, "catalog.v0_9_1.json"), "utf8"), +); + +export interface EmittedInstance { + /** Source file the instance was emitted into. */ + source: string; + surfaceId: string; + /** The raw emitted component object, including `id` and `component`. */ + component: Record; +} + +/** Pull every A2UI operation out of a file, whichever envelope carries it. */ +function a2uiOperations(doc: unknown): any[] { + const ops: any[] = []; + const walk = (node: any) => { + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (!node || typeof node !== "object") return; + // Recorded runs: operations ride inside AG-UI tool-call results. + if (node.type === "TOOL_CALL_RESULT" && typeof node.content === "string") { + try { + const parsed = JSON.parse(node.content); + if (Array.isArray(parsed?.a2ui_operations)) ops.push(...parsed.a2ui_operations); + } catch { + /* non-JSON tool results are not A2UI payloads */ + } + } + // Contracts build output: { messages: [...] }. + if (Array.isArray(node.messages)) ops.push(...node.messages); + Object.values(node).forEach(walk); + }; + walk(doc); + return ops; +} + +let cached: EmittedInstance[] | undefined; + +/** Every distinct emitted instance, in discovery order. */ +export function emittedInstances(): EmittedInstance[] { + if (cached) return cached; + const files: Array<[string, string]> = []; + for (const f of readdirSync(CONTRACTS_OUT)) { + if (f.endsWith(".surface.json")) files.push([CONTRACTS_OUT, f]); + } + for (const f of readdirSync(FIXTURES_DIR)) { + if (f.endsWith(".json")) files.push([FIXTURES_DIR, f]); + } + const final = new Map(); + for (const [dir, file] of files) { + const doc = JSON.parse(readFileSync(join(dir, file), "utf8")); + for (const op of a2uiOperations(doc)) { + const update = op?.updateComponents; + if (!update || !Array.isArray(update.components)) continue; + for (const component of update.components) { + if (!component?.id || !component?.component) continue; + final.set(`${file}|${update.surfaceId}|${component.id}`, { + source: file, + surfaceId: String(update.surfaceId), + component, + }); + } + } + } + cached = [...final.values()]; + return cached; +} + +/** Props the emitter actually put on an instance (envelope keys removed). */ +export function emittedProps(component: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(component)) { + if (k === "id" || k === "component") continue; + out[k] = v; + } + return out; +} + +/** Flattened catalog property schemas for one component (allOf/$ref resolved). */ +export function catalogProps(componentName: string): Record { + const node = catalog.components?.[componentName]; + if (!node) throw new Error(`Component '${componentName}' is not in the emitted catalog.`); + const acc: Record = {}; + const flatten = (n: any) => { + if (!n || typeof n !== "object") return; + if (typeof n.$ref === "string" && n.$ref.startsWith("#/")) { + let target: any = catalog; + for (const seg of n.$ref.replace(/^#\//, "").split("/")) target = target?.[seg]; + flatten(target); + } + if (Array.isArray(n.allOf)) n.allOf.forEach(flatten); + if (n.properties) Object.assign(acc, n.properties); + }; + flatten(node); + delete acc.component; + delete acc.id; + return acc; +} + +/** + * Every (component, enum prop) pair the emitter actually emits, with the + * catalog's full legal value set and declared default. This is the contract + * vocabulary a renderer has to keep distinguishable. + */ +export function emittedEnumProps(): Array<{ + componentName: string; + prop: string; + values: string[]; + default?: string; + emittedValues: string[]; +}> { + const seen = new Map>(); + for (const { component } of emittedInstances()) { + for (const [prop, value] of Object.entries(emittedProps(component))) { + const key = `${component.component}.${prop}`; + if (!seen.has(key)) seen.set(key, new Set()); + if (typeof value === "string") seen.get(key)!.add(value); + } + } + const out: ReturnType = []; + for (const key of seen.keys()) { + const [componentName, prop] = key.split("."); + const schema = catalogProps(componentName)[prop]; + if (!Array.isArray(schema?.enum)) continue; + out.push({ + componentName, + prop, + values: schema.enum, + default: schema.default, + emittedValues: [...seen.get(key)!], + }); + } + return out; +} diff --git a/packages/shadcn-renderers/src/emitted-prop-parity.test.tsx b/packages/shadcn-renderers/src/emitted-prop-parity.test.tsx new file mode 100644 index 0000000..84a3fef --- /dev/null +++ b/packages/shadcn-renderers/src/emitted-prop-parity.test.tsx @@ -0,0 +1,398 @@ +/** + * PROPS-LEVEL PARITY: what dspack-emit EMITS vs what this design system's + * renderers actually CONSUME. + * + * Why this suite exists, and why the checks it already had are not enough: + * A3 (the emitter's instance gate) validates a message against the catalog, so + * it passes for a renderer that ignores every prop it is handed; a screenshot + * shows pixels but cannot say which emitted prop produced them. A schema-valid + * surface that renders wrong is FAILED representation evidence — the drift has + * to be caught at the prop level or it is not caught at all. + * + * The corpus is real emitter output (see `emitted-corpus.ts`): every distinct + * A2UI instance in the contracts build and in the recorded fixtures. Nothing + * here is hand-listed, so growing the scenario shelf grows the guard. + * + * The four properties asserted, each grounded in how the stack actually runs: + * 1. CONSUMPTION — perturbing an emitted prop to another legal value must + * change the rendered markup. If it does not, the renderer is provably + * ignoring that prop. + * 2. DISTINGUISHABILITY — for an enum prop the emitter emits, every legal + * catalog value must render distinguishably. A design system may project a + * treatment onto its own idiom, but collapsing distinct contract values + * onto identical pixels destroys the distinction the contract carried. + * 3. DEFAULT FIDELITY — omitting a prop must render as the catalog's declared + * default. A2UI's GenericBinder resolves raw properties and never runs the + * zod schema's `.default()`, so an omitted prop reaches the renderer as + * `undefined` and the renderer's own fallback is the ONLY thing that can + * honor the contract default. + * 4. CONTENT — every string the instance carries must appear in the output, + * every child id must be built exactly once, and every table row must + * survive. Blank output for an instance that carries content is a failure, + * never a silent pass. + */ +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { shadcnRegistry } from "./registry"; +import { + catalogProps, + emittedEnumProps, + emittedInstances, + emittedProps, + type EmittedInstance, +} from "./emitted-corpus"; + +/* ------------------------------------------------------------------ render */ + +/** Marker child so slot wiring is observable in static markup. */ +const buildChild = (id: string): ReactNode => createElement("i", { key: id }, `[child:${id}]`); + +/** + * Shape raw emitted props the way A2UI's binder delivers them: actions become + * callables, DynamicString bindings arrive resolved, everything else verbatim. + */ +function bindProps(component: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(emittedProps(component))) { + if (key === "action") { + out[key] = () => {}; + } else if (value && typeof value === "object" && !Array.isArray(value) && typeof value.path === "string") { + out[key] = `bound:${value.path}`; + } else { + out[key] = value; + } + } + return out; +} + +function render(componentName: string, props: Record): string { + const Visual = (shadcnRegistry.custom as Record)[componentName]; + if (!Visual) return ""; + return renderToStaticMarkup( + createElement(Visual, { + props, + buildChild, + context: { componentModel: { id: "node" }, dataContext: { path: "/" } }, + }), + ); +} + +const visibleText = (html: string) => html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + +/** Instances this design system draws (Dialog is the deliberate placeholder). */ +const renderable = (): EmittedInstance[] => + emittedInstances().filter((i) => Boolean((shadcnRegistry.custom as Record)[i.component.component])); + +const label = (i: EmittedInstance) => `${i.source}:${i.surfaceId}#${i.component.id} (${i.component.component})`; + +/** Another legal value for an emitted prop — enum-aware, shape-aware. */ +function perturb(componentName: string, prop: string, value: unknown): unknown { + const schema = catalogProps(componentName)[prop]; + if (Array.isArray(schema?.enum)) return schema.enum.find((v: unknown) => v !== value) ?? value; + if (typeof value === "boolean") return !value; + if (typeof value === "number") return value + 41; + if (typeof value === "string") return `${value} PERTURBED`; + if (Array.isArray(value)) { + if (value.length > 1) return value.slice(0, -1); + if (value.length === 1) { + const [only] = value; + if (typeof only === "string") return [`${only}-perturbed`]; + if (only && typeof only === "object") return []; + } + return [...value, "perturbed"]; + } + return value; +} + +/* ------------------------------------------------- 1. emitted vs consumed */ + +describe("emitted-prop vs consumed-prop parity", () => { + it("every prop the emitter emits changes what this design system renders", () => { + const ignored: string[] = []; + for (const instance of renderable()) { + const name = instance.component.component; + const props = bindProps(instance.component); + const baseline = render(name, props); + for (const [prop, value] of Object.entries(props)) { + if (prop === "action") continue; // dispatch behavior, not markup + const other = perturb(name, prop, instance.component[prop]); + if (JSON.stringify(other) === JSON.stringify(instance.component[prop])) continue; + if (render(name, { ...props, [prop]: other }) === baseline) { + ignored.push(`${name}.${prop} ignored at ${label(instance)}`); + } + } + } + expect(ignored).toEqual([]); + }); + + it("covers a corpus that actually exercises the registry", () => { + // A guard on the guard: if the corpus ever empties (a moved fixture + // directory, a skipped contracts build) every check above passes vacuously. + const instances = renderable(); + expect(instances.length).toBeGreaterThan(100); + const covered = new Set(instances.map((i) => i.component.component)); + for (const name of Object.keys(shadcnRegistry.custom)) expect([...covered]).toContain(name); + }); +}); + +/* ------------------------------------------- 2. variant / value fidelity */ + +describe("variant fidelity", () => { + it("keeps every legal value of an emitted enum prop distinguishable", () => { + const collapsed: string[] = []; + for (const { componentName, prop, values } of emittedEnumProps()) { + const instance = renderable().find((i) => i.component.component === componentName && prop in i.component); + if (!instance) continue; + const props = bindProps(instance.component); + const byMarkup = new Map(); + for (const value of values) { + const markup = render(componentName, { ...props, [prop]: value }); + byMarkup.set(markup, [...(byMarkup.get(markup) ?? []), value]); + } + for (const group of byMarkup.values()) { + if (group.length > 1) collapsed.push(`${componentName}.${prop}: ${group.join(" = ")} render identically`); + } + } + expect(collapsed).toEqual([]); + }); + + it("renders a destructive action destructively, and a non-destructive one not", () => { + // The named failure mode: a destructive button that looks like any other. + const destructive = render("Button", { label: "Delete project", variant: "destructive", action: () => {} }); + const primary = render("Button", { label: "Delete project", variant: "primary", action: () => {} }); + expect(destructive).toContain("destructive"); + expect(destructive).not.toEqual(primary); + expect(primary).not.toContain("destructive"); + + // AlertDialog's confirm button carries the same duty... + const confirm = render("AlertDialog", { + title: "Delete this project?", + description: "This cannot be undone.", + actionLabel: "Delete project", + actionVariant: "destructive", + action: () => {}, + }); + expect(confirm).toContain("destructive"); + + // ...in both directions: the contract's `primary` default must not be + // dressed as a destructive confirm, or every dialog reads as dangerous. + const informational = { + title: "Publish this project?", + description: "It becomes visible to your team.", + actionLabel: "Publish project", + action: () => {}, + }; + expect(render("AlertDialog", { ...informational, actionVariant: "primary" })).not.toContain("destructive"); + // `actionVariant` is optional; omitting it means the catalog default, + // which is `primary` — not destructive. + expect(render("AlertDialog", informational)).not.toContain("destructive"); + }); +}); + +/* -------------------------------------------------- 3. default fidelity */ + +describe("catalog default fidelity", () => { + it("renders an omitted prop as the catalog's declared default", () => { + // A2UI's binder never applies the zod schema's default (it resolves raw + // properties), so the renderer's fallback is the contract's last defense. + const wrong: string[] = []; + for (const { componentName, prop, default: declared } of emittedEnumProps()) { + if (declared === undefined) continue; + const instance = renderable().find((i) => i.component.component === componentName && prop in i.component); + if (!instance) continue; + const props = bindProps(instance.component); + const { [prop]: _omitted, ...without } = props; + const asDefault = render(componentName, { ...props, [prop]: declared }); + if (render(componentName, without) !== asDefault) { + wrong.push(`${componentName}.${prop} omitted does not render as the catalog default '${declared}'`); + } + } + expect(wrong).toEqual([]); + }); +}); + +/* --------------------------------------- 4. slots, repeats, content, rows */ + +/** + * The structural detectors, written as pure `(markup, emitted) -> violations` + * so each one can be turned on a DELIBERATELY BROKEN visual below. A detector + * that has never rejected anything is decoration, not a guard. + */ + +/** Every child id the instance hands the renderer must be built exactly once. */ +function slotViolations(html: string, component: Record): string[] { + const ids: string[] = []; + if (typeof component.child === "string") ids.push(component.child); + if (Array.isArray(component.children)) { + ids.push(...component.children.filter((c: unknown): c is string => typeof c === "string")); + } + return ids + .map((id) => ({ id, times: html.split(`[child:${id}]`).length - 1 })) + .filter(({ times }) => times !== 1) + .map(({ id, times }) => `child '${id}' built ${times}x`); +} + +/** Every repeated item a data-driven prop carries must reach the output. */ +function repeatViolations(html: string, component: Record): string[] { + const text = visibleText(html); + const out: string[] = []; + for (const item of (component.items ?? []) as Array>) { + for (const field of ["label", "value"] as const) { + const v = item?.[field]; + if (typeof v === "string" && v.trim() && !text.includes(v.trim())) out.push(`item ${field} '${v}'`); + } + } + for (const column of (component.columns ?? []) as unknown[]) { + if (typeof column === "string" && column.trim() && !text.includes(column.trim())) { + out.push(`column header '${column}'`); + } + } + return out; +} + +/** One body row per emitted row, every cell and status intact. */ +function tableRowViolations(html: string, component: Record): string[] { + const out: string[] = []; + const body = html.slice(html.indexOf(""), html.indexOf("")); + const rendered = body.split(">; + const children = (component.children ?? []) as string[]; + const expected = + rows.length > 0 + ? rows.length + : Math.ceil(children.length / Math.max(((component.columns ?? []) as unknown[]).length, 1)); + if (rendered !== expected) out.push(`rendered ${rendered} body rows for ${expected} emitted`); + const text = visibleText(html); + for (const row of rows) { + for (const cell of row?.cells ?? []) { + if (String(cell).trim() && !text.includes(String(cell).trim())) out.push(`dropped cell '${cell}'`); + } + const status = row?.status?.label; + if (typeof status === "string" && status.trim() && !text.includes(status.trim())) { + out.push(`dropped status '${status}'`); + } + } + return out; +} + +/** Every string an emitted instance promises will be visible. */ +function promisedText(component: Record): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) out.push(v.trim()); + }; + for (const key of ["label", "text", "title", "description", "actionLabel", "cancelLabel"]) push(component[key]); + for (const item of (component.items ?? []) as Array>) { + push(item?.label); + push(item?.value); + } + for (const row of (component.data ?? []) as Array>) { + for (const cell of row?.cells ?? []) push(String(cell)); + } + for (const column of (component.columns ?? []) as unknown[]) push(column as string); + return out; +} + +/** Blank output, or promised content missing from it. */ +function contentViolations(html: string, component: Record): string[] { + const promised = promisedText(component); + if (promised.length === 0) return []; // nothing claimed, nothing owed + const text = visibleText(html); + if (!text) return ["rendered nothing"]; + const dropped = promised.filter((s) => !text.includes(s) && !html.includes(s)); + return dropped.length ? [`dropped ${JSON.stringify(dropped)}`] : []; +} + +/** A Table instance with rows, cells, a status column and headers. */ +const TABLE_FIXTURE = { + component: "Table", + columns: ["Ticket", "Customer"], + data: [ + { cells: ["T-1041", "Northwind"], status: { label: "open", variant: "info" } }, + { cells: ["T-1042", "Contoso"], status: { label: "escalated", variant: "error" } }, + { cells: ["T-1043", "Fabrikam"], status: { label: "closed", variant: "neutral" } }, + ], +}; + +describe("slot and repeated-item parity", () => { + it("builds every emitted child id exactly once", () => { + const broken: string[] = []; + for (const instance of renderable()) { + const html = render(instance.component.component, bindProps(instance.component)); + for (const v of slotViolations(html, instance.component)) broken.push(`${label(instance)} ${v}`); + } + expect(broken).toEqual([]); + }); + + it("renders every repeated item a data-driven prop carries", () => { + const missing: string[] = []; + for (const instance of renderable()) { + const html = render(instance.component.component, bindProps(instance.component)); + for (const v of repeatViolations(html, instance.component)) missing.push(`${label(instance)} ${v}`); + } + expect(missing).toEqual([]); + }); + + it("rejects a visual that ignores its slot or drops repeated items", () => { + // The detectors turned on deliberately broken visuals. + const ignoresChildren = renderToStaticMarkup(createElement("div", null, "no slots here")); + expect(slotViolations(ignoresChildren, { children: ["a", "b"] })).toEqual([ + "child 'a' built 0x", + "child 'b' built 0x", + ]); + const duplicatesChild = renderToStaticMarkup(createElement("div", null, "[child:a]", "[child:a]")); + expect(slotViolations(duplicatesChild, { child: "a" })).toEqual(["child 'a' built 2x"]); + const dropsItems = renderToStaticMarkup(createElement("dl", null, createElement("dt", null, "Guests"))); + expect(repeatViolations(dropsItems, { items: [{ label: "Guests", value: "2" }] })).toEqual(["item value '2'"]); + }); +}); + +describe("table row preservation", () => { + it("keeps one body row per emitted data row, with every cell", () => { + const lost: string[] = []; + for (const instance of renderable()) { + if (instance.component.component !== "Table") continue; + const html = render("Table", bindProps(instance.component)); + for (const v of tableRowViolations(html, instance.component)) lost.push(`${label(instance)} ${v}`); + } + expect(lost).toEqual([]); + }); + + it("preserves rows, cells and statuses for a multi-row table", () => { + const violations = tableRowViolations(render("Table", TABLE_FIXTURE), TABLE_FIXTURE); + expect(violations).toEqual([]); + }); + + it("rejects a visual that drops rows", () => { + // Same detector, same fixture, one row deliberately dropped. + const truncated = render("Table", { ...TABLE_FIXTURE, data: TABLE_FIXTURE.data.slice(0, -1) }); + expect(tableRowViolations(truncated, TABLE_FIXTURE)).toEqual([ + "rendered 2 body rows for 3 emitted", + "dropped cell 'T-1043'", + "dropped cell 'Fabrikam'", + "dropped status 'closed'", + ]); + }); +}); + +describe("blank-content detection", () => { + it("never renders an empty component for an instance that carries content", () => { + const blank: string[] = []; + for (const instance of renderable()) { + const html = render(instance.component.component, bindProps(instance.component)); + for (const v of contentViolations(html, instance.component)) blank.push(`${label(instance)} ${v}`); + } + expect(blank).toEqual([]); + }); + + it("fails a visual that renders nothing, rather than passing it silently", () => { + // Without this the detector could be vacuous: "renders nothing" and + // "renders correctly" would be the same green tick. + const nothing = renderToStaticMarkup(createElement(() => null)); + expect(contentViolations(nothing, { label: "Delete project" })).toEqual(["rendered nothing"]); + const real = render("Badge", { label: "open", variant: "info" }); + expect(contentViolations(real, { label: "open" })).toEqual([]); + expect(visibleText(real)).toBe("open"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a54249f..f7d6ec0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,9 +352,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - react: - specifier: '>=19.0.0' - version: 19.2.7 tailwind-merge: specifier: ^3.0.2 version: 3.6.0 @@ -365,6 +362,15 @@ importers: '@types/react': specifier: ^19.0.0 version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) tailwindcss: specifier: ^4.1.0 version: 4.3.2 From 768d8de8f15750ad6a365826bdcea2b3032fb530 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 5 Aug 2026 18:01:28 -0400 Subject: [PATCH 2/2] fix(shadcn): render every prop the emitter actually emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six renderers were dropping or flattening contract vocabulary, so a schema-valid emitted surface drew wrong under this design system — the one failure mode A3 validation and screenshots both wave through. Measured against every distinct emitted A2UI instance in the repo (172: 31 from the contracts build, 141 from the recorded fixtures). Ignored props — the renderer never read them (11 emitted instances): Table.density (8), Table.dividers (1), Table.isStriped (1) — a compact, striped, grid-divided ticket table drew as a default one; TextField.size (1); SelectableCard.variant (2) — an option emitted `blue` drew identical to `default`. Collapsed vocabulary — read, then flattened onto one treatment: Badge's fourteen variants onto four, so `success` was pixel-identical to `info` in five emitted instances; Card's and SelectableCard's color variants onto the token background. Wrong catalog defaults — A2UI's GenericBinder resolves raw properties and never runs the schema's .default(), so an omitted prop arrives as undefined and the renderer's fallback is the contract's last defense: AlertDialog defaulted `actionVariant` to destructive where the catalog says primary, dressing every ungoverned confirmation as dangerous; Badge defaulted to the filled primary treatment where the catalog says neutral. The projections stay native to shadcn — spacing/border utilities for density and dividers, the input scale for size, and shadcn's documented "badge or card with an explicit color class" idiom for the color vocabulary — and every catalog value keeps a treatment of its own. The emitter, the catalogs and the dspack contracts are untouched: the emitted contract is the source of truth and the renderer moved to it. emitted-prop-parity.test.tsx: 4 failed / 13 before, 13 passed after. Package suite 3 -> 16 tests; repo suite 92 -> 105, all green. Co-Authored-By: Claude Opus 5 --- .../src/components/AlertDialogRender.tsx | 9 ++- .../src/components/BadgeRender.tsx | 69 +++++++++---------- .../src/components/CardRender.tsx | 18 +++-- .../src/components/SelectableCardRender.tsx | 14 +++- .../src/components/TableRender.tsx | 65 ++++++++++++++--- .../src/components/TextFieldRender.tsx | 17 ++++- packages/shadcn-renderers/src/styles.css | 1 + .../shadcn-renderers/src/surface-variants.ts | 35 ++++++++++ 8 files changed, 169 insertions(+), 59 deletions(-) create mode 100644 packages/shadcn-renderers/src/surface-variants.ts diff --git a/packages/shadcn-renderers/src/components/AlertDialogRender.tsx b/packages/shadcn-renderers/src/components/AlertDialogRender.tsx index aca058c..a6fcfbb 100644 --- a/packages/shadcn-renderers/src/components/AlertDialogRender.tsx +++ b/packages/shadcn-renderers/src/components/AlertDialogRender.tsx @@ -21,7 +21,12 @@ const actionVariants = cva( destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", }, }, - defaultVariants: { variant: "destructive" }, + // The catalog's declared default for `actionVariant` is `primary`, and + // A2UI's binder never applies the schema default — an omitted variant + // arrives as undefined. Defaulting to `destructive` here dressed every + // ungoverned confirmation as dangerous, which is the opposite failure to + // a destructive action that renders as an ordinary button. + defaultVariants: { variant: "primary" }, }, ); @@ -49,7 +54,7 @@ export const AlertDialogRender: FC = ({ props }) => { )}