diff --git a/apps/web/app/design-system.tsx b/apps/web/app/design-system.tsx index 26fcdad..badeb15 100644 --- a/apps/web/app/design-system.tsx +++ b/apps/web/app/design-system.tsx @@ -32,13 +32,13 @@ export const DESIGN_SYSTEMS: Record = { id: "astryx", label: "Astryx", registry: astryxRegistry, - note: "All 9 catalog components render through @astryxdesign/core, with 8 runtime themes.", + note: "All 12 catalog components render through @astryxdesign/core, with 8 runtime themes.", }, shadcn: { id: "shadcn", label: "shadcn/ui", registry: shadcnRegistry, - note: "8 of 9 catalog components render through vendored shadcn/ui visuals; Dialog shows the unimplemented placeholder (incremental adoption, stated plainly).", + note: "11 of 12 catalog components render through vendored shadcn/ui visuals; Dialog shows the unimplemented placeholder (incremental adoption, stated plainly).", }, }; diff --git a/docs/renderer-abstraction.md b/docs/renderer-abstraction.md index 8073cdb..e85204a 100644 --- a/docs/renderer-abstraction.md +++ b/docs/renderer-abstraction.md @@ -4,7 +4,7 @@ The studio's rendering stack is layered so the design system is a plug-in, not a foundation. This document states the boundaries as they exist in code today (verified by `packages/a2ui-ingest/src/registry-abstraction.test.ts`). Since the FM-10 groundwork the swap is real: `packages/shadcn-renderers` -supplies a second registry (8 of 9 catalog names; `Dialog` renders the +supplies a second registry (11 of 12 catalog names; `Dialog` renders the unimplemented placeholder by design), selected in the restyle view and applied to every canvas. The e2e proof (`e2e/design-swap.spec.ts`) replays one fixture under both design systems and asserts the receipt hash is diff --git a/e2e/design-swap.spec.ts b/e2e/design-swap.spec.ts index 7de73df..08b211d 100644 --- a/e2e/design-swap.spec.ts +++ b/e2e/design-swap.spec.ts @@ -30,7 +30,7 @@ test("honesty: the receipt hash is identical under both design systems; the pixe // Swap the design system in the restyle view (shell-level state). await page.getByTestId("view-canvas").click(); await page.getByTestId("design-system-shadcn").click(); - await expect(page.getByTestId("design-system-note")).toContainText("8 of 9"); + await expect(page.getByTestId("design-system-note")).toContainText("11 of 12"); // Back in replay, the same run renders through shadcn/ui. await page.getByTestId("view-replay").click(); diff --git a/packages/astryx-renderers/src/components/ListRender.tsx b/packages/astryx-renderers/src/components/ListRender.tsx new file mode 100644 index 0000000..5e8efb7 --- /dev/null +++ b/packages/astryx-renderers/src/components/ListRender.tsx @@ -0,0 +1,14 @@ +/** + * Catalog `List` -> Astryx List. The contract's List takes arbitrary + * children (for a comparison, SelectableCards); density and dividers carry + * verbatim. + */ +import type { FC } from "react"; +import { List } from "@astryxdesign/core/List"; +import { childIds } from "@dspack-studio/a2ui-ingest"; + +export const ListRender: FC = ({ props, buildChild }) => ( + + {childIds(props.children).map(buildChild)} + +); diff --git a/packages/astryx-renderers/src/components/MetadataListRender.tsx b/packages/astryx-renderers/src/components/MetadataListRender.tsx new file mode 100644 index 0000000..57b6737 --- /dev/null +++ b/packages/astryx-renderers/src/components/MetadataListRender.tsx @@ -0,0 +1,26 @@ +/** + * Catalog `MetadataList` -> Astryx MetadataList. The catalog carries items + * as data-props (the Table idiom — sub-components carry no props under + * grammar-constrained generation); this renderer synthesizes the upstream + * MetadataListItem children from the array. + */ +import type { FC } from "react"; +import { MetadataList, MetadataListItem } from "@astryxdesign/core/MetadataList"; + +interface Item { + label?: unknown; + value?: unknown; +} + +export const MetadataListRender: FC = ({ props }) => { + const items: Item[] = Array.isArray(props.items) ? props.items : []; + return ( + + {items.map((item, i) => ( + + {String(item.value ?? "")} + + ))} + + ); +}; diff --git a/packages/astryx-renderers/src/components/SelectableCardRender.tsx b/packages/astryx-renderers/src/components/SelectableCardRender.tsx new file mode 100644 index 0000000..06565cf --- /dev/null +++ b/packages/astryx-renderers/src/components/SelectableCardRender.tsx @@ -0,0 +1,26 @@ +/** + * Catalog `SelectableCard` -> Astryx SelectableCard. Presentational in + * replay: `isSelected` is delivered state (selection lives in the shared + * data model), so onChange is a no-op until the interaction overlay grounds + * a select action. + */ +import type { FC } from "react"; +import { SelectableCard } from "@astryxdesign/core/SelectableCard"; +import { childIds } from "@dspack-studio/a2ui-ingest"; + +const VARIANTS = new Set([ + "default", "transparent", "muted", + "blue", "cyan", "gray", "green", "orange", "pink", "purple", "red", "teal", "yellow", +]); + +export const SelectableCardRender: FC = ({ props, buildChild }) => ( + {}} + > + {childIds(props.children).map(buildChild)} + +); diff --git a/packages/astryx-renderers/src/registry.tsx b/packages/astryx-renderers/src/registry.tsx index 562ad85..107b1ad 100644 --- a/packages/astryx-renderers/src/registry.tsx +++ b/packages/astryx-renderers/src/registry.tsx @@ -16,6 +16,9 @@ import { TableRender } from "./components/TableRender"; import { AlertDialogRender } from "./components/AlertDialogRender"; import { DialogRender } from "./components/DialogRender"; import { ColumnRender } from "./components/ColumnRender"; +import { ListRender } from "./components/ListRender"; +import { SelectableCardRender } from "./components/SelectableCardRender"; +import { MetadataListRender } from "./components/MetadataListRender"; const renders = { Text: TextRender, @@ -27,6 +30,9 @@ const renders = { AlertDialog: AlertDialogRender, Dialog: DialogRender, Column: ColumnRender, + List: ListRender, + SelectableCard: SelectableCardRender, + MetadataList: MetadataListRender, }; export const astryxRegistry: Registry = { diff --git a/packages/contracts/astryx.dspack.json b/packages/contracts/astryx.dspack.json index 6679201..8694c66 100644 --- a/packages/contracts/astryx.dspack.json +++ b/packages/contracts/astryx.dspack.json @@ -378,6 +378,139 @@ "composition": { "notes": "Takes arbitrary children (ReactNode); surfaces express them as child nodes." } + }, + "list": { + "name": "List", + "description": "A vertical collection of items with consistent spacing, dividers, and optional markers.", + "whenToUse": "Use it to display ordered or unordered groups of related content — including groups of selectable options. Lists imply a meaningful collection.", + "whenNotToUse": "Do not use a list for a single item or for laying out unrelated content. Records with uniform fields belong in a table.", + "props": { + "density": { + "type": "enum", + "values": [ + "compact", + "balanced", + "spacious" + ], + "description": "Item density.", + "default": "balanced" + }, + "hasDividers": { + "type": "boolean", + "description": "Renders dividers between items." + } + }, + "categories": [ + "table-list", + "container" + ], + "x-source": "https://github.com/facebook/astryx/blob/v0.1.4/packages/core/src/List/List.doc.mjs", + "composition": { + "notes": "Takes arbitrary children (ReactNode); surfaces express items as child nodes — for a comparison, selectable-card children." + } + }, + "selectable-card": { + "name": "SelectableCard", + "description": "A card that toggles between selected and unselected states with an accent border.", + "whenToUse": "Use for plan pickers, filter chips, or option grids — one named option among alternatives, with its selected state visible.", + "whenNotToUse": "Do not use for navigation; use ClickableCard for that (not in this contract).", + "props": { + "label": { + "type": "string", + "description": "The option's name. Every option is named.", + "required": true + }, + "isSelected": { + "type": "boolean", + "description": "Whether this option is currently selected. Presentational: selection state lives in the shared data model and re-delivers this prop." + }, + "isDisabled": { + "type": "boolean", + "description": "Whether the option can be chosen." + }, + "variant": { + "type": "enum", + "values": [ + "default", + "transparent", + "muted", + "blue", + "cyan", + "gray", + "green", + "orange", + "pink", + "purple", + "red", + "teal", + "yellow" + ], + "description": "Card background variant, verbatim from SelectableCard's union; color variants categorize rather than signal status.", + "default": "default" + } + }, + "categories": [ + "container", + "data-input" + ], + "x-source": "https://github.com/facebook/astryx/blob/v0.1.4/packages/core/src/SelectableCard/SelectableCard.doc.mjs", + "composition": { + "notes": "Takes arbitrary children (ReactNode); an option's content — attributes, badges, actions — are child nodes." + } + }, + "metadata-list": { + "name": "MetadataList", + "description": "Key-value pairs for object attributes, in a structured layout. DATA-DRIVEN: items are an array prop, the same Astryx data-props idiom as Table.", + "whenToUse": "MetadataList displays key-value pairs for object attributes like quality, condition, and status. Use it for detail panels, settings summaries, and record information — the attributes that make options comparable.", + "whenNotToUse": "Do not use for extensive form input (use a form layout) or for data without a clear key-value structure.", + "props": { + "items": { + "type": "array", + "description": "Attribute records, each { label, value } — e.g. { \"label\": \"Storage\", \"value\": \"1 TB\" }.", + "required": true, + "x-drift": "upstream MetadataList is children-based (MetadataListItem); the contract carries items as data-props because sub-components carry no props under grammar-constrained generation", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + }, + "columns": { + "type": "enum", + "values": [ + "single", + "multi" + ], + "description": "Column layout for the attribute grid.", + "default": "single" + }, + "orientation": { + "type": "enum", + "values": [ + "vertical", + "horizontal" + ], + "description": "Label/value flow direction.", + "default": "vertical" + } + }, + "categories": [ + "content" + ], + "x-source": "https://github.com/facebook/astryx/blob/v0.1.4/packages/core/src/MetadataList/MetadataList.doc.mjs", + "composition": { + "notes": "Upstream MetadataList takes MetadataListItem children; the contract carries items as data-props because sub-components carry no props under grammar-constrained generation — the renderer synthesizes the item children." + } } }, "categories": { @@ -575,7 +708,7 @@ "require": [ "table" ], - "rationale": "\"Table displays structured data in rows and columns with consistent dimensionality\"; \"Use Table for data sets with uniform structure; for simpler or inconsistent data, consider a list or card layout instead\". A record collection is uniform by definition — same fields on every record — so it renders as a table, not as prose or ad-hoc stacks. (Valid while table is this catalog's only collection structure; widen to a choice set if a list component lands.) — Astryx Table docs, v0.1.4", + "rationale": "\"Table displays structured data in rows and columns with consistent dimensionality\"; \"Use Table for data sets with uniform structure; for simpler or inconsistent data, consider a list or card layout instead\". A record collection is uniform by definition — same fields on every record — so it renders as a table, not as prose or ad-hoc stacks. (The catalog's list component serves heterogeneous and selectable content, per the same docs; uniform records still choose tables. A table-or-list choice set is inexpressible in current rule types — component-choice require is conjunctive — noted as candidate dspack-gen work if a scenario ever needs it.) — Astryx Table docs, v0.1.4", "examples": [ "ex.support-ticket-triage" ], diff --git a/packages/contracts/src/astryx-profile.ts b/packages/contracts/src/astryx-profile.ts index 03359e6..16463aa 100644 --- a/packages/contracts/src/astryx-profile.ts +++ b/packages/contracts/src/astryx-profile.ts @@ -394,6 +394,119 @@ export const astryxProfile: Profile = { required: [], surfacePlan: { textProp: "text", childrenProp: "children" }, }, + + { + a2ui: "List", + dspackId: "list", + commons: ["ComponentCommon"], + structural: { + children: { + schema: { $ref: "#/$defs/ChildList" }, + description: "Child component IDs rendered as the list's items, in order.", + synthNote: + "The contract's List takes arbitrary children (upstream List accepts ReactNode); " + + "projected as an ordered child list. For a comparison, the children are " + + "SelectableCards.", + }, + }, + propMap: { + density: { + a2ui: "density", + kind: "enum", + targetEnum: ["compact", "balanced", "spacious"], + default: "balanced", + description: "Item density, carried verbatim.", + }, + hasDividers: { + a2ui: "hasDividers", + kind: "boolean", + description: "Renders dividers between items.", + }, + }, + required: ["children"], + surfacePlan: { childrenProp: "children" }, + }, + + { + a2ui: "SelectableCard", + dspackId: "selectable-card", + commons: ["ComponentCommon"], + structural: { + children: { + schema: { $ref: "#/$defs/ChildList" }, + description: "IDs of the option's content components: attributes, badges, actions.", + synthNote: + "The contract's SelectableCard takes arbitrary children (ReactNode); projected " + + "as a child list.", + }, + }, + propMap: { + label: { + a2ui: "label", + kind: "string", + description: "The option's name, carried verbatim.", + }, + isSelected: { + a2ui: "isSelected", + kind: "boolean", + description: + "Selected state, carried verbatim. Presentational: selection lives in the shared " + + "data model and re-delivers this prop; the card itself carries no action " + + "(upstream onChange is the interaction overlay's territory, out of contract scope).", + }, + isDisabled: { + a2ui: "isDisabled", + kind: "boolean", + description: "Whether the option can be chosen.", + }, + variant: { + a2ui: "variant", + kind: "enum", + targetEnum: [ + "default", "transparent", "muted", + "blue", "cyan", "gray", "green", "orange", "pink", "purple", "red", "teal", "yellow", + ], + default: "default", + description: "Card background variant, carried verbatim from SelectableCard's union.", + }, + }, + required: ["label"], + surfacePlan: { childrenProp: "children" }, + }, + + { + a2ui: "MetadataList", + dspackId: "metadata-list", + commons: ["ComponentCommon"], + structural: { + items: { + schema: { type: "array", items: { type: "object" } }, + description: "Attribute records, each { label, value }.", + synthNote: + "The contract carries items as data-props (the Table idiom) because " + + "sub-components carry no props under grammar-constrained generation; the " + + "renderer synthesizes upstream MetadataListItem children from this array.", + }, + }, + propMap: { + columns: { + a2ui: "columns", + kind: "enum", + targetEnum: ["single", "multi"], + default: "single", + description: "Column layout for the attribute grid, carried verbatim.", + }, + orientation: { + a2ui: "orientation", + kind: "enum", + targetEnum: ["vertical", "horizontal"], + default: "vertical", + description: "Label/value flow direction, carried verbatim.", + }, + }, + required: ["items"], + surfacePlan: { structuralPassthrough: ["items"] }, + }, ], synthesized: [ diff --git a/packages/contracts/src/drift-check.ts b/packages/contracts/src/drift-check.ts index f4c99d9..ab08c63 100644 --- a/packages/contracts/src/drift-check.ts +++ b/packages/contracts/src/drift-check.ts @@ -33,6 +33,9 @@ const ASTRYX_NAME: Record = { "dropdown-menu": "DropdownMenu", table: "Table", text: "Text", + list: "List", + "selectable-card": "SelectableCard", + "metadata-list": "MetadataList", }; interface AstryxProp { @@ -73,6 +76,13 @@ for (const [id, comp] of Object.entries(doc.components ?? {})) { for (const [propName, propDef] of Object.entries(comp.props ?? {})) { const ap = byName.get(propName); if (!ap) { + // A contract prop with an `x-drift` note is an acknowledged, documented + // divergence from the upstream API (e.g. the data-props idiom standing + // in for children-based composition). Reported, never a finding. + if (typeof propDef?.["x-drift"] === "string") { + unverified.push(`${id}.${propName}: acknowledged divergence — ${propDef["x-drift"]}`); + continue; + } findings.push(`${id}.${propName}: prop not found on Astryx ${astryxName}`); continue; } diff --git a/packages/shadcn-renderers/src/components/ListRender.tsx b/packages/shadcn-renderers/src/components/ListRender.tsx new file mode 100644 index 0000000..2151802 --- /dev/null +++ b/packages/shadcn-renderers/src/components/ListRender.tsx @@ -0,0 +1,24 @@ +/** + * Catalog `List` -> shadcn/ui-style list markup: a vertical stack with + * token-colored dividers. Density projects onto spacing steps; treatment + * native to this design system. + */ +import type { FC } from "react"; +import { childIds } from "@dspack-studio/a2ui-ingest"; +import { cn } from "../cn"; + +const GAP: Record = { + compact: "gap-1", + balanced: "gap-2", + spacious: "gap-4", +}; + +export const ListRender: FC = ({ props, buildChild }) => ( +
*]:pt-2 first:[&>*]:pt-0")}> + {childIds(props.children).map((id: string) => ( +
+ {buildChild(id)} +
+ ))} +
+); diff --git a/packages/shadcn-renderers/src/components/MetadataListRender.tsx b/packages/shadcn-renderers/src/components/MetadataListRender.tsx new file mode 100644 index 0000000..1788545 --- /dev/null +++ b/packages/shadcn-renderers/src/components/MetadataListRender.tsx @@ -0,0 +1,27 @@ +/** + * Catalog `MetadataList` -> shadcn/ui-style definition list. The catalog + * carries items as data-props ({ label, value } records); this renders them + * as a token-colored dl grid — muted labels, plain values. + */ +import type { FC } from "react"; +import { cn } from "../cn"; + +interface Item { + label?: unknown; + value?: unknown; +} + +export const MetadataListRender: FC = ({ props }) => { + const items: Item[] = Array.isArray(props.items) ? props.items : []; + const horizontal = props.orientation === "horizontal"; + return ( +
+ {items.map((item, i) => ( +
+
{String(item.label ?? "")}
+
{String(item.value ?? "")}
+
+ ))} +
+ ); +}; diff --git a/packages/shadcn-renderers/src/components/SelectableCardRender.tsx b/packages/shadcn-renderers/src/components/SelectableCardRender.tsx new file mode 100644 index 0000000..e431520 --- /dev/null +++ b/packages/shadcn-renderers/src/components/SelectableCardRender.tsx @@ -0,0 +1,32 @@ +/** + * Catalog `SelectableCard` -> shadcn/ui-style selectable card: a bordered + * card whose selected state renders as the primary accent ring (shadcn has + * no SelectableCard primitive; this is the idiomatic ring treatment). + * Presentational in replay — selection state is delivered, not clicked. + */ +import type { FC } from "react"; +import { childIds } from "@dspack-studio/a2ui-ingest"; +import { cn } from "../cn"; + +export const SelectableCardRender: FC = ({ props, buildChild }) => { + const selected = Boolean(props.isSelected); + const ids = childIds(props.children); + return ( +
+
+ {String(props.label ?? "")} + {selected && selected} +
+
{ids.map(buildChild)}
+
+ ); +}; diff --git a/packages/shadcn-renderers/src/registry-parity.test.ts b/packages/shadcn-renderers/src/registry-parity.test.ts index b4733cf..a4395c7 100644 --- a/packages/shadcn-renderers/src/registry-parity.test.ts +++ b/packages/shadcn-renderers/src/registry-parity.test.ts @@ -22,7 +22,7 @@ describe("shadcn registry parity (catalog owns vocabulary; registry owns pixels) expect([...plan.reuseBasic, ...plan.custom, ...plan.unimplemented].sort()).toEqual([...names].sort()); expect(plan.unimplemented).toEqual(["Dialog"]); expect(plan.custom.sort()).toEqual( - ["AlertDialog", "Badge", "Button", "Card", "Column", "Table", "Text", "TextField"].sort(), + ["AlertDialog", "Badge", "Button", "Card", "Column", "List", "MetadataList", "SelectableCard", "Table", "Text", "TextField"].sort(), ); }); diff --git a/packages/shadcn-renderers/src/registry.tsx b/packages/shadcn-renderers/src/registry.tsx index faf2b9b..29d992e 100644 --- a/packages/shadcn-renderers/src/registry.tsx +++ b/packages/shadcn-renderers/src/registry.tsx @@ -3,7 +3,7 @@ * each wrapped with the same data-a2ui-id provenance tagging as the Astryx * registry, so X-ray works identically under either design system. * - * Coverage is deliberately 8 of the catalog's 9 names: `Dialog` has no + * Coverage is deliberately 11 of the catalog's 12 names: `Dialog` has no * visual here and renders the a2ui-ingest `unimplemented` placeholder — * the incremental-adoption mechanism docs/renderer-abstraction.md names as * the intended migration path, exercised in production rather than claimed. @@ -18,6 +18,9 @@ import { BadgeRender } from "./components/BadgeRender"; import { TableRender } from "./components/TableRender"; import { AlertDialogRender } from "./components/AlertDialogRender"; import { ColumnRender } from "./components/ColumnRender"; +import { ListRender } from "./components/ListRender"; +import { SelectableCardRender } from "./components/SelectableCardRender"; +import { MetadataListRender } from "./components/MetadataListRender"; const renders = { Text: TextRender, @@ -28,6 +31,9 @@ const renders = { Table: TableRender, AlertDialog: AlertDialogRender, Column: ColumnRender, + List: ListRender, + SelectableCard: SelectableCardRender, + MetadataList: MetadataListRender, // Dialog: deliberately absent — renders the visible unimplemented // placeholder (legal vocabulary, missing pixels; the run is unaffected). };