From 8dc5ab32cef386b38009eede380fd3592018bd26 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 20:28:59 +0700 Subject: [PATCH] AI-first architecture spec New spec supersedes all prior design docs. Phase 1 covers Zod-first props migration for 31 components, Meta objects, Card/Avatar/NavigationMenu, cut ContextMenu/Image/Meter, and registry scaffolding. --- .../2026-03-29-phase1-zod-first-foundation.md | 3127 +++++++++++++++++ ...026-03-29-pettyui-ai-first-architecture.md | 323 ++ 2 files changed, 3450 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-29-phase1-zod-first-foundation.md create mode 100644 docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md diff --git a/docs/superpowers/plans/2026-03-29-phase1-zod-first-foundation.md b/docs/superpowers/plans/2026-03-29-phase1-zod-first-foundation.md new file mode 100644 index 0000000..faa1f6e --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-phase1-zod-first-foundation.md @@ -0,0 +1,3127 @@ +# Phase 1: Zod-First Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate all existing components to Zod-first prop schemas with Meta objects, cut 3 components, demote 2 exports, add Card/Avatar/NavigationMenu, and scaffold the registry package. + +**Architecture:** Every component gets a `{name}.props.ts` file containing a Zod schema (`{Name}PropsSchema`) and a Meta object (`{Name}Meta`). TypeScript types extend from `z.infer<>`. The MCP server (Phase 3) reads these schemas directly. Components that extend JSX attributes use `interface extends z.infer` pattern. + +**Tech Stack:** Zod v4 (already a root devDependency), SolidJS, TypeScript 6, Vitest, tsdown + +**Spec:** `docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md` + +**Phases 2 & 3:** Separate plans — Phase 2 (advanced components), Phase 3 (MCP server + CLI) + +--- + +## File Structure Overview + +**New files per component (migration):** +``` +packages/core/src/components/{name}/{name}.props.ts → Zod schema + Meta +``` + +**Modified files per component (migration):** +``` +packages/core/src/components/{name}/{name}-root.tsx → Import props from .props.ts +packages/core/src/components/{name}/index.ts → Re-export schema + Meta +``` + +**New packages:** +``` +packages/registry/ → Registry scaffolding (init, tokens, utils) +``` + +**New components:** +``` +packages/core/src/components/card/ +packages/core/src/components/avatar/ +packages/core/src/components/navigation-menu/ +``` + +--- + +## Schema Convention + +Every component follows this pattern. Root/complex components get their own `{name}.props.ts`: + +```typescript +// {name}.props.ts +import { z } from "zod/v4"; + +// Schema value — what MCP reads +export const {Name}RootPropsSchema = z.object({ /* ... */ }); + +// TypeScript type — extends schema + JSX stuff that can't be schema'd +export interface {Name}RootProps extends z.infer { + children: JSX.Element; +} + +// Meta — what MCP uses for discovery +export const {Name}Meta = { + name: "{Name}", + description: "...", + parts: [...] as const, + requiredParts: [...] as const, +} as const; +``` + +Sub-components that only pass through JSX attributes don't need schemas — their existence is documented in the parent's `Meta.parts`. + +--- + +### Task 1: Add Zod v4 to Core Package + Shared Meta Type + +**Files:** +- Modify: `packages/core/package.json` +- Create: `packages/core/src/meta.ts` + +- [ ] **Step 1: Add Zod v4 as a dependency to the core package** + +```bash +cd packages/core && pnpm add zod@^4.3.6 +``` + +Zod is a build-time/tooling dependency for schema definitions. It will be externalized in the build (not bundled into browser output). + +- [ ] **Step 2: Create shared Meta type definition** + +Create `packages/core/src/meta.ts`: + +```typescript +/** + * Metadata for MCP component discovery. + * Every component exports a Meta object conforming to this shape. + */ +export interface ComponentMeta { + /** Component display name */ + readonly name: string; + /** One-line description for AI semantic search */ + readonly description: string; + /** All available sub-component parts */ + readonly parts: readonly string[]; + /** Parts required for accessibility compliance */ + readonly requiredParts: readonly string[]; +} +``` + +- [ ] **Step 3: Add Zod to tsdown externals** + +Modify `packages/core/tsdown.config.ts` — add `"zod"` and `"zod/v4"` to the external array: + +```typescript +external: ["solid-js", "solid-js/web", "solid-js/store", "zod", "zod/v4"], +``` + +- [ ] **Step 4: Verify build still works** + +```bash +cd packages/core && pnpm build +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/package.json packages/core/src/meta.ts packages/core/tsdown.config.ts pnpm-lock.yaml +git commit -m "feat: add Zod v4 to core, create shared ComponentMeta type" +``` + +--- + +### Task 2: Reference Migration — Dialog to Zod-First + +**Files:** +- Create: `packages/core/src/components/dialog/dialog.props.ts` +- Modify: `packages/core/src/components/dialog/dialog-root.tsx` +- Modify: `packages/core/src/components/dialog/dialog-content.tsx` +- Modify: `packages/core/src/components/dialog/index.ts` +- Test: `packages/core/tests/components/dialog/dialog-props.test.ts` + +This is the reference implementation. Every subsequent migration follows this exact pattern. + +- [ ] **Step 1: Write test for Dialog schema** + +Create `packages/core/tests/components/dialog/dialog-props.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "../../../src/components/dialog/dialog.props"; + +describe("Dialog Zod schemas", () => { + it("validates correct root props", () => { + const result = DialogRootPropsSchema.safeParse({ + open: true, + modal: false, + }); + expect(result.success).toBe(true); + }); + + it("validates empty root props (all optional)", () => { + const result = DialogRootPropsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects invalid root props", () => { + const result = DialogRootPropsSchema.safeParse({ + open: "yes", + }); + expect(result.success).toBe(false); + }); + + it("validates content props", () => { + const result = DialogContentPropsSchema.safeParse({ + forceMount: true, + }); + expect(result.success).toBe(true); + }); + + it("exposes meta with required parts", () => { + expect(DialogMeta.name).toBe("Dialog"); + expect(DialogMeta.parts).toContain("Root"); + expect(DialogMeta.parts).toContain("Content"); + expect(DialogMeta.parts).toContain("Title"); + expect(DialogMeta.requiredParts).toContain("Root"); + expect(DialogMeta.requiredParts).toContain("Content"); + expect(DialogMeta.requiredParts).toContain("Title"); + }); + + it("schema has descriptions on all fields", () => { + const shape = DialogRootPropsSchema.shape; + // Verify .describe() was called — Zod v4 stores descriptions + expect(shape.open.description).toBeDefined(); + expect(shape.modal.description).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/core && pnpm vitest run tests/components/dialog/dialog-props.test.ts +``` + +Expected: FAIL — `dialog.props` module does not exist. + +- [ ] **Step 3: Create dialog.props.ts** + +Create `packages/core/src/components/dialog/dialog.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +// --- Root --- +export const DialogRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + modal: z.boolean().optional() + .describe("Whether to trap focus and add backdrop. Defaults to true"), +}); + +export interface DialogRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +// --- Content --- +export const DialogContentPropsSchema = z.object({ + forceMount: z.boolean().optional() + .describe("Force mount content even when closed, useful for animations"), +}); + +export interface DialogContentProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +// --- Trigger --- +export interface DialogTriggerProps + extends Omit, "children"> { + children?: JSX.Element | ((props: JSX.HTMLAttributes) => JSX.Element); +} + +// --- Title --- +export interface DialogTitleProps extends JSX.HTMLAttributes {} + +// --- Description --- +export interface DialogDescriptionProps extends JSX.HTMLAttributes {} + +// --- Close --- +export interface DialogCloseProps extends JSX.HTMLAttributes {} + +// --- Overlay --- +export interface DialogOverlayProps extends JSX.HTMLAttributes {} + +// --- Portal --- +export interface DialogPortalProps { + mount?: Node; + children: JSX.Element; +} + +// --- Meta --- +export const DialogMeta: ComponentMeta = { + name: "Dialog", + description: "Modal overlay that interrupts the user with important content requiring acknowledgment", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"] as const, + requiredParts: ["Root", "Content", "Title"] as const, +} as const; +``` + +- [ ] **Step 4: Run schema test to verify it passes** + +```bash +cd packages/core && pnpm vitest run tests/components/dialog/dialog-props.test.ts +``` + +Expected: PASS — all 6 tests pass. + +- [ ] **Step 5: Update dialog-root.tsx to import props from dialog.props.ts** + +In `packages/core/src/components/dialog/dialog-root.tsx`, replace the local `DialogRootProps` interface with an import: + +Replace: +```typescript +export interface DialogRootProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + modal?: boolean; + children: JSX.Element; +} +``` + +With: +```typescript +import type { DialogRootProps } from "./dialog.props"; +export type { DialogRootProps }; +``` + +- [ ] **Step 6: Update dialog-content.tsx to import props from dialog.props.ts** + +In `packages/core/src/components/dialog/dialog-content.tsx`, replace the local `DialogContentProps` with an import: + +Replace the local interface with: +```typescript +import type { DialogContentProps } from "./dialog.props"; +export type { DialogContentProps }; +``` + +- [ ] **Step 7: Update remaining sub-component files to import props from dialog.props.ts** + +For each of `dialog-trigger.tsx`, `dialog-title.tsx`, `dialog-description.tsx`, `dialog-close.tsx`, `dialog-overlay.tsx`, `dialog-portal.tsx`: + +Replace local prop interfaces with imports from `./dialog.props`. Keep the `export type` re-export so existing consumers don't break. + +- [ ] **Step 8: Update dialog/index.ts to re-export schema and meta** + +Add these exports to `packages/core/src/components/dialog/index.ts`: + +```typescript +export { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "./dialog.props"; +``` + +- [ ] **Step 9: Run ALL existing Dialog tests to verify nothing broke** + +```bash +cd packages/core && pnpm vitest run tests/components/dialog/ +``` + +Expected: ALL existing tests pass + the new schema test passes. + +- [ ] **Step 10: Commit** + +```bash +git add packages/core/src/components/dialog/ packages/core/tests/components/dialog/ +git commit -m "feat(dialog): migrate to Zod-first props with Meta object" +``` + +--- + +### Task 3: Migrate Simple Leaf Components + +**Components:** Button, Badge, Alert, Skeleton, Link, Toggle, Progress + +These are simple components with few or no custom props beyond JSX passthrough. Each gets a `.props.ts` with a schema + Meta. + +**Pattern per component:** +1. Create `{name}.props.ts` with schema + Meta +2. Update component file to import props from `.props.ts` +3. Update `index.ts` to re-export schema + Meta +4. Run tests +5. Commit (batch — all simple components in one commit) + +- [ ] **Step 1: Create button.props.ts** + +Create `packages/core/src/components/button/button.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ButtonPropsSchema = z.object({ + type: z.enum(["button", "submit", "reset"]).optional() + .describe("Button type attribute. Defaults to 'button' to prevent accidental form submission"), + disabled: z.boolean().optional() + .describe("Whether the button is disabled"), +}); + +export interface ButtonProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const ButtonMeta: ComponentMeta = { + name: "Button", + description: "Clickable element that triggers an action. Defaults type to 'button' to prevent form submission", + parts: ["Button"] as const, + requiredParts: ["Button"] as const, +} as const; +``` + +- [ ] **Step 2: Update button.tsx to import from button.props.ts** + +In `packages/core/src/components/button/button.tsx`, replace the local `ButtonProps` interface: + +```typescript +import type { ButtonProps } from "./button.props"; +export type { ButtonProps }; +``` + +- [ ] **Step 3: Update button/index.ts to re-export schema + Meta** + +Add to `packages/core/src/components/button/index.ts`: + +```typescript +export { ButtonPropsSchema, ButtonMeta } from "./button.props"; +``` + +- [ ] **Step 4: Create badge.props.ts** + +Create `packages/core/src/components/badge/badge.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const BadgePropsSchema = z.object({}); + +export interface BadgeProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export const BadgeMeta: ComponentMeta = { + name: "Badge", + description: "Small status indicator label, typically used for counts, tags, or status", + parts: ["Badge"] as const, + requiredParts: ["Badge"] as const, +} as const; +``` + +- [ ] **Step 5: Update badge component + index.ts** + +Same pattern as Button — import props from `.props.ts`, add schema/Meta re-exports to `index.ts`. + +- [ ] **Step 6: Create alert.props.ts** + +Create `packages/core/src/components/alert/alert.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const AlertRootPropsSchema = z.object({}); + +export interface AlertRootProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export const AlertMeta: ComponentMeta = { + name: "Alert", + description: "Inline status message for important information, warnings, errors, or success states", + parts: ["Root", "Title", "Description"] as const, + requiredParts: ["Root"] as const, +} as const; +``` + +- [ ] **Step 7: Update alert component + index.ts** + +Import props from `.props.ts`, re-export schema + Meta. + +- [ ] **Step 8: Create skeleton.props.ts** + +Create `packages/core/src/components/skeleton/skeleton.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const SkeletonPropsSchema = z.object({}); + +export interface SkeletonProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export const SkeletonMeta: ComponentMeta = { + name: "Skeleton", + description: "Placeholder loading indicator that mimics the shape of content being loaded", + parts: ["Skeleton"] as const, + requiredParts: ["Skeleton"] as const, +} as const; +``` + +- [ ] **Step 9: Update skeleton component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 10: Create link.props.ts** + +Create `packages/core/src/components/link/link.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const LinkPropsSchema = z.object({ + href: z.string().optional() + .describe("URL the link points to"), + external: z.boolean().optional() + .describe("Whether the link opens in a new tab with rel='noopener noreferrer'"), + disabled: z.boolean().optional() + .describe("Whether the link is disabled"), +}); + +export interface LinkProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const LinkMeta: ComponentMeta = { + name: "Link", + description: "Navigation anchor element with external link and disabled support", + parts: ["Link"] as const, + requiredParts: ["Link"] as const, +} as const; +``` + +- [ ] **Step 11: Update link component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 12: Create toggle.props.ts** + +Create `packages/core/src/components/toggle/toggle.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const TogglePropsSchema = z.object({ + pressed: z.boolean().optional() + .describe("Controlled pressed state"), + defaultPressed: z.boolean().optional() + .describe("Initial pressed state (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the toggle is disabled"), +}); + +export interface ToggleProps + extends z.infer, + Omit, keyof z.infer> { + onPressedChange?: (pressed: boolean) => void; + children?: JSX.Element; +} + +export const ToggleMeta: ComponentMeta = { + name: "Toggle", + description: "Two-state button that can be toggled on or off", + parts: ["Toggle"] as const, + requiredParts: ["Toggle"] as const, +} as const; +``` + +- [ ] **Step 13: Update toggle component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 14: Create progress.props.ts** + +Create `packages/core/src/components/progress/progress.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ProgressRootPropsSchema = z.object({ + value: z.number().optional() + .describe("Current progress value"), + max: z.number().optional() + .describe("Maximum progress value. Defaults to 100"), + getValueLabel: z.function().args(z.number(), z.number()).returns(z.string()).optional() + .describe("Function to generate accessible label from (value, max)"), +}); + +export interface ProgressRootProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const ProgressMeta: ComponentMeta = { + name: "Progress", + description: "Visual indicator showing completion progress of a task or operation", + parts: ["Root", "Track", "Fill", "Label"] as const, + requiredParts: ["Root"] as const, +} as const; +``` + +- [ ] **Step 15: Update progress component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 16: Write batch schema test** + +Create `packages/core/tests/schemas/simple-components.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { ButtonPropsSchema, ButtonMeta } from "../../src/components/button/button.props"; +import { BadgeMeta } from "../../src/components/badge/badge.props"; +import { AlertMeta } from "../../src/components/alert/alert.props"; +import { SkeletonMeta } from "../../src/components/skeleton/skeleton.props"; +import { LinkPropsSchema, LinkMeta } from "../../src/components/link/link.props"; +import { TogglePropsSchema, ToggleMeta } from "../../src/components/toggle/toggle.props"; +import { ProgressRootPropsSchema, ProgressMeta } from "../../src/components/progress/progress.props"; + +describe("Simple component schemas", () => { + it("Button schema validates", () => { + expect(ButtonPropsSchema.safeParse({ type: "submit" }).success).toBe(true); + expect(ButtonPropsSchema.safeParse({ type: "invalid" }).success).toBe(false); + }); + + it("Link schema validates", () => { + expect(LinkPropsSchema.safeParse({ href: "/about", external: true }).success).toBe(true); + }); + + it("Toggle schema validates", () => { + expect(TogglePropsSchema.safeParse({ pressed: true, disabled: false }).success).toBe(true); + }); + + it("Progress schema validates", () => { + expect(ProgressRootPropsSchema.safeParse({ value: 50, max: 100 }).success).toBe(true); + }); + + it("all Meta objects have required fields", () => { + const metas = [ButtonMeta, BadgeMeta, AlertMeta, SkeletonMeta, LinkMeta, ToggleMeta, ProgressMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 17: Run all tests** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: ALL tests pass — both new schema tests and all existing component tests. + +- [ ] **Step 18: Commit** + +```bash +git add packages/core/src/components/button/ packages/core/src/components/badge/ packages/core/src/components/alert/ packages/core/src/components/skeleton/ packages/core/src/components/link/ packages/core/src/components/toggle/ packages/core/src/components/progress/ packages/core/tests/schemas/ +git commit -m "feat: migrate Button, Badge, Alert, Skeleton, Link, Toggle, Progress to Zod-first props" +``` + +--- + +### Task 4: Migrate Form Components + +**Components:** TextField, Checkbox, Switch, RadioGroup, Slider, NumberField, ToggleGroup + +These share a consistent controlled/uncontrolled pattern with `value`, `defaultValue`, `onChange`. + +- [ ] **Step 1: Create text-field.props.ts** + +Create `packages/core/src/components/text-field/text-field.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const TextFieldRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled input value"), + defaultValue: z.string().optional() + .describe("Initial input value (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the field is disabled"), + readOnly: z.boolean().optional() + .describe("Whether the field is read-only"), + required: z.boolean().optional() + .describe("Whether the field is required"), +}); + +export interface TextFieldRootProps extends z.infer { + onValueChange?: (value: string) => void; + children: JSX.Element; +} + +export const TextFieldMeta: ComponentMeta = { + name: "TextField", + description: "Text input field with label, description, and error message support", + parts: ["Root", "Label", "Input", "TextArea", "Description", "ErrorMessage"] as const, + requiredParts: ["Root", "Input"] as const, +} as const; +``` + +- [ ] **Step 2: Update text-field component files + index.ts** + +Import props from `.props.ts`, re-export schema + Meta from `index.ts`. + +- [ ] **Step 3: Create checkbox.props.ts** + +Create `packages/core/src/components/checkbox/checkbox.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const CheckboxRootPropsSchema = z.object({ + checked: z.boolean().optional() + .describe("Controlled checked state"), + defaultChecked: z.boolean().optional() + .describe("Initial checked state (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the checkbox is disabled"), + required: z.boolean().optional() + .describe("Whether the checkbox is required"), + name: z.string().optional() + .describe("Name attribute for form submission"), + value: z.string().optional() + .describe("Value attribute for form submission"), +}); + +export interface CheckboxRootProps + extends z.infer, + Omit, keyof z.infer> { + onCheckedChange?: (checked: boolean) => void; + children?: JSX.Element; +} + +export const CheckboxMeta: ComponentMeta = { + name: "Checkbox", + description: "Toggle control for boolean input, supports indeterminate state", + parts: ["Root", "Input", "Control", "Indicator", "Label", "Description", "ErrorMessage"] as const, + requiredParts: ["Root"] as const, +} as const; +``` + +- [ ] **Step 4: Update checkbox component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 5: Create switch.props.ts** + +Create `packages/core/src/components/switch/switch.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const SwitchRootPropsSchema = z.object({ + checked: z.boolean().optional() + .describe("Controlled checked state"), + defaultChecked: z.boolean().optional() + .describe("Initial checked state (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the switch is disabled"), + required: z.boolean().optional() + .describe("Whether the switch is required"), + name: z.string().optional() + .describe("Name attribute for form submission"), + value: z.string().optional() + .describe("Value attribute for form submission"), +}); + +export interface SwitchRootProps + extends z.infer, + Omit, keyof z.infer> { + onCheckedChange?: (checked: boolean) => void; + children?: JSX.Element; +} + +export const SwitchMeta: ComponentMeta = { + name: "Switch", + description: "Toggle control for on/off states, visually distinct from checkbox", + parts: ["Root", "Input", "Control", "Thumb", "Label", "Description", "ErrorMessage"] as const, + requiredParts: ["Root"] as const, +} as const; +``` + +- [ ] **Step 6: Update switch component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 7: Create radio-group.props.ts** + +Create `packages/core/src/components/radio-group/radio-group.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const RadioGroupRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled selected value"), + defaultValue: z.string().optional() + .describe("Initial selected value (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the entire group is disabled"), + required: z.boolean().optional() + .describe("Whether selection is required"), + name: z.string().optional() + .describe("Name attribute for form submission"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Layout direction for keyboard navigation. Defaults to 'vertical'"), +}); + +export interface RadioGroupRootProps extends z.infer { + onValueChange?: (value: string) => void; + children: JSX.Element; +} + +export const RadioGroupItemPropsSchema = z.object({ + value: z.string().describe("Value of this radio option"), + disabled: z.boolean().optional().describe("Whether this option is disabled"), +}); + +export interface RadioGroupItemProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const RadioGroupMeta: ComponentMeta = { + name: "RadioGroup", + description: "Group of mutually exclusive options where only one can be selected", + parts: ["Root", "Item", "ItemInput", "ItemControl", "ItemIndicator", "ItemLabel", "Label", "Description", "ErrorMessage"] as const, + requiredParts: ["Root", "Item"] as const, +} as const; +``` + +- [ ] **Step 8: Update radio-group component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 9: Create slider.props.ts** + +Create `packages/core/src/components/slider/slider.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const SliderRootPropsSchema = z.object({ + value: z.array(z.number()).optional() + .describe("Controlled value (array for range sliders)"), + defaultValue: z.array(z.number()).optional() + .describe("Initial value (uncontrolled)"), + min: z.number().optional() + .describe("Minimum value. Defaults to 0"), + max: z.number().optional() + .describe("Maximum value. Defaults to 100"), + step: z.number().optional() + .describe("Step increment. Defaults to 1"), + disabled: z.boolean().optional() + .describe("Whether the slider is disabled"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Slider orientation. Defaults to 'horizontal'"), +}); + +export interface SliderRootProps extends z.infer { + onValueChange?: (value: number[]) => void; + children: JSX.Element; +} + +export const SliderMeta: ComponentMeta = { + name: "Slider", + description: "Range input for selecting numeric values by dragging a thumb along a track", + parts: ["Root", "Track", "Fill", "Thumb", "Label", "ValueLabel"] as const, + requiredParts: ["Root", "Track", "Thumb"] as const, +} as const; +``` + +- [ ] **Step 10: Update slider component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 11: Create number-field.props.ts** + +Create `packages/core/src/components/number-field/number-field.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const NumberFieldRootPropsSchema = z.object({ + value: z.number().optional() + .describe("Controlled numeric value"), + defaultValue: z.number().optional() + .describe("Initial numeric value (uncontrolled)"), + min: z.number().optional() + .describe("Minimum allowed value"), + max: z.number().optional() + .describe("Maximum allowed value"), + step: z.number().optional() + .describe("Step increment for increment/decrement buttons. Defaults to 1"), + disabled: z.boolean().optional() + .describe("Whether the field is disabled"), + required: z.boolean().optional() + .describe("Whether the field is required"), +}); + +export interface NumberFieldRootProps extends z.infer { + onValueChange?: (value: number) => void; + children: JSX.Element; +} + +export const NumberFieldMeta: ComponentMeta = { + name: "NumberField", + description: "Numeric input with increment/decrement buttons and keyboard support", + parts: ["Root", "Input", "IncrementTrigger", "DecrementTrigger", "Label", "Description", "ErrorMessage"] as const, + requiredParts: ["Root", "Input"] as const, +} as const; +``` + +- [ ] **Step 12: Update number-field component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 13: Create toggle-group.props.ts** + +Create `packages/core/src/components/toggle-group/toggle-group.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ToggleGroupRootPropsSchema = z.object({ + value: z.union([z.string(), z.array(z.string())]).optional() + .describe("Controlled selected value(s). String for single, array for multiple"), + defaultValue: z.union([z.string(), z.array(z.string())]).optional() + .describe("Initial selected value(s) (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the entire group is disabled"), + multiple: z.boolean().optional() + .describe("Whether multiple items can be selected simultaneously"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Layout direction for keyboard navigation. Defaults to 'horizontal'"), +}); + +export interface ToggleGroupRootProps extends z.infer { + onValueChange?: (value: string | string[]) => void; + children: JSX.Element; +} + +export const ToggleGroupItemPropsSchema = z.object({ + value: z.string().describe("Value of this toggle option"), + disabled: z.boolean().optional().describe("Whether this item is disabled"), +}); + +export interface ToggleGroupItemProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const ToggleGroupMeta: ComponentMeta = { + name: "ToggleGroup", + description: "Group of toggle buttons where one or multiple can be selected", + parts: ["Root", "Item"] as const, + requiredParts: ["Root", "Item"] as const, +} as const; +``` + +- [ ] **Step 14: Update toggle-group component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 15: Write batch schema test for form components** + +Create `packages/core/tests/schemas/form-components.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { TextFieldRootPropsSchema, TextFieldMeta } from "../../src/components/text-field/text-field.props"; +import { CheckboxRootPropsSchema, CheckboxMeta } from "../../src/components/checkbox/checkbox.props"; +import { SwitchRootPropsSchema, SwitchMeta } from "../../src/components/switch/switch.props"; +import { RadioGroupRootPropsSchema, RadioGroupMeta } from "../../src/components/radio-group/radio-group.props"; +import { SliderRootPropsSchema, SliderMeta } from "../../src/components/slider/slider.props"; +import { NumberFieldRootPropsSchema, NumberFieldMeta } from "../../src/components/number-field/number-field.props"; +import { ToggleGroupRootPropsSchema, ToggleGroupMeta } from "../../src/components/toggle-group/toggle-group.props"; + +describe("Form component schemas", () => { + it("TextField validates controlled props", () => { + expect(TextFieldRootPropsSchema.safeParse({ value: "hello", disabled: false }).success).toBe(true); + }); + + it("Checkbox validates checked state", () => { + expect(CheckboxRootPropsSchema.safeParse({ checked: true, name: "agree" }).success).toBe(true); + }); + + it("Switch validates checked state", () => { + expect(SwitchRootPropsSchema.safeParse({ checked: false }).success).toBe(true); + }); + + it("RadioGroup validates value + orientation", () => { + expect(RadioGroupRootPropsSchema.safeParse({ value: "opt1", orientation: "horizontal" }).success).toBe(true); + expect(RadioGroupRootPropsSchema.safeParse({ orientation: "invalid" }).success).toBe(false); + }); + + it("Slider validates range values", () => { + expect(SliderRootPropsSchema.safeParse({ value: [25, 75], min: 0, max: 100, step: 5 }).success).toBe(true); + }); + + it("NumberField validates numeric props", () => { + expect(NumberFieldRootPropsSchema.safeParse({ value: 42, min: 0, max: 100, step: 1 }).success).toBe(true); + }); + + it("ToggleGroup validates single and multiple values", () => { + expect(ToggleGroupRootPropsSchema.safeParse({ value: "a" }).success).toBe(true); + expect(ToggleGroupRootPropsSchema.safeParse({ value: ["a", "b"], multiple: true }).success).toBe(true); + }); + + it("all form Meta objects have required fields", () => { + const metas = [TextFieldMeta, CheckboxMeta, SwitchMeta, RadioGroupMeta, SliderMeta, NumberFieldMeta, ToggleGroupMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 16: Run all tests** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: ALL tests pass. + +- [ ] **Step 17: Commit** + +```bash +git add packages/core/src/components/text-field/ packages/core/src/components/checkbox/ packages/core/src/components/switch/ packages/core/src/components/radio-group/ packages/core/src/components/slider/ packages/core/src/components/number-field/ packages/core/src/components/toggle-group/ packages/core/tests/schemas/ +git commit -m "feat: migrate TextField, Checkbox, Switch, RadioGroup, Slider, NumberField, ToggleGroup to Zod-first props" +``` + +--- + +### Task 5: Migrate Disclosure + Structure Components + +**Components:** Accordion, Collapsible, AlertDialog, Breadcrumbs, Tabs + +- [ ] **Step 1: Create accordion.props.ts** + +Create `packages/core/src/components/accordion/accordion.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const AccordionRootPropsSchema = z.object({ + value: z.union([z.string(), z.array(z.string())]).optional() + .describe("Controlled expanded item(s). String for single, array for multiple"), + defaultValue: z.union([z.string(), z.array(z.string())]).optional() + .describe("Initial expanded item(s) (uncontrolled)"), + multiple: z.boolean().optional() + .describe("Whether multiple items can be expanded simultaneously"), + collapsible: z.boolean().optional() + .describe("Whether all items can be collapsed. When false, one item stays open"), + disabled: z.boolean().optional() + .describe("Whether the entire accordion is disabled"), +}); + +export interface AccordionRootProps extends z.infer { + onValueChange?: (value: string | string[]) => void; + children: JSX.Element; +} + +export const AccordionItemPropsSchema = z.object({ + value: z.string().describe("Unique identifier for this accordion item"), + disabled: z.boolean().optional().describe("Whether this item is disabled"), +}); + +export interface AccordionItemProps extends z.infer { + children: JSX.Element; +} + +export const AccordionMeta: ComponentMeta = { + name: "Accordion", + description: "Vertically stacked sections that expand/collapse to show content one at a time or multiple", + parts: ["Root", "Item", "Header", "Trigger", "Content"] as const, + requiredParts: ["Root", "Item", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 2: Update accordion component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 3: Create collapsible.props.ts** + +Create `packages/core/src/components/collapsible/collapsible.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const CollapsibleRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the collapsible is disabled"), +}); + +export interface CollapsibleRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const CollapsibleMeta: ComponentMeta = { + name: "Collapsible", + description: "Content section that can be expanded or collapsed with a trigger", + parts: ["Root", "Trigger", "Content"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 4: Update collapsible component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 5: Create alert-dialog.props.ts** + +Create `packages/core/src/components/alert-dialog/alert-dialog.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const AlertDialogRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), +}); + +export interface AlertDialogRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const AlertDialogMeta: ComponentMeta = { + name: "AlertDialog", + description: "Modal dialog for critical confirmations that requires explicit user action to dismiss", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Cancel", "Action"] as const, + requiredParts: ["Root", "Content", "Title", "Action"] as const, +} as const; +``` + +- [ ] **Step 6: Update alert-dialog component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 7: Create breadcrumbs.props.ts** + +Create `packages/core/src/components/breadcrumbs/breadcrumbs.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const BreadcrumbsRootPropsSchema = z.object({ + separator: z.string().optional() + .describe("Separator character between items. Defaults to '/'"), +}); + +export interface BreadcrumbsRootProps + extends z.infer, + Omit, keyof z.infer> { + children: JSX.Element; +} + +export const BreadcrumbsMeta: ComponentMeta = { + name: "Breadcrumbs", + description: "Navigation trail showing the current page location within a hierarchy", + parts: ["Root", "Item", "Link", "Separator"] as const, + requiredParts: ["Root", "Item"] as const, +} as const; +``` + +- [ ] **Step 8: Update breadcrumbs component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 9: Create tabs.props.ts** + +Create `packages/core/src/components/tabs/tabs.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const TabsRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled active tab value"), + defaultValue: z.string().optional() + .describe("Initial active tab value (uncontrolled)"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Tab list orientation for keyboard navigation. Defaults to 'horizontal'"), + activationMode: z.enum(["automatic", "manual"]).optional() + .describe("Whether tabs activate on focus or on click. Defaults to 'automatic'"), + disabled: z.boolean().optional() + .describe("Whether the entire tab group is disabled"), +}); + +export interface TabsRootProps extends z.infer { + onValueChange?: (value: string) => void; + children: JSX.Element; +} + +export const TabsTriggerPropsSchema = z.object({ + value: z.string().describe("Value matching the corresponding TabsContent"), + disabled: z.boolean().optional().describe("Whether this tab is disabled"), +}); + +export interface TabsTriggerProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const TabsContentPropsSchema = z.object({ + value: z.string().describe("Value matching the corresponding TabsTrigger"), + forceMount: z.boolean().optional().describe("Keep content mounted when inactive"), +}); + +export interface TabsContentProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const TabsMeta: ComponentMeta = { + name: "Tabs", + description: "Tabbed interface for switching between different views or sections of content", + parts: ["Root", "List", "Trigger", "Content"] as const, + requiredParts: ["Root", "List", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 10: Update tabs component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 11: Write batch schema test** + +Create `packages/core/tests/schemas/disclosure-components.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { AccordionRootPropsSchema, AccordionMeta } from "../../src/components/accordion/accordion.props"; +import { CollapsibleRootPropsSchema, CollapsibleMeta } from "../../src/components/collapsible/collapsible.props"; +import { AlertDialogRootPropsSchema, AlertDialogMeta } from "../../src/components/alert-dialog/alert-dialog.props"; +import { BreadcrumbsRootPropsSchema, BreadcrumbsMeta } from "../../src/components/breadcrumbs/breadcrumbs.props"; +import { TabsRootPropsSchema, TabsMeta } from "../../src/components/tabs/tabs.props"; + +describe("Disclosure component schemas", () => { + it("Accordion validates single and multiple values", () => { + expect(AccordionRootPropsSchema.safeParse({ value: "item-1" }).success).toBe(true); + expect(AccordionRootPropsSchema.safeParse({ value: ["item-1", "item-2"], multiple: true }).success).toBe(true); + }); + + it("Collapsible validates open state", () => { + expect(CollapsibleRootPropsSchema.safeParse({ open: true, disabled: false }).success).toBe(true); + }); + + it("AlertDialog validates open state", () => { + expect(AlertDialogRootPropsSchema.safeParse({ open: false }).success).toBe(true); + }); + + it("Breadcrumbs validates separator", () => { + expect(BreadcrumbsRootPropsSchema.safeParse({ separator: ">" }).success).toBe(true); + }); + + it("Tabs validates orientation and activation mode", () => { + expect(TabsRootPropsSchema.safeParse({ value: "tab1", orientation: "vertical", activationMode: "manual" }).success).toBe(true); + expect(TabsRootPropsSchema.safeParse({ orientation: "diagonal" }).success).toBe(false); + }); + + it("all disclosure Meta objects have required fields", () => { + const metas = [AccordionMeta, CollapsibleMeta, AlertDialogMeta, BreadcrumbsMeta, TabsMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 12: Run all tests** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: ALL tests pass. + +- [ ] **Step 13: Commit** + +```bash +git add packages/core/src/components/accordion/ packages/core/src/components/collapsible/ packages/core/src/components/alert-dialog/ packages/core/src/components/breadcrumbs/ packages/core/src/components/tabs/ packages/core/tests/schemas/ +git commit -m "feat: migrate Accordion, Collapsible, AlertDialog, Breadcrumbs, Tabs to Zod-first props" +``` + +--- + +### Task 6: Migrate Overlay Components + +**Components:** Tooltip, Popover, HoverCard, Drawer, Toast + +- [ ] **Step 1: Create tooltip.props.ts** + +Create `packages/core/src/components/tooltip/tooltip.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const TooltipRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + openDelay: z.number().optional() + .describe("Delay in ms before tooltip opens on hover. Defaults to 700"), + closeDelay: z.number().optional() + .describe("Delay in ms before tooltip closes after leaving trigger. Defaults to 300"), + disabled: z.boolean().optional() + .describe("Whether the tooltip is disabled"), +}); + +export interface TooltipRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const TooltipMeta: ComponentMeta = { + name: "Tooltip", + description: "Floating label that appears on hover/focus to describe an element", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 2: Update tooltip component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 3: Create popover.props.ts** + +Create `packages/core/src/components/popover/popover.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const PopoverRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + modal: z.boolean().optional() + .describe("Whether to trap focus inside the popover"), +}); + +export interface PopoverRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const PopoverMeta: ComponentMeta = { + name: "Popover", + description: "Floating content panel anchored to a trigger element, for interactive content", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow", "Close", "Title", "Description"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 4: Update popover component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 5: Create hover-card.props.ts** + +Create `packages/core/src/components/hover-card/hover-card.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const HoverCardRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + openDelay: z.number().optional() + .describe("Delay in ms before card opens on hover. Defaults to 700"), + closeDelay: z.number().optional() + .describe("Delay in ms before card closes after leaving. Defaults to 300"), +}); + +export interface HoverCardRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const HoverCardMeta: ComponentMeta = { + name: "HoverCard", + description: "Card that appears on hover to preview linked content without navigating", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 6: Update hover-card component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 7: Create drawer.props.ts** + +Create `packages/core/src/components/drawer/drawer.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const DrawerRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + side: z.enum(["left", "right", "top", "bottom"]).optional() + .describe("Which edge the drawer slides from. Defaults to 'right'"), + modal: z.boolean().optional() + .describe("Whether to trap focus and add backdrop. Defaults to true"), +}); + +export interface DrawerRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const DrawerMeta: ComponentMeta = { + name: "Drawer", + description: "Panel that slides in from the edge of the screen, used for navigation or secondary content", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"] as const, + requiredParts: ["Root", "Content", "Title"] as const, +} as const; +``` + +- [ ] **Step 8: Update drawer component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 9: Create toast.props.ts** + +Create `packages/core/src/components/toast/toast.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ToastRegionPropsSchema = z.object({ + placement: z.enum(["top-start", "top-center", "top-end", "bottom-start", "bottom-center", "bottom-end"]).optional() + .describe("Where toasts appear on screen. Defaults to 'bottom-end'"), + duration: z.number().optional() + .describe("Default auto-dismiss duration in ms. Defaults to 5000"), + swipeDirection: z.enum(["left", "right", "up", "down"]).optional() + .describe("Swipe direction to dismiss. Defaults to 'right'"), + limit: z.number().optional() + .describe("Maximum number of visible toasts. Defaults to 3"), +}); + +export interface ToastRegionProps extends z.infer { + children: JSX.Element; +} + +export const ToastMeta: ComponentMeta = { + name: "Toast", + description: "Temporary notification that auto-dismisses, with imperative toast.add() API for programmatic creation", + parts: ["Region", "List", "Root", "Title", "Description", "Close", "Action"] as const, + requiredParts: ["Region", "List"] as const, +} as const; +``` + +- [ ] **Step 10: Update toast component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 11: Write batch schema test** + +Create `packages/core/tests/schemas/overlay-components.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { TooltipRootPropsSchema, TooltipMeta } from "../../src/components/tooltip/tooltip.props"; +import { PopoverRootPropsSchema, PopoverMeta } from "../../src/components/popover/popover.props"; +import { HoverCardRootPropsSchema, HoverCardMeta } from "../../src/components/hover-card/hover-card.props"; +import { DrawerRootPropsSchema, DrawerMeta } from "../../src/components/drawer/drawer.props"; +import { ToastRegionPropsSchema, ToastMeta } from "../../src/components/toast/toast.props"; + +describe("Overlay component schemas", () => { + it("Tooltip validates delay props", () => { + expect(TooltipRootPropsSchema.safeParse({ openDelay: 500, closeDelay: 200 }).success).toBe(true); + }); + + it("Popover validates modal prop", () => { + expect(PopoverRootPropsSchema.safeParse({ open: true, modal: true }).success).toBe(true); + }); + + it("HoverCard validates delay props", () => { + expect(HoverCardRootPropsSchema.safeParse({ openDelay: 300 }).success).toBe(true); + }); + + it("Drawer validates side enum", () => { + expect(DrawerRootPropsSchema.safeParse({ side: "left" }).success).toBe(true); + expect(DrawerRootPropsSchema.safeParse({ side: "center" }).success).toBe(false); + }); + + it("Toast validates placement and limits", () => { + expect(ToastRegionPropsSchema.safeParse({ placement: "top-center", duration: 3000, limit: 5 }).success).toBe(true); + expect(ToastRegionPropsSchema.safeParse({ placement: "middle" }).success).toBe(false); + }); + + it("all overlay Meta objects have required fields", () => { + const metas = [TooltipMeta, PopoverMeta, HoverCardMeta, DrawerMeta, ToastMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 12: Run all tests** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: ALL tests pass. + +- [ ] **Step 13: Commit** + +```bash +git add packages/core/src/components/tooltip/ packages/core/src/components/popover/ packages/core/src/components/hover-card/ packages/core/src/components/drawer/ packages/core/src/components/toast/ packages/core/tests/schemas/ +git commit -m "feat: migrate Tooltip, Popover, HoverCard, Drawer, Toast to Zod-first props" +``` + +--- + +### Task 7: Migrate Collection Components + +**Components:** Select, Combobox, DropdownMenu, Listbox, Separator, Pagination + +Separator and Pagination get migrated but their standalone exports are removed in Task 8. + +- [ ] **Step 1: Create select.props.ts** + +Create `packages/core/src/components/select/select.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const SelectRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled selected value"), + defaultValue: z.string().optional() + .describe("Initial selected value (uncontrolled)"), + disabled: z.boolean().optional() + .describe("Whether the select is disabled"), + required: z.boolean().optional() + .describe("Whether selection is required"), + name: z.string().optional() + .describe("Name attribute for form submission"), + placeholder: z.string().optional() + .describe("Placeholder text when no value is selected"), +}); + +export interface SelectRootProps extends z.infer { + onValueChange?: (value: string) => void; + children: JSX.Element; +} + +export const SelectItemPropsSchema = z.object({ + value: z.string().describe("Value of this option"), + disabled: z.boolean().optional().describe("Whether this option is disabled"), + textValue: z.string().optional().describe("Text for typeahead search, if different from visible text"), +}); + +export interface SelectItemProps + extends z.infer, + Omit, keyof z.infer> { + children?: JSX.Element; +} + +export const SelectMeta: ComponentMeta = { + name: "Select", + description: "Dropdown for selecting a single option from a list, with keyboard navigation and typeahead", + parts: ["Root", "Trigger", "Value", "Portal", "Content", "Listbox", "Item", "ItemLabel", "ItemIndicator", "Group", "GroupLabel"] as const, + requiredParts: ["Root", "Trigger", "Content", "Item"] as const, +} as const; +``` + +- [ ] **Step 2: Update select component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 3: Create combobox.props.ts** + +Create `packages/core/src/components/combobox/combobox.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ComboboxRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled selected value"), + defaultValue: z.string().optional() + .describe("Initial selected value (uncontrolled)"), + inputValue: z.string().optional() + .describe("Controlled search input value"), + disabled: z.boolean().optional() + .describe("Whether the combobox is disabled"), + required: z.boolean().optional() + .describe("Whether selection is required"), + name: z.string().optional() + .describe("Name attribute for form submission"), + placeholder: z.string().optional() + .describe("Placeholder text for the search input"), + allowCustomValue: z.boolean().optional() + .describe("Whether to allow values not in the option list"), +}); + +export interface ComboboxRootProps extends z.infer { + onValueChange?: (value: string) => void; + onInputValueChange?: (value: string) => void; + children: JSX.Element; +} + +export const ComboboxMeta: ComponentMeta = { + name: "Combobox", + description: "Searchable select that filters options as the user types, with keyboard navigation", + parts: ["Root", "Input", "Trigger", "Portal", "Content", "Listbox", "Item", "ItemLabel", "ItemIndicator", "Group", "GroupLabel"] as const, + requiredParts: ["Root", "Input", "Content", "Item"] as const, +} as const; +``` + +- [ ] **Step 4: Update combobox component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 5: Create dropdown-menu.props.ts** + +Create `packages/core/src/components/dropdown-menu/dropdown-menu.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const DropdownMenuRootPropsSchema = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), +}); + +export interface DropdownMenuRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const DropdownMenuItemPropsSchema = z.object({ + disabled: z.boolean().optional().describe("Whether this item is disabled"), + textValue: z.string().optional().describe("Text for typeahead, if different from visible text"), + closeOnSelect: z.boolean().optional().describe("Whether to close the menu when this item is selected. Defaults to true"), +}); + +export interface DropdownMenuItemProps + extends z.infer, + Omit, keyof z.infer> { + onSelect?: () => void; + children?: JSX.Element; +} + +export const DropdownMenuMeta: ComponentMeta = { + name: "DropdownMenu", + description: "Menu of actions triggered by a button, with keyboard navigation, grouping, and sub-menus", + parts: ["Root", "Trigger", "Portal", "Content", "Item", "Group", "GroupLabel", "Separator", "Sub", "SubTrigger", "SubContent"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; +``` + +- [ ] **Step 6: Update dropdown-menu component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 7: Create listbox.props.ts** + +Create `packages/core/src/components/listbox/listbox.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const ListboxRootPropsSchema = z.object({ + value: z.union([z.string(), z.array(z.string())]).optional() + .describe("Controlled selected value(s)"), + defaultValue: z.union([z.string(), z.array(z.string())]).optional() + .describe("Initial selected value(s) (uncontrolled)"), + multiple: z.boolean().optional() + .describe("Whether multiple items can be selected"), + disabled: z.boolean().optional() + .describe("Whether the listbox is disabled"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Layout direction for keyboard navigation. Defaults to 'vertical'"), +}); + +export interface ListboxRootProps extends z.infer { + onValueChange?: (value: string | string[]) => void; + children: JSX.Element; +} + +export const ListboxMeta: ComponentMeta = { + name: "Listbox", + description: "Inline list of selectable options with keyboard navigation, not in a dropdown", + parts: ["Root", "Item", "ItemLabel", "ItemIndicator", "Group", "GroupLabel"] as const, + requiredParts: ["Root", "Item"] as const, +} as const; +``` + +- [ ] **Step 8: Update listbox component + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 9: Create separator.props.ts and pagination.props.ts** + +Create `packages/core/src/components/separator/separator.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const SeparatorPropsSchema = z.object({ + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Visual orientation. Defaults to 'horizontal'"), +}); + +export interface SeparatorProps + extends z.infer, + Omit, keyof z.infer> {} + +export const SeparatorMeta: ComponentMeta = { + name: "Separator", + description: "Visual divider between content sections or menu items", + parts: ["Separator"] as const, + requiredParts: ["Separator"] as const, +} as const; +``` + +Create `packages/core/src/components/pagination/pagination.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const PaginationRootPropsSchema = z.object({ + page: z.number().optional() + .describe("Controlled current page (1-indexed)"), + defaultPage: z.number().optional() + .describe("Initial page (uncontrolled)"), + count: z.number().describe("Total number of pages"), + siblingCount: z.number().optional() + .describe("Number of page buttons shown around the current page. Defaults to 1"), + disabled: z.boolean().optional() + .describe("Whether pagination is disabled"), +}); + +export interface PaginationRootProps extends z.infer { + onPageChange?: (page: number) => void; + children: JSX.Element; +} + +export const PaginationMeta: ComponentMeta = { + name: "Pagination", + description: "Navigation for paginated content with page numbers, previous/next controls", + parts: ["Root", "Previous", "Next", "Items", "Item", "Ellipsis"] as const, + requiredParts: ["Root"] as const, +} as const; +``` + +- [ ] **Step 10: Update separator and pagination components + index.ts** + +Import props, re-export schema + Meta. + +- [ ] **Step 11: Write batch schema test** + +Create `packages/core/tests/schemas/collection-components.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { SelectRootPropsSchema, SelectMeta } from "../../src/components/select/select.props"; +import { ComboboxRootPropsSchema, ComboboxMeta } from "../../src/components/combobox/combobox.props"; +import { DropdownMenuRootPropsSchema, DropdownMenuMeta } from "../../src/components/dropdown-menu/dropdown-menu.props"; +import { ListboxRootPropsSchema, ListboxMeta } from "../../src/components/listbox/listbox.props"; +import { SeparatorPropsSchema, SeparatorMeta } from "../../src/components/separator/separator.props"; +import { PaginationRootPropsSchema, PaginationMeta } from "../../src/components/pagination/pagination.props"; + +describe("Collection component schemas", () => { + it("Select validates value and placeholder", () => { + expect(SelectRootPropsSchema.safeParse({ value: "opt1", placeholder: "Choose..." }).success).toBe(true); + }); + + it("Combobox validates search input", () => { + expect(ComboboxRootPropsSchema.safeParse({ inputValue: "search", allowCustomValue: true }).success).toBe(true); + }); + + it("DropdownMenu validates open state", () => { + expect(DropdownMenuRootPropsSchema.safeParse({ open: true }).success).toBe(true); + }); + + it("Listbox validates single and multiple selection", () => { + expect(ListboxRootPropsSchema.safeParse({ value: "a" }).success).toBe(true); + expect(ListboxRootPropsSchema.safeParse({ value: ["a", "b"], multiple: true }).success).toBe(true); + }); + + it("Separator validates orientation", () => { + expect(SeparatorPropsSchema.safeParse({ orientation: "vertical" }).success).toBe(true); + expect(SeparatorPropsSchema.safeParse({ orientation: "angled" }).success).toBe(false); + }); + + it("Pagination validates page and count", () => { + expect(PaginationRootPropsSchema.safeParse({ page: 3, count: 10, siblingCount: 2 }).success).toBe(true); + }); + + it("all collection Meta objects have required fields", () => { + const metas = [SelectMeta, ComboboxMeta, DropdownMenuMeta, ListboxMeta, SeparatorMeta, PaginationMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +}); +``` + +- [ ] **Step 12: Run all tests** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: ALL tests pass. + +- [ ] **Step 13: Commit** + +```bash +git add packages/core/src/components/select/ packages/core/src/components/combobox/ packages/core/src/components/dropdown-menu/ packages/core/src/components/listbox/ packages/core/src/components/separator/ packages/core/src/components/pagination/ packages/core/tests/schemas/ +git commit -m "feat: migrate Select, Combobox, DropdownMenu, Listbox, Separator, Pagination to Zod-first props" +``` + +--- + +### Task 8: Cut ContextMenu, Image, Meter + Demote Separator, Pagination Exports + +**Files:** +- Modify: `packages/core/tsdown.config.ts` +- Modify: `packages/core/package.json` +- Delete: Component test files for cut components (optional — keep for reference or delete) + +- [ ] **Step 1: Remove ContextMenu, Image, Meter from tsdown components array** + +In `packages/core/tsdown.config.ts`, remove `"context-menu"`, `"image"`, and `"meter"` from the `components` array. + +- [ ] **Step 2: Remove Separator and Pagination from tsdown components array** + +Also remove `"separator"` and `"pagination"` from the `components` array. They still exist in source (used internally by DropdownMenu and DataTable) but have no standalone build entry. + +- [ ] **Step 3: Remove exports from package.json** + +In `packages/core/package.json`, delete the `"./context-menu"`, `"./image"`, `"./meter"`, `"./separator"`, and `"./pagination"` entries from `"exports"`. + +- [ ] **Step 4: Verify build still works** + +```bash +cd packages/core && pnpm build +``` + +Expected: Build succeeds. Only 29 component entries + 6 utility entries. + +- [ ] **Step 5: Run all tests to ensure no import breakage** + +```bash +cd packages/core && pnpm vitest run +``` + +Expected: All tests pass. Tests for cut components may still pass (they import from source, not build output). Existing components that use Separator internally (DropdownMenu) still work because source files aren't deleted. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/tsdown.config.ts packages/core/package.json +git commit -m "feat: cut ContextMenu, Image, Meter exports; demote Separator, Pagination to internal" +``` + +--- + +### Task 9: Build Card Component + +**Files:** +- Create: `packages/core/src/components/card/card.props.ts` +- Create: `packages/core/src/components/card/card-root.tsx` +- Create: `packages/core/src/components/card/card-header.tsx` +- Create: `packages/core/src/components/card/card-content.tsx` +- Create: `packages/core/src/components/card/card-footer.tsx` +- Create: `packages/core/src/components/card/card-title.tsx` +- Create: `packages/core/src/components/card/card-description.tsx` +- Create: `packages/core/src/components/card/index.ts` +- Create: `packages/core/tests/components/card/card.test.tsx` +- Modify: `packages/core/tsdown.config.ts` +- Modify: `packages/core/package.json` + +- [ ] **Step 1: Write failing Card test** + +Create `packages/core/tests/components/card/card.test.tsx`: + +```typescript +import { render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Card } from "../../../src/components/card/index"; +import { CardPropsSchema, CardMeta } from "../../../src/components/card/card.props"; + +describe("Card", () => { + it("renders with compound API", () => { + render(() => ( + + + Title + Description + + Body content + Footer + + )); + expect(screen.getByText("Title")).toBeTruthy(); + expect(screen.getByText("Description")).toBeTruthy(); + expect(screen.getByText("Body content")).toBeTruthy(); + expect(screen.getByText("Footer")).toBeTruthy(); + }); + + it("renders as article element", () => { + render(() => ( + + Content + + )); + const card = screen.getByTestId("card"); + expect(card.tagName).toBe("DIV"); + }); + + it("schema validates empty props", () => { + expect(CardPropsSchema.safeParse({}).success).toBe(true); + }); + + it("meta has all required fields", () => { + expect(CardMeta.name).toBe("Card"); + expect(CardMeta.parts).toContain("Root"); + expect(CardMeta.parts).toContain("Header"); + expect(CardMeta.parts).toContain("Content"); + expect(CardMeta.parts).toContain("Footer"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/core && pnpm vitest run tests/components/card/ +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create card.props.ts** + +Create `packages/core/src/components/card/card.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const CardPropsSchema = z.object({}); + +export interface CardRootProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CardHeaderProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CardContentProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CardFooterProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CardTitleProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CardDescriptionProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export const CardMeta: ComponentMeta = { + name: "Card", + description: "Grouped content container with header, body, and footer sections", + parts: ["Root", "Header", "Content", "Footer", "Title", "Description"] as const, + requiredParts: ["Root", "Content"] as const, +} as const; +``` + +- [ ] **Step 4: Create card sub-components** + +Create `packages/core/src/components/card/card-root.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardRootProps } from "./card.props"; + +export function CardRoot(props: CardRootProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return
{local.children}
; +} +``` + +Create `packages/core/src/components/card/card-header.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardHeaderProps } from "./card.props"; + +export function CardHeader(props: CardHeaderProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return
{local.children}
; +} +``` + +Create `packages/core/src/components/card/card-content.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardContentProps } from "./card.props"; + +export function CardContent(props: CardContentProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return
{local.children}
; +} +``` + +Create `packages/core/src/components/card/card-footer.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardFooterProps } from "./card.props"; + +export function CardFooter(props: CardFooterProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return
{local.children}
; +} +``` + +Create `packages/core/src/components/card/card-title.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardTitleProps } from "./card.props"; + +export function CardTitle(props: CardTitleProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return

{local.children}

; +} +``` + +Create `packages/core/src/components/card/card-description.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CardDescriptionProps } from "./card.props"; + +export function CardDescription(props: CardDescriptionProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return

{local.children}

; +} +``` + +- [ ] **Step 5: Create card/index.ts with compound export** + +Create `packages/core/src/components/card/index.ts`: + +```typescript +import { CardRoot } from "./card-root"; +import { CardHeader } from "./card-header"; +import { CardContent } from "./card-content"; +import { CardFooter } from "./card-footer"; +import { CardTitle } from "./card-title"; +import { CardDescription } from "./card-description"; + +export const Card = Object.assign(CardRoot, { + Header: CardHeader, + Content: CardContent, + Footer: CardFooter, + Title: CardTitle, + Description: CardDescription, +}); + +export type { CardRootProps, CardHeaderProps, CardContentProps, CardFooterProps, CardTitleProps, CardDescriptionProps } from "./card.props"; +export { CardPropsSchema, CardMeta } from "./card.props"; +``` + +- [ ] **Step 6: Add card to tsdown.config.ts and package.json exports** + +Add `"card"` to the `components` array in `tsdown.config.ts`. + +Add to `package.json` exports: +```json +"./card": { + "solid": "./src/components/card/index.ts", + "import": "./dist/card/index.js", + "require": "./dist/card/index.cjs" +} +``` + +- [ ] **Step 7: Run tests** + +```bash +cd packages/core && pnpm vitest run tests/components/card/ +``` + +Expected: PASS — all 4 tests. + +- [ ] **Step 8: Run full test suite + build** + +```bash +cd packages/core && pnpm vitest run && pnpm build +``` + +Expected: All tests pass, build succeeds. + +- [ ] **Step 9: Commit** + +```bash +git add packages/core/src/components/card/ packages/core/tests/components/card/ packages/core/tsdown.config.ts packages/core/package.json +git commit -m "feat(card): add Card component with Zod-first props" +``` + +--- + +### Task 10: Build Avatar Component + +**Files:** +- Create: `packages/core/src/components/avatar/avatar.props.ts` +- Create: `packages/core/src/components/avatar/avatar-root.tsx` +- Create: `packages/core/src/components/avatar/avatar-image.tsx` +- Create: `packages/core/src/components/avatar/avatar-fallback.tsx` +- Create: `packages/core/src/components/avatar/avatar-context.ts` +- Create: `packages/core/src/components/avatar/index.ts` +- Create: `packages/core/tests/components/avatar/avatar.test.tsx` +- Modify: `packages/core/tsdown.config.ts` +- Modify: `packages/core/package.json` + +- [ ] **Step 1: Write failing Avatar test** + +Create `packages/core/tests/components/avatar/avatar.test.tsx`: + +```typescript +import { render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Avatar } from "../../../src/components/avatar/index"; +import { AvatarRootPropsSchema, AvatarMeta } from "../../../src/components/avatar/avatar.props"; + +describe("Avatar", () => { + it("renders fallback when no image", () => { + render(() => ( + + MB + + )); + expect(screen.getByTestId("fallback")).toBeTruthy(); + expect(screen.getByText("MB")).toBeTruthy(); + }); + + it("renders image element", () => { + render(() => ( + + + MB + + )); + const img = screen.getByTestId("img"); + expect(img.tagName).toBe("IMG"); + expect(img.getAttribute("src")).toBe("test.jpg"); + expect(img.getAttribute("alt")).toBe("User"); + }); + + it("schema validates src and alt", () => { + expect(AvatarRootPropsSchema.safeParse({}).success).toBe(true); + }); + + it("meta has all required fields", () => { + expect(AvatarMeta.name).toBe("Avatar"); + expect(AvatarMeta.parts).toContain("Root"); + expect(AvatarMeta.parts).toContain("Image"); + expect(AvatarMeta.parts).toContain("Fallback"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/core && pnpm vitest run tests/components/avatar/ +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create avatar.props.ts** + +Create `packages/core/src/components/avatar/avatar.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const AvatarRootPropsSchema = z.object({}); + +export interface AvatarRootProps extends JSX.HTMLAttributes { + children: JSX.Element; +} + +export const AvatarImagePropsSchema = z.object({ + src: z.string().describe("Image URL"), + alt: z.string().describe("Alt text for accessibility"), +}); + +export interface AvatarImageProps + extends z.infer, + Omit, keyof z.infer> {} + +export interface AvatarFallbackProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export const AvatarMeta: ComponentMeta = { + name: "Avatar", + description: "User profile image with fallback to initials or icon when image fails to load", + parts: ["Root", "Image", "Fallback"] as const, + requiredParts: ["Root", "Fallback"] as const, +} as const; +``` + +- [ ] **Step 4: Create avatar-context.ts** + +Create `packages/core/src/components/avatar/avatar-context.ts`: + +```typescript +import { createContext, useContext } from "solid-js"; + +interface AvatarContextValue { + imageLoadingStatus: () => "idle" | "loading" | "loaded" | "error"; + setImageLoadingStatus: (status: "idle" | "loading" | "loaded" | "error") => void; +} + +const AvatarContext = createContext(); + +export function useAvatarContext(): AvatarContextValue { + const context = useContext(AvatarContext); + if (!context) { + throw new Error("[PettyUI] Avatar parts must be used within . Fix: Wrap Avatar.Image and Avatar.Fallback inside ."); + } + return context; +} + +export { AvatarContext }; +``` + +- [ ] **Step 5: Create avatar sub-components** + +Create `packages/core/src/components/avatar/avatar-root.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { createSignal, splitProps } from "solid-js"; +import type { AvatarRootProps } from "./avatar.props"; +import { AvatarContext } from "./avatar-context"; + +export function AvatarRoot(props: AvatarRootProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const [imageLoadingStatus, setImageLoadingStatus] = createSignal<"idle" | "loading" | "loaded" | "error">("idle"); + + return ( + + + {local.children} + + + ); +} +``` + +Create `packages/core/src/components/avatar/avatar-image.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { onMount, splitProps } from "solid-js"; +import type { AvatarImageProps } from "./avatar.props"; +import { useAvatarContext } from "./avatar-context"; + +export function AvatarImage(props: AvatarImageProps): JSX.Element { + const [local, rest] = splitProps(props, ["src", "alt"]); + const context = useAvatarContext(); + + onMount(() => { + context.setImageLoadingStatus("loading"); + const img = new window.Image(); + img.src = local.src; + img.onload = () => context.setImageLoadingStatus("loaded"); + img.onerror = () => context.setImageLoadingStatus("error"); + }); + + return ( + {local.alt} + ); +} +``` + +Create `packages/core/src/components/avatar/avatar-fallback.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import type { AvatarFallbackProps } from "./avatar.props"; +import { useAvatarContext } from "./avatar-context"; + +export function AvatarFallback(props: AvatarFallbackProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const context = useAvatarContext(); + + return ( + + + {local.children} + + + ); +} +``` + +- [ ] **Step 6: Create avatar/index.ts** + +Create `packages/core/src/components/avatar/index.ts`: + +```typescript +import { AvatarRoot } from "./avatar-root"; +import { AvatarImage } from "./avatar-image"; +import { AvatarFallback } from "./avatar-fallback"; + +export const Avatar = Object.assign(AvatarRoot, { + Image: AvatarImage, + Fallback: AvatarFallback, +}); + +export type { AvatarRootProps, AvatarImageProps, AvatarFallbackProps } from "./avatar.props"; +export { AvatarRootPropsSchema, AvatarImagePropsSchema, AvatarMeta } from "./avatar.props"; +``` + +- [ ] **Step 7: Add avatar to tsdown.config.ts and package.json** + +Add `"avatar"` to components array. Add `"./avatar"` export to package.json. + +- [ ] **Step 8: Run tests + build** + +```bash +cd packages/core && pnpm vitest run tests/components/avatar/ && pnpm vitest run && pnpm build +``` + +Expected: All pass. + +- [ ] **Step 9: Commit** + +```bash +git add packages/core/src/components/avatar/ packages/core/tests/components/avatar/ packages/core/tsdown.config.ts packages/core/package.json +git commit -m "feat(avatar): add Avatar component with Zod-first props" +``` + +--- + +### Task 11: Build NavigationMenu Component + +**Files:** +- Create: `packages/core/src/components/navigation-menu/navigation-menu.props.ts` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-root.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-list.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-item.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-content.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-link.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx` +- Create: `packages/core/src/components/navigation-menu/navigation-menu-context.ts` +- Create: `packages/core/src/components/navigation-menu/index.ts` +- Create: `packages/core/tests/components/navigation-menu/navigation-menu.test.tsx` +- Modify: `packages/core/tsdown.config.ts` +- Modify: `packages/core/package.json` + +- [ ] **Step 1: Write failing NavigationMenu test** + +Create `packages/core/tests/components/navigation-menu/navigation-menu.test.tsx`: + +```typescript +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { NavigationMenu } from "../../../src/components/navigation-menu/index"; +import { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "../../../src/components/navigation-menu/navigation-menu.props"; + +describe("NavigationMenu", () => { + it("renders menu with items", () => { + render(() => ( + + + + About + + + + )); + expect(screen.getByText("About")).toBeTruthy(); + }); + + it("renders trigger with dropdown content", () => { + render(() => ( + + + + Products + +
Product A
+
Product B
+
+
+
+
+ )); + expect(screen.getByText("Products")).toBeTruthy(); + }); + + it("has correct nav role", () => { + render(() => ( + + + + Home + + + + )); + const nav = screen.getByTestId("nav"); + expect(nav.tagName).toBe("NAV"); + }); + + it("schema validates orientation", () => { + expect(NavigationMenuRootPropsSchema.safeParse({ orientation: "horizontal" }).success).toBe(true); + expect(NavigationMenuRootPropsSchema.safeParse({ orientation: "invalid" }).success).toBe(false); + }); + + it("meta has required fields", () => { + expect(NavigationMenuMeta.name).toBe("NavigationMenu"); + expect(NavigationMenuMeta.parts).toContain("Root"); + expect(NavigationMenuMeta.parts).toContain("List"); + expect(NavigationMenuMeta.parts).toContain("Item"); + expect(NavigationMenuMeta.parts).toContain("Trigger"); + expect(NavigationMenuMeta.parts).toContain("Content"); + expect(NavigationMenuMeta.parts).toContain("Link"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/core && pnpm vitest run tests/components/navigation-menu/ +``` + +Expected: FAIL. + +- [ ] **Step 3: Create navigation-menu.props.ts** + +Create `packages/core/src/components/navigation-menu/navigation-menu.props.ts`: + +```typescript +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const NavigationMenuRootPropsSchema = z.object({ + value: z.string().optional() + .describe("Controlled active item value (the currently open dropdown)"), + defaultValue: z.string().optional() + .describe("Initial active item (uncontrolled)"), + orientation: z.enum(["horizontal", "vertical"]).optional() + .describe("Menu orientation. Defaults to 'horizontal'"), + delayDuration: z.number().optional() + .describe("Delay in ms before dropdown opens on hover. Defaults to 200"), +}); + +export interface NavigationMenuRootProps + extends z.infer, + Omit, keyof z.infer> { + onValueChange?: (value: string) => void; + children: JSX.Element; +} + +export interface NavigationMenuListProps extends JSX.HTMLAttributes { + children: JSX.Element; +} + +export interface NavigationMenuItemProps extends JSX.HTMLAttributes { + value?: string; + children: JSX.Element; +} + +export interface NavigationMenuTriggerProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +export interface NavigationMenuContentProps extends JSX.HTMLAttributes { + forceMount?: boolean; + children?: JSX.Element; +} + +export interface NavigationMenuLinkProps extends JSX.AnchorHTMLAttributes { + active?: boolean; + children?: JSX.Element; +} + +export interface NavigationMenuViewportProps extends JSX.HTMLAttributes { + forceMount?: boolean; +} + +export const NavigationMenuMeta: ComponentMeta = { + name: "NavigationMenu", + description: "Horizontal navigation bar with dropdown submenus, hover intent, and keyboard support", + parts: ["Root", "List", "Item", "Trigger", "Content", "Link", "Viewport", "Indicator"] as const, + requiredParts: ["Root", "List", "Item"] as const, +} as const; +``` + +- [ ] **Step 4: Create navigation-menu-context.ts** + +Create `packages/core/src/components/navigation-menu/navigation-menu-context.ts`: + +```typescript +import { createContext, useContext } from "solid-js"; +import type { Accessor } from "solid-js"; + +interface NavigationMenuContextValue { + value: Accessor; + setValue: (value: string) => void; + orientation: Accessor<"horizontal" | "vertical">; +} + +const NavigationMenuContext = createContext(); + +export function useNavigationMenuContext(): NavigationMenuContextValue { + const context = useContext(NavigationMenuContext); + if (!context) { + throw new Error("[PettyUI] NavigationMenu parts must be used within . Fix: Wrap items inside ."); + } + return context; +} + +export { NavigationMenuContext }; + +interface NavigationMenuItemContextValue { + value: string; + isActive: Accessor; +} + +const NavigationMenuItemContext = createContext(); + +export function useNavigationMenuItemContext(): NavigationMenuItemContextValue { + const context = useContext(NavigationMenuItemContext); + if (!context) { + throw new Error("[PettyUI] NavigationMenu.Trigger and NavigationMenu.Content must be used within ."); + } + return context; +} + +export { NavigationMenuItemContext }; +``` + +- [ ] **Step 5: Create navigation-menu sub-components** + +Create `packages/core/src/components/navigation-menu/navigation-menu-root.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps, createMemo } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; +import type { NavigationMenuRootProps } from "./navigation-menu.props"; +import { NavigationMenuContext } from "./navigation-menu-context"; + +export function NavigationMenuRoot(props: NavigationMenuRootProps): JSX.Element { + const [local, rest] = splitProps(props, ["value", "defaultValue", "onValueChange", "orientation", "delayDuration", "children"]); + const [value, setValue] = createControllableSignal({ + value: () => local.value, + defaultValue: () => local.defaultValue ?? "", + onChange: local.onValueChange, + }); + + const orientation = createMemo(() => local.orientation ?? "horizontal"); + + return ( + + + + ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-list.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { NavigationMenuListProps } from "./navigation-menu.props"; + +export function NavigationMenuList(props: NavigationMenuListProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
    + {local.children} +
+ ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-item.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps, createMemo, createUniqueId } from "solid-js"; +import type { NavigationMenuItemProps } from "./navigation-menu.props"; +import { useNavigationMenuContext, NavigationMenuItemContext } from "./navigation-menu-context"; + +export function NavigationMenuItem(props: NavigationMenuItemProps): JSX.Element { + const [local, rest] = splitProps(props, ["value", "children"]); + const context = useNavigationMenuContext(); + const itemValue = local.value ?? createUniqueId(); + const isActive = createMemo(() => context.value() === itemValue); + + return ( + +
  • + {local.children} +
  • +
    + ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { NavigationMenuTriggerProps } from "./navigation-menu.props"; +import { useNavigationMenuContext, useNavigationMenuItemContext } from "./navigation-menu-context"; + +export function NavigationMenuTrigger(props: NavigationMenuTriggerProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const menuContext = useNavigationMenuContext(); + const itemContext = useNavigationMenuItemContext(); + + const handleClick: JSX.EventHandlerUnion = () => { + menuContext.setValue(itemContext.isActive() ? "" : itemContext.value); + }; + + const handlePointerEnter = () => { + menuContext.setValue(itemContext.value); + }; + + const handlePointerLeave = () => { + menuContext.setValue(""); + }; + + return ( + + ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-content.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import type { NavigationMenuContentProps } from "./navigation-menu.props"; +import { useNavigationMenuItemContext } from "./navigation-menu-context"; + +export function NavigationMenuContent(props: NavigationMenuContentProps): JSX.Element { + const [local, rest] = splitProps(props, ["forceMount", "children"]); + const itemContext = useNavigationMenuItemContext(); + + return ( + +
    {}} // keep open while hovering content + {...rest} + > + {local.children} +
    +
    + ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-link.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { NavigationMenuLinkProps } from "./navigation-menu.props"; + +export function NavigationMenuLink(props: NavigationMenuLinkProps): JSX.Element { + const [local, rest] = splitProps(props, ["active", "children"]); + return ( + + {local.children} + + ); +} +``` + +Create `packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx`: + +```typescript +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { NavigationMenuViewportProps } from "./navigation-menu.props"; + +export function NavigationMenuViewport(props: NavigationMenuViewportProps): JSX.Element { + const [_, rest] = splitProps(props, ["forceMount"]); + return
    ; +} +``` + +- [ ] **Step 6: Create navigation-menu/index.ts** + +Create `packages/core/src/components/navigation-menu/index.ts`: + +```typescript +import { NavigationMenuRoot } from "./navigation-menu-root"; +import { NavigationMenuList } from "./navigation-menu-list"; +import { NavigationMenuItem } from "./navigation-menu-item"; +import { NavigationMenuTrigger } from "./navigation-menu-trigger"; +import { NavigationMenuContent } from "./navigation-menu-content"; +import { NavigationMenuLink } from "./navigation-menu-link"; +import { NavigationMenuViewport } from "./navigation-menu-viewport"; + +export const NavigationMenu = Object.assign(NavigationMenuRoot, { + List: NavigationMenuList, + Item: NavigationMenuItem, + Trigger: NavigationMenuTrigger, + Content: NavigationMenuContent, + Link: NavigationMenuLink, + Viewport: NavigationMenuViewport, +}); + +export type { + NavigationMenuRootProps, + NavigationMenuListProps, + NavigationMenuItemProps, + NavigationMenuTriggerProps, + NavigationMenuContentProps, + NavigationMenuLinkProps, + NavigationMenuViewportProps, +} from "./navigation-menu.props"; +export { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "./navigation-menu.props"; +``` + +- [ ] **Step 7: Add to tsdown.config.ts and package.json** + +Add `"navigation-menu"` to components array. Add `"./navigation-menu"` export to package.json. + +- [ ] **Step 8: Run tests + build** + +```bash +cd packages/core && pnpm vitest run tests/components/navigation-menu/ && pnpm vitest run && pnpm build +``` + +Expected: All pass. + +- [ ] **Step 9: Commit** + +```bash +git add packages/core/src/components/navigation-menu/ packages/core/tests/components/navigation-menu/ packages/core/tsdown.config.ts packages/core/package.json +git commit -m "feat(navigation-menu): add NavigationMenu with Zod-first props, hover intent, keyboard nav" +``` + +--- + +### Task 12: Registry Package Scaffolding + +**Files:** +- Create: `packages/registry/package.json` +- Create: `packages/registry/tsconfig.json` +- Create: `packages/registry/src/utils.ts` +- Create: `packages/registry/src/tokens.css` +- Create: `packages/registry/src/components/dialog.tsx` +- Create: `packages/registry/tests/utils.test.ts` + +- [ ] **Step 1: Create registry package.json** + +Create `packages/registry/package.json`: + +```json +{ + "name": "pettyui-registry", + "version": "0.1.0", + "private": true, + "description": "PettyUI styled component registry — shadcn model for SolidJS", + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "pettyui": "workspace:*" + } +} +``` + +- [ ] **Step 2: Create registry tsconfig.json** + +Create `packages/registry/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create cn utility** + +Create `packages/registry/src/utils.ts`: + +```typescript +/** + * Conditionally join class names. Minimal implementation for registry components. + * Consumers can replace with clsx/tailwind-merge if needed. + */ +export function cn(...inputs: (string | undefined | null | false)[]): string { + return inputs.filter(Boolean).join(" "); +} +``` + +- [ ] **Step 4: Create theme tokens CSS** + +Create `packages/registry/src/tokens.css`: + +```css +:root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; +} + +.dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; +} +``` + +- [ ] **Step 5: Create first styled registry component — Dialog** + +Create `packages/registry/src/components/dialog.tsx`: + +```typescript +import { Dialog as DialogPrimitive } from "pettyui/dialog"; +import { cn } from "@/utils"; +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +const Dialog = DialogPrimitive; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogClose = DialogPrimitive.Close; +const DialogPortal = DialogPrimitive.Portal; +const DialogTitle = DialogPrimitive.Title; +const DialogDescription = DialogPrimitive.Description; + +function DialogOverlay(props: JSX.HTMLAttributes): JSX.Element { + const [local, rest] = splitProps(props, ["class"]); + return ( + + ); +} + +function DialogContent(props: JSX.HTMLAttributes & { children?: JSX.Element }): JSX.Element { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + + + {local.children} + + + ); +} + +export { Dialog, DialogTrigger, DialogContent, DialogOverlay, DialogPortal, DialogClose, DialogTitle, DialogDescription }; +``` + +- [ ] **Step 6: Write cn utility test** + +Create `packages/registry/tests/utils.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { cn } from "../src/utils"; + +describe("cn utility", () => { + it("joins class names", () => { + expect(cn("foo", "bar")).toBe("foo bar"); + }); + + it("filters falsy values", () => { + expect(cn("foo", undefined, null, false, "bar")).toBe("foo bar"); + }); + + it("returns empty string for no classes", () => { + expect(cn()).toBe(""); + }); +}); +``` + +- [ ] **Step 7: Install dependencies and run tests** + +```bash +cd packages/registry && pnpm install && pnpm test +``` + +Expected: PASS — 3 cn tests pass. + +- [ ] **Step 8: Run full workspace build to verify integration** + +```bash +cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm build +``` + +Expected: Both packages build cleanly. + +- [ ] **Step 9: Commit** + +```bash +git add packages/registry/ +git commit -m "feat(registry): scaffold registry package with tokens, cn utility, and Dialog styled component" +``` + +--- + +## Verification + +After all tasks complete: + +```bash +# Full test suite +cd packages/core && pnpm vitest run + +# Build +pnpm build + +# Verify exports count (should be 31 component + 6 utility = 37 entries) +node -e "const pkg = require('./packages/core/package.json'); console.log(Object.keys(pkg.exports).length)" +``` + +**Expected state after Phase 1:** +- 31 component directories (28 migrated - 3 cut + 3 new + 3 kept-internal) +- 31 standalone component exports (29 existing + Card + Avatar + NavigationMenu - Separator - Pagination) +- 6 utility exports (unchanged) +- Every exported component has a Zod schema + Meta object +- Registry package exists with tokens, cn, and one styled component (Dialog) +- All tests pass, build succeeds diff --git a/docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md b/docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md new file mode 100644 index 0000000..ce0d0bb --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md @@ -0,0 +1,323 @@ +# PettyUI AI-First Architecture Design + +**Date:** 2026-03-29 +**Status:** Approved +**Supersedes:** All prior specs and plans in this directory + +## Vision + +PettyUI is the first component library where **the AI is the intended user, not an afterthought**. The primary consumer is LLMs generating code. Human developers benefit as a side effect of good AI ergonomics. Distribution follows the proven Radix → shadcn two-layer model: headless core + copy-paste styled registry. + +## Package Structure + +``` +packages/ + core/ → Headless primitives with Zod-first props + meta + mcp/ → MCP server: discovery, validation, code generation + registry/ → Styled copy-paste components (shadcn layer for Solid) +``` + +No separate `schemas/` package. The Zod schema lives WITH the component because it IS the component's type system. + +## Component Inventory + +Organized by **AI task** — what the agent is trying to build — not UI taxonomy. + +### BUILD A FORM (AI's #1 task) + +| Component | Status | Notes | +|-----------|--------|-------| +| TextField | Done | | +| Checkbox | Done | | +| Switch | Done | | +| RadioGroup | Done | | +| Select | Done | | +| Combobox | Done | | +| Slider | Done | | +| NumberField | Done | | +| ToggleGroup | Done | | +| **Form** | New | Validation integration (Zod v4), field grouping, error display, aria-describedby linking | +| **DatePicker** | New | Calendar + input, range selection, locale-aware | +| **Calendar** | New | Standalone month/year grid, keyboard nav | + +### BUILD NAVIGATION + +| Component | Status | Notes | +|-----------|--------|-------| +| Tabs | Done | | +| Breadcrumbs | Done | | +| Link | Done | | +| DropdownMenu | Done | | +| **CommandPalette** | New | cmdk pattern — search, keyboard nav, grouped actions | +| **NavigationMenu** | New | Horizontal nav with dropdown submenus, hover intent | + +### BUILD AN OVERLAY + +| Component | Status | Notes | +|-----------|--------|-------| +| Dialog | Done | | +| AlertDialog | Done | | +| Drawer | Done | | +| Popover | Done | | +| Tooltip | Done | | +| HoverCard | Done | | +| Toast | Done | | + +### BUILD A DASHBOARD / DATA VIEW + +| Component | Status | Notes | +|-----------|--------|-------| +| Progress | Done | | +| Badge | Done | | +| Skeleton | Done | | +| Alert | Done | | +| **DataTable** | New | Sorting, filtering, pagination, column resize, row selection | +| **VirtualList** | New | Virtualized rendering for large datasets, variable row heights | +| **Avatar** | New | Image + fallback initials, status indicator | + +### BUILD LAYOUT & STRUCTURE + +| Component | Status | Notes | +|-----------|--------|-------| +| Accordion | Done | | +| Collapsible | Done | | +| **Card** | New | Header/Content/Footer compound, token contract | +| **Wizard/Stepper** | New | Multi-step flows, step validation, linear/non-linear | + +### BUILD INTERACTIONS + +| Component | Status | Notes | +|-----------|--------|-------| +| Button | Done | | +| Toggle | Done | | + +### CUT (remove from exports, keep internally if needed) + +| Component | Reason | +|-----------|--------| +| ContextMenu | Niche desktop pattern | +| Image | `` covers it | +| Meter | Nobody uses it vs Progress | +| Separator | Keep as menu sub-component only, drop standalone export | +| Pagination | Keep as DataTable internal, drop standalone export | +| ColorPicker suite | Design-tool-only, <5% of projects | +| Menubar | Desktop OS pattern | +| TimeField | Extremely specialized | +| Rating | Trivial, single-purpose | +| FileField | Browser native + dropzone libs | +| SegmentedControl | ToggleGroup covers this | + +### Totals + +| Category | Done | Cut | New | Final Total | +|----------|------|-----|-----|-------------| +| Components | 28 | -3 | +10 | 35 | +| Standalone exports | 28 | -5 | +10 | 33 | +| Primitives | 5 | 0 | +1 (createVirtualizer) | 6 | +| Utilities | 6 | 0 | 0 | 6 | + +## Schema Architecture: Zod-First, Single Source of Truth + +Every component's props are defined AS Zod schemas. TypeScript types are derived FROM the schemas. No duplication, no drift. + +### Per-Component Pattern + +```typescript +// components/dialog/dialog.props.ts +import { z } from "zod/v4"; + +export const DialogRootProps = z.object({ + open: z.boolean().optional() + .describe("Controlled open state"), + defaultOpen: z.boolean().optional() + .describe("Initial open state (uncontrolled)"), + onOpenChange: z.function().args(z.boolean()).returns(z.void()).optional() + .describe("Called when open state changes"), + modal: z.boolean().default(true) + .describe("Whether to trap focus and add backdrop"), +}); + +// TypeScript types DERIVED from schema +export type DialogRootProps = z.infer; + +// Metadata — just enough for MCP discovery +export const DialogMeta = { + name: "Dialog", + description: "Modal overlay requiring user acknowledgment", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"] as const, + requiredParts: ["Root", "Content", "Title"] as const, +} as const; +``` + +### What This Enables + +| AI asks... | Schema provides... | +|---|---| +| "What components can build a form?" | `.describe()` strings + MCP semantic search | +| "What props does Select accept?" | Full typed contract with descriptions | +| "Is this Dialog usage valid?" | Runtime validation against schema | +| "Which parts are required for a11y?" | `requiredParts` in meta | + +## MCP Server Architecture + +The MCP server is the AI's interface to PettyUI. It reads Zod schemas and meta directly from `core/`. + +### Tools + +``` +pettyui.discover → "I need to collect user input" → returns matching components with schemas +pettyui.inspect → "Tell me about Dialog" → returns full props schema + parts + required +pettyui.validate → "Is this Dialog usage correct?" → validates JSX/props against schema +pettyui.add → "Add Dialog to my project" → copies styled component from registry +pettyui.compose → "Build a settings form" → returns composed multi-component JSX +``` + +### pettyui.discover + +``` +Input: { intent: "I need a searchable dropdown" } +Output: [ + { name: "Combobox", match: 0.95, description: "..." }, + { name: "CommandPalette", match: 0.72, description: "..." }, + { name: "Select", match: 0.6, description: "..." } +] +``` + +Uses `.describe()` strings for semantic matching. No rigid taxonomy — descriptions ARE the search index. + +### pettyui.inspect + +``` +Input: { component: "Dialog" } +Output: { + props: { /* Zod schema as JSON Schema */ }, + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"], + requiredParts: ["Root", "Content", "Title"], + example: "/* minimal valid usage */", + source: "/* full component source if requested */" +} +``` + +### pettyui.validate + +``` +Input: { component: "Dialog", jsx: "hello" } +Output: { + valid: false, + errors: ["Missing required part: Title. Dialog.Content must contain Dialog.Title for accessibility"], + fix: "...hello" +} +``` + +### pettyui.add + +``` +Input: { component: "Dialog", style: "default" } +Output: { + files: [{ path: "src/components/ui/dialog.tsx", content: "/* styled component */" }], + dependencies: ["pettyui/dialog"] +} +``` + +### pettyui.compose + +``` +Input: { intent: "login form with email and password", components: ["Form", "TextField", "Button"] } +Output: { jsx: "/* complete composed component */", imports: ["pettyui/form", "pettyui/text-field", "pettyui/button"] } +``` + +### MCP Server Does NOT Do + +- No styling opinions — registry's job +- No state management — components handle their own +- No routing — out of scope +- No build tooling — tsdown/vite + +## Registry Layer (shadcn Model for Solid) + +### What Gets Copied + +`pettyui add dialog` creates `src/components/ui/dialog.tsx`: + +```typescript +import { Dialog as DialogPrimitive } from "pettyui/dialog"; +import { cn } from "@/lib/utils"; + +const DialogContent = (props) => ( + + + + {props.children} + + +); + +export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogDescription, DialogClose }; +``` + +### Registry Principles + +1. **Tailwind + CSS variables** — styles co-located, tokens via `--` variables +2. **Imports headless from core** — registry depends on `pettyui/*`, not copy-pasted behavior +3. **Pre-composed for 90% case** — DialogContent includes Portal + Overlay automatically +4. **Fully editable** — your file after copy +5. **One file per component** — AI reads one file, gets everything + +### Theme Contract + +```css +:root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --muted: 0 0% 96.1%; + --border: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; +} +``` + +### CLI + +```bash +pettyui init # Sets up tokens, utils, tsconfig paths +pettyui add dialog # Copies styled dialog component +pettyui add form # Copies styled form with Zod integration +pettyui diff dialog # Shows what you've changed vs upstream +``` + +## Build Order + +``` +Phase 1: Foundation Phase 2: Advanced Phase 3: AI Layer +───────────────── ────────────────── ───────────────── +Zod-first props refactor DataTable MCP server + (migrate 28 components) CommandPalette pettyui.discover +Meta objects on all DatePicker + Calendar pettyui.inspect + components Wizard/Stepper pettyui.validate +Card + Avatar (simple) Form system pettyui.add +NavigationMenu VirtualList + primitive pettyui.compose +Cut ContextMenu/Image/ + Meter/Separator/Pagination + +Registry scaffolding Registry: styled versions CLI wrapper + (init, tokens, utils) of all components pettyui init/add/diff +``` + +Phase 1 is mostly refactoring what exists. Phase 2 is the hard new work. Phase 3 is where PettyUI becomes unique. + +## Key Design Principles + +1. **AI is the primary user** — every API decision optimizes for LLM code generation +2. **Zod-first** — schemas ARE the type system, not a parallel description +3. **Sub-path exports** — `pettyui/dialog` not `pettyui` barrel. Prevents hallucination +4. **Compound components** — `Dialog.Root` + `Dialog.Content` over prop explosion +5. **Sensible defaults** — components work with zero props, accept controlled props when needed +6. **Union types over booleans** — `variant: 'primary' | 'secondary'` not `isPrimary?: boolean` +7. **Consistent naming** — same patterns everywhere, reduces AI search space +8. **CSS variables for theming** — AI handles CSS vars naturally vs JS theme providers +9. **`.describe()` on every prop** — this IS the documentation +10. **Runtime validation feedback** — AI generates, validates, fixes. Feedback loop