Skip to content

fix(shadcn): close renderer drift against the emitted A2UI contract, with props-level parity tests - #47

Merged
ryandmonk merged 3 commits into
mainfrom
fix/renderer-parity
Aug 5, 2026
Merged

fix(shadcn): close renderer drift against the emitted A2UI contract, with props-level parity tests#47
ryandmonk merged 3 commits into
mainfrom
fix/renderer-parity

Conversation

@ryandmonk

Copy link
Copy Markdown
Contributor

Six shadcn renderers were dropping or flattening contract vocabulary. A schema-valid emitted surface that draws wrong is failed representation evidence, and it is exactly the failure A3 and screenshots both wave through: A3 validates the message against the catalog, so it passes a renderer that ignores every prop it is handed, and a screenshot shows pixels without saying which emitted prop produced them. This fixes the drift and adds a props-level guard.

Measured, not assumed

Corpus: every distinct A2UI instance dspack-emit has actually produced in this repo — 172 instances, folded last-write-wins per (file, surfaceId, component id):

  • 31 from packages/contracts/out/*.surface.json (the contracts build's emitSurface output)
  • 141 from packages/replay/fixtures/*.json (recorded runs; the A2UI operations ride inside AG-UI TOOL_CALL_RESULT payloads)

Method: render each instance under the shadcn registry, then perturb one emitted prop at a time to another legal catalog value. If the markup does not change, the renderer is provably ignoring that prop. Then render every legal value of each emitted enum prop and look for values that collapse onto identical output, and render each prop omitted and compare against its catalog default.

The briefed figure was "7 of 22"; I measured different numbers and am reporting mine. 22 is close to the 23 distinct (component, prop-set) shapes in the corpus, of which 6 drift — but at instance granularity it is 11 of 172 instances with an outright ignored prop, rising to 16 of 172 once Badge's success/info collapse is counted. Six renderers drift, which does match.

Drift inventory

Renderer What drifted How
TableRender density (8 instances), dividers (1), isStriped (1) never read — a compact, striped, grid-divided ticket table drew as a default one
TextFieldRender size (1) never read
SelectableCardRender variant (2) never read — an option emitted blue drew identical to default
BadgeRender variant 14 catalog values flattened onto 4 treatments: success pixel-identical to info (5 emitted instances), the 9 colors all one outline; and the fallback for an omitted variant was the filled primary treatment where the catalog default is neutral
CardRender variant 12 catalog values onto 2 — every color variant identical to default
AlertDialogRender actionVariant defaulted to destructive where the catalog default is primary, dressing every ungoverned confirmation as dangerous

The default-fidelity class is live, not theoretical: A2UI's GenericBinder resolves raw component 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 catalog default.

Fixes 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 emitted catalogs and the dspack contracts are untouched: the emitted contract is the source of truth and the renderer moved to it. Independent of any dspack-emit branch.

Fail-first evidence

Tests landed first, against the pre-fix renderers (470b62b), then the fix (768d8de). Reproduce with git checkout 470b62b && pnpm --filter @dspack-studio/shadcn-renderers test:

 ❯ src/emitted-prop-parity.test.tsx (13 tests | 4 failed) 102ms
   × emitted-prop vs consumed-prop parity > every prop the emitter emits changes what this design system renders
   × variant fidelity > keeps every legal value of an emitted enum prop distinguishable
   × variant fidelity > renders a destructive action destructively, and a non-destructive one not
   × catalog default fidelity > renders an omitted prop as the catalog's declared default

 FAIL > every prop the emitter emits changes what this design system renders
AssertionError: expected [ …(13) ] to deeply equal []
+   "Table.density ignored at recipe-creator.surface.json:structured_editing#ingredients (Table)",
+   "Table.density ignored at recipe-creator.surface.json:structured_editing#instructions (Table)",
+   "TextField.size ignored at fixture-004.json:scheduling#name_input (TextField)",
+   "Table.density ignored at fixture-006.json:structured_editing#ingredients_table (Table)",
+   "Table.density ignored at fixture-006.json:structured_editing#instructions_table (Table)",
+   "Table.density ignored at fixture-007.json:structured_editing#ingredients (Table)",
+   "Table.density ignored at fixture-007.json:structured_editing#instructions (Table)",
+   "Table.density ignored at fixture-010.json:record_collection#tickets (Table)",
+   "Table.density ignored at fixture-011.json:record_collection#tickets_table (Table)",
+   "Table.dividers ignored at fixture-011.json:record_collection#tickets_table (Table)",
+   "Table.isStriped ignored at fixture-011.json:record_collection#tickets_table (Table)",
+   "SelectableCard.variant ignored at fixture-015.json:transactional_review#selectablecard_2 (SelectableCard)",
+   "SelectableCard.variant ignored at fixture-016.json:transactional_review#selectablecard_2 (SelectableCard)",

 FAIL > keeps every legal value of an emitted enum prop distinguishable
AssertionError: expected [ …(8) ] to deeply equal []
+   "Badge.variant: info = success render identically",
+   "Badge.variant: warning = blue = cyan = green = orange = pink = purple = teal = yellow render identically",
+   "Badge.variant: error = red render identically",
+   "Table.density: compact = balanced = spacious render identically",
+   "TextField.size: sm = md = lg render identically",
+   "Card.variant: default = blue = cyan = gray = green = orange = pink = purple = red = teal = yellow render identically",
+   "Table.dividers: rows = columns = grid = none render identically",
+   "SelectableCard.variant: default = transparent = muted = blue = cyan = gray = green = orange = pink = purple = red = teal = yellow render identically",

 FAIL > renders a destructive action destructively, and a non-destructive one not
AssertionError: expected '<span style="display:contents" data-a…' not to contain 'destructive'
Received: "…<button type="button" class="… bg-destructive text-destructive-foreground shadow-sm
hover:bg-destructive/90">Publish project</button>…"

 FAIL > renders an omitted prop as the catalog's declared default
AssertionError: expected [ …(2) ] to deeply equal []
+   "AlertDialog.actionVariant omitted does not render as the catalog default 'primary'",
+   "Badge.variant omitted does not render as the catalog default 'neutral'",

 Test Files  1 failed (1)
      Tests  4 failed | 9 passed (13)

After the fix, the same 13 pass unchanged.

What the parity tests now guard

packages/shadcn-renderers/src/emitted-prop-parity.test.tsx, driven by emitted-corpus.ts. Nothing is hand-listed — the corpus is read from real emitter output, so growing the scenario shelf grows the guard.

  • emitted-prop vs consumed-prop parity (the core check) — perturbing any emitted prop to another legal value must change the markup.
  • Variant fidelity — every legal value of an emitted enum prop must render distinguishably; plus the named failure mode explicitly, in both directions: a destructive action must carry the destructive treatment, and a non-destructive one (including the omitted-actionVariant case) must not.
  • Catalog default fidelity — an omitted prop must render as the catalog's declared default.
  • Slot and repeated-item parity — every emitted child id built exactly once; every items label/value and every column header present.
  • Table-row preservation — one body row per emitted data row, every cell and status intact.
  • Blank-content detection — every string an instance promises must appear; blank output for an instance carrying content fails.
  • Corpus guard — the corpus must stay non-trivial and cover every registered visual, so the suite cannot pass vacuously if a fixture directory moves.

The structural detectors are written as pure (markup, emitted) -> violations functions and are additionally turned on deliberately broken visuals (a visual that ignores its slot, one that duplicates a child, one that drops a row, one that renders nothing), so no guard is decoration.

Verification

before after
repo test suite (pnpm test) 12 files, 92 tests, all green 13 files, 105 tests, all green
@dspack-studio/shadcn-renderers 1 file, 3 tests 2 files, 16 tests
pnpm -r typecheck clean clean

Also confirmed the projections are real pixels, not just distinct class strings: rebuilt dist/shadcn.css and rendered all 14 badge variants, all 13 surface variants, the three densities, four divider styles, striping, the three field sizes and both alert-dialog action variants — every one visibly distinct, the omitted-variant badge grey, and the omitted-actionVariant dialog no longer red. (Screenshots are a sanity check on top of the props-level suite, never a substitute for it.)

Left unfixed, deliberately

  • Column.justify: start and stretch share a flex treatment. A2UI's stretch has no justify-content equivalent; the un-drifted Astryx reference maps it to start identically. No instance emits justify, so the distinguishability check does not reach it. Fixing it would mean inventing a treatment neither design system has.
  • Button.isIconOnly and the common accessibility prop are ignored by both registries. The Astryx renderer documents isIconOnly as waiting on an icon vocabulary in the catalog. Neither is emitted anywhere in the corpus.
  • Eight emitted Text instances carry neither text nor children (fixture-001#text_3, fixture-002#text_5/6/11, fixture-004#title/intro, fixture-005#title/intro). They render blank under both registries, so this is not renderer drift — it is upstream of the renderer, in generation or in the recorded fixtures. Flagged here rather than papered over with renderer-side placeholder text, which would fabricate content the surface never carried. The blank-content check skips instances that promise no content, so it neither fails on these nor pretends they are fine.

No packages published, nothing deployed, nothing merged.

🤖 Generated with Claude Code

ryandmonk and others added 2 commits August 5, 2026 17:56
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 22:02
@ryandmonk

Copy link
Copy Markdown
Contributor Author

CI note: the test job is red on a step that predates this branch and gates every later step (unit tests, typechecks and Playwright were all skipped as a result).

DRIFT    shadcn-ui.dspack.json  (the shadcn contract — copy of the spec repo's source of truth)
         differs from https://raw.githubusercontent.com/aestheticfunction/dspack/main/examples/shadcn-ui.dspack.json

This branch does not touch packages/contracts/ at all — git diff main...HEAD is packages/shadcn-renderers/** plus the lockfile, and packages/contracts/shadcn-ui.dspack.json is byte-identical to main. The check fetches the upstream copy at run time, so it fails on main too right now; upstream dspack main moved since main last ran green.

Resyncing it here would mean editing a contract copy, which is the one thing the upstream-first rule reserves for a dspack-side decision — so it is deliberately left alone rather than papered over inside a renderer PR. It needs its own change, either check:sync --write after confirming the upstream delta is intended, or a dspack PR carrying this copy upstream.

Verified locally in the meantime, at 768d8de:

pnpm test        13 test files, 105 tests, 0 failed   (was 12 files / 92 tests)
pnpm -r typecheck   12 projects, clean

The Playwright suites were not run locally, but nothing in e2e/ couples to what changed: design-swap.spec.ts asserts on [role=alertdialog], governed text, provenance attributes and the receipt hash, and no spec asserts on a Tailwind class.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are two confirmed correctness gaps (Table header divider behavior vs dividers, and parity test perturbation using unbound values) that could misrepresent contract semantics or miss drift.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR aligns the shadcn renderer implementations with the emitted A2UI contract vocabulary (fixing previously ignored/flattened props) and adds an emitted-prop parity test suite that uses real emitter output as a guard against future renderer drift.

Changes:

  • Add a corpus-driven “emitted prop vs consumed prop” parity test suite plus structural/content guards.
  • Project previously ignored props/variants into shadcn renderers (Table density/dividers/striping, TextField size, Card/SelectableCard/Badge variants, AlertDialog actionVariant default).
  • Introduce shared surface-variant styling and update Tailwind sources; add React/ReactDOM dev deps for server markup tests.
File summaries
File Description
pnpm-lock.yaml Updates lockfile entries for React/ReactDOM typings and packages.
packages/shadcn-renderers/package.json Adds devDependencies needed for SSR-markup testing (react/react-dom + types).
packages/shadcn-renderers/src/styles.css Adds Tailwind @source so class strings in surface-variants.ts are scanned.
packages/shadcn-renderers/src/surface-variants.ts Introduces a shared mapping from catalog surface variants to shadcn/Tailwind classes.
packages/shadcn-renderers/src/emitted-corpus.ts Adds test-only emitted-instance corpus loader backed by contracts output + replay fixtures.
packages/shadcn-renderers/src/emitted-prop-parity.test.tsx Adds corpus-driven parity and structural/content guard tests.
packages/shadcn-renderers/src/components/TableRender.tsx Implements density/dividers/striping handling in Table rendering.
packages/shadcn-renderers/src/components/TextFieldRender.tsx Implements size projection onto shadcn input scale.
packages/shadcn-renderers/src/components/SelectableCardRender.tsx Applies shared surface variant styling to SelectableCard.
packages/shadcn-renderers/src/components/CardRender.tsx Applies shared surface variant styling to Card variants.
packages/shadcn-renderers/src/components/BadgeRender.tsx Expands badge variant treatments to preserve full catalog distinction.
packages/shadcn-renderers/src/components/AlertDialogRender.tsx Fixes default/fallback for actionVariant to match catalog default.
Review details

Files not reviewed (1)

  • pnpm-lock.yaml: Generated file
  • Files reviewed: 11/12 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines 80 to +85
return (
<div className="w-full overflow-auto rounded-md border">
<table className="w-full caption-bottom text-sm">
{headers.length > 0 && (
<thead className="border-b">
<tr className={TR}>
<tr className="transition-colors">
Comment on lines +117 to +121
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) {
Comment on lines +22 to +27
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<string, any> = JSON.parse(
readFileSync(join(CONTRACTS_OUT, "catalog.v0_9_1.json"), "utf8"),
);
@ryandmonk
ryandmonk merged commit f8be44d into main Aug 5, 2026
1 check passed
@ryandmonk
ryandmonk deleted the fix/renderer-parity branch August 5, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants