Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/shadcn-renderers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
);

Expand Down Expand Up @@ -49,7 +54,7 @@ export const AlertDialogRender: FC<any> = ({ props }) => {
)}
<button
type="button"
className={cn(actionVariants({ variant: (props.actionVariant as any) ?? "destructive" }))}
className={cn(actionVariants({ variant: (props.actionVariant as any) ?? "primary" }))}
onClick={() => props.action?.()}
>
{String(props.actionLabel ?? "")}
Expand Down
69 changes: 34 additions & 35 deletions packages/shadcn-renderers/src/components/BadgeRender.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,46 @@
/**
* Catalog `Badge` -> shadcn/ui Badge. The catalog's fourteen Astryx variants
* project onto shadcn's four treatments by meaning: error/red -> destructive,
* neutral/gray tones -> secondary, everything colorful -> outline with the
* label verbatim (shadcn badges are token-colored, not rainbow-colored).
* Catalog `Badge` -> shadcn/ui Badge.
*
* The catalog carries fourteen variants: five status meanings plus nine
* categorical colors. shadcn ships four badge treatments, so the projection is
* shadcn's four canonical variants for the meanings that map onto them, plus
* shadcn's documented "badge with an explicit color class" idiom for the rest.
* Every catalog value keeps a treatment of its own: collapsing `success` onto
* `info`, or the nine colors onto one outline, would accept the vocabulary and
* then discard the distinction the surface was emitted to carry.
*
* Alpha tints (`/15`, `/40`) rather than fixed 50/950 steps: this package's
* theme switches on `[data-mode="dark"]`, not Tailwind's `dark:` variant, and
* a tint reads correctly under both modes from one rule.
*/
import type { FC } from "react";
import { cva } from "class-variance-authority";
import { cn } from "../cn";

const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors w-fit",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
const BASE =
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors w-fit";

const VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
neutral: "secondary",
info: "default",
success: "default",
warning: "outline",
error: "destructive",
red: "destructive",
blue: "outline",
cyan: "outline",
green: "outline",
orange: "outline",
pink: "outline",
purple: "outline",
teal: "outline",
yellow: "outline",
/** Catalog variant -> shadcn treatment. Catalog default: neutral. */
const VARIANT: Record<string, string> = {
// Status meanings: shadcn's own filled treatments.
neutral: "border-transparent bg-secondary text-secondary-foreground",
info: "border-transparent bg-primary text-primary-foreground shadow",
success: "border-transparent bg-emerald-600 text-white shadow",
warning: "border-transparent bg-amber-500 text-zinc-950 shadow",
error: "border-transparent bg-destructive text-destructive-foreground shadow",
// Categorical colors: outline treatment, tinted per color.
blue: "border-blue-500/40 bg-blue-500/15 text-blue-700",
cyan: "border-cyan-500/40 bg-cyan-500/15 text-cyan-700",
green: "border-green-500/40 bg-green-500/15 text-green-700",
orange: "border-orange-500/40 bg-orange-500/15 text-orange-700",
pink: "border-pink-500/40 bg-pink-500/15 text-pink-700",
purple: "border-purple-500/40 bg-purple-500/15 text-purple-700",
red: "border-red-500/40 bg-red-500/15 text-red-700",
teal: "border-teal-500/40 bg-teal-500/15 text-teal-700",
yellow: "border-yellow-500/40 bg-yellow-500/15 text-yellow-700",
};

export const BadgeRender: FC<any> = ({ props }) => (
<span className={cn(badgeVariants({ variant: VARIANT[props.variant as string] ?? "default" }))}>
<span className={cn(BASE, VARIANT[props.variant as string] ?? VARIANT.neutral)}>
{String(props.label ?? "")}
</span>
);
18 changes: 12 additions & 6 deletions packages/shadcn-renderers/src/components/CardRender.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/**
* Catalog `Card` -> shadcn/ui Card markup. The catalog's Astryx color
* variants have no shadcn equivalent (shadcn cards are token-neutral); the
* projection keeps `muted` (secondary background) and renders every color
* variant on the token background — vocabulary accepted, treatment native
* to this design system.
* Catalog `Card` -> shadcn/ui Card markup. shadcn cards are token-neutral, so
* the catalog's color variants are projected onto shadcn's accented-surface
* idiom (token card plus a Tailwind color tint) in `surface-variants.ts` —
* every catalog value keeps its own treatment rather than collapsing onto the
* default background.
*/
import type { FC } from "react";
import { cn } from "../cn";
import { surfaceVariant } from "../surface-variants";

export const CardRender: FC<any> = ({ props, buildChild }) => (
<div className={cn("rounded-xl border bg-card text-card-foreground shadow p-6", props.variant === "muted" && "bg-secondary")}>
<div
className={cn(
"rounded-xl border text-card-foreground shadow p-6",
surfaceVariant(props.variant),
)}
>
{props.child ? buildChild(props.child) : null}
</div>
);
14 changes: 11 additions & 3 deletions packages/shadcn-renderers/src/components/SelectableCardRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
* 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.
*
* `variant` carries the same surface vocabulary as Card and is projected
* through the shared table, so an emitted `blue` option reads as blue here
* exactly as it does under Astryx.
*/
import type { FC } from "react";
import { childIds } from "@dspack-studio/a2ui-ingest";
import { cn } from "../cn";
import { surfaceVariant } from "../surface-variants";

export const SelectableCardRender: FC<any> = ({ props, buildChild }) => {
const selected = Boolean(props.isSelected);
Expand All @@ -17,16 +22,19 @@ export const SelectableCardRender: FC<any> = ({ props, buildChild }) => {
aria-selected={selected}
aria-disabled={Boolean(props.isDisabled) || undefined}
className={cn(
"rounded-xl border bg-card text-card-foreground p-4 shadow-sm",
selected ? "border-primary ring-2 ring-primary" : "border-border",
"rounded-xl border border-border text-card-foreground p-4 shadow-sm",
// The variant's tint (including its border color) lands before the
// selection ring, so selection always wins the border.
surfaceVariant(props.variant),
selected && "border-primary ring-2 ring-primary",
props.isDisabled && "opacity-50",
)}
>
<div className="font-semibold mb-2 flex items-center justify-between">
{String(props.label ?? "")}
{selected && <span className="text-primary text-xs font-medium">selected</span>}
</div>
<div className="flex flex-col gap-2">{ids.map(buildChild)}</div>
<div className="flex flex-col gap-2">{ids.map((id) => buildChild(id))}</div>
</div>
);
};
65 changes: 54 additions & 11 deletions packages/shadcn-renderers/src/components/TableRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
* (data-driven columns/data and nested children) render, mirroring the
* Astryx renderer's chunking of flat children into rows of one cell per
* column; rows with a status get a trailing Badge cell.
*
* The presentation props the contract carries — `density`, `dividers`,
* `isStriped` — are projected onto shadcn's table utilities rather than
* dropped: a table emitted as compact-and-striped must read as compact and
* striped here, or the emitted surface is being misrepresented.
*/
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import { childIds } from "@dspack-studio/a2ui-ingest";
import { BadgeRender } from "./BadgeRender";
import { cn } from "../cn";
Expand All @@ -14,15 +19,45 @@ interface Row {
status?: { label?: string; variant?: string };
}

const TH = "h-10 px-2 text-left align-middle text-sm font-medium text-muted-foreground";
const TD = "p-2 align-middle text-sm";
const TR = "border-b transition-colors hover:bg-muted/50";
/** Row density -> shadcn's header/cell spacing steps. Catalog default: balanced. */
const DENSITY: Record<string, { head: string; cell: string }> = {
compact: { head: "h-8 px-2 py-0.5", cell: "px-2 py-0.5" },
balanced: { head: "h-10 px-2 py-2", cell: "px-2 py-2" },
spacious: { head: "h-12 px-4 py-4", cell: "px-4 py-4" },
};

/**
* Divider style -> the borders shadcn's table draws. Catalog default: rows.
* `rows` divides horizontally, `columns` vertically, `grid` both, `none`
* neither — four values, four distinct treatments.
*/
const ROW_DIVIDER: Record<string, string> = {
rows: "border-b",
columns: "",
grid: "border-b",
none: "",
};
const CELL_DIVIDER: Record<string, string> = {
rows: "",
columns: "border-r last:border-r-0",
grid: "border-r last:border-r-0",
none: "",
};

const HEAD = "text-left align-middle text-sm font-medium text-muted-foreground";
const CELL = "align-middle text-sm";

export const TableRender: FC<any> = ({ props, buildChild }) => {
const headers: string[] = Array.isArray(props.columns) ? props.columns.map(String) : [];
const nested = childIds(props.children);

let bodyRows: Array<Array<React.ReactNode>>;
const density = DENSITY[props.density as string] ?? DENSITY.balanced;
const dividers = (props.dividers as string) in ROW_DIVIDER ? (props.dividers as string) : "rows";
const rowDivider = ROW_DIVIDER[dividers];
const cellDivider = CELL_DIVIDER[dividers];
const striped = Boolean(props.isStriped);

let bodyRows: Array<Array<ReactNode>>;
let anyStatus = false;
if (nested.length > 0) {
const width = Math.max(headers.length, 1);
Expand All @@ -34,7 +69,7 @@ export const TableRender: FC<any> = ({ props, buildChild }) => {
const rows: Row[] = Array.isArray(props.data) ? props.data : [];
anyStatus = rows.some((r) => r.status);
bodyRows = rows.map((r) => {
const cells: React.ReactNode[] = (r.cells ?? []).map((c) => String(c));
const cells: ReactNode[] = (r.cells ?? []).map((c) => String(c));
if (anyStatus) {
cells.push(r.status ? <BadgeRender props={{ label: r.status.label, variant: r.status.variant }} /> : null);
}
Expand All @@ -47,19 +82,27 @@ export const TableRender: FC<any> = ({ props, buildChild }) => {
<table className="w-full caption-bottom text-sm">
{headers.length > 0 && (
<thead className="border-b">
<tr className={TR}>
<tr className="transition-colors">
{headers.map((h, i) => (
<th key={i} scope="col" className={TH}>{h}</th>
<th key={i} scope="col" className={cn(HEAD, density.head, cellDivider)}>{h}</th>
))}
{anyStatus && <th scope="col" className={TH}>Status</th>}
{anyStatus && <th scope="col" className={cn(HEAD, density.head, cellDivider)}>Status</th>}
</tr>
</thead>
)}
<tbody>
{bodyRows.map((row, ri) => (
<tr key={ri} className={cn(TR, "last:border-0")}>
<tr
key={ri}
className={cn(
"transition-colors hover:bg-muted/50",
rowDivider,
"last:border-b-0",
striped && "odd:bg-muted/40",
)}
>
{row.map((cell, ci) => (
<td key={ci} className={TD}>{cell}</td>
<td key={ci} className={cn(CELL, density.cell, cellDivider)}>{cell}</td>
))}
</tr>
))}
Expand Down
17 changes: 15 additions & 2 deletions packages/shadcn-renderers/src/components/TextFieldRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,30 @@
* the binder-generated setter (optimistic-local, synced on action), exactly
* as in the Astryx renderer — the data-model behavior belongs to the
* protocol layer, not the design system.
*
* `size` is carried verbatim by the contract and projected onto shadcn's
* input height/type-scale steps; dropping it would render a compact field at
* the same height as a large one.
*/
import { useId, useState, type FC } from "react";
import { cn } from "../cn";

/** Field size -> shadcn's input scale. Catalog default: md. */
const SIZE: Record<string, { input: string; label: string }> = {
sm: { input: "h-8 px-2 py-1 text-xs", label: "text-xs" },
md: { input: "h-9 px-3 py-1 text-sm", label: "text-sm" },
lg: { input: "h-10 px-4 py-2 text-base", label: "text-base" },
};

export const TextFieldRender: FC<any> = ({ props }) => {
const id = useId();
const bound = typeof props.setValue === "function";
const [local, setLocal] = useState(String(props.value ?? ""));
const size = SIZE[props.size as string] ?? SIZE.md;
return (
<div className="grid w-full gap-1.5">
{!props.isLabelHidden && (
<label htmlFor={id} className="text-sm font-medium leading-none">
<label htmlFor={id} className={cn("font-medium leading-none", size.label)}>
{String(props.label ?? "")}
</label>
)}
Expand All @@ -23,7 +35,8 @@ export const TextFieldRender: FC<any> = ({ props }) => {
aria-label={props.isLabelHidden ? String(props.label ?? "") : undefined}
type={props.variant === "obscured" ? "password" : "text"}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex w-full rounded-md border border-input bg-transparent shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
size.input,
)}
value={bound ? String(props.value ?? "") : local}
onChange={(e) => (bound ? props.setValue(e.target.value) : setLocal(e.target.value))}
Expand Down
Loading
Loading