PettyUI/docs/superpowers/plans/2026-03-29-phase1-zod-first-foundation.md
Mats Bosson 8dc5ab32ce 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 20:28:59 +07:00

102 KiB

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<Schema> 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:

// {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<typeof {Name}RootPropsSchema> {
  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

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:

/**
 * 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:

external: ["solid-js", "solid-js/web", "solid-js/store", "zod", "zod/v4"],
  • Step 4: Verify build still works
cd packages/core && pnpm build

Expected: Build succeeds with no errors.

  • Step 5: Commit
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:

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
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:

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<typeof DialogRootPropsSchema> {
  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<typeof DialogContentPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof DialogContentPropsSchema>> {
  children?: JSX.Element;
}

// --- Trigger ---
export interface DialogTriggerProps
  extends Omit<JSX.HTMLAttributes<HTMLButtonElement>, "children"> {
  children?: JSX.Element | ((props: JSX.HTMLAttributes<HTMLElement>) => JSX.Element);
}

// --- Title ---
export interface DialogTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {}

// --- Description ---
export interface DialogDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {}

// --- Close ---
export interface DialogCloseProps extends JSX.HTMLAttributes<HTMLButtonElement> {}

// --- Overlay ---
export interface DialogOverlayProps extends JSX.HTMLAttributes<HTMLDivElement> {}

// --- 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
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:

export interface DialogRootProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  modal?: boolean;
  children: JSX.Element;
}

With:

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:

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:

export { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "./dialog.props";
  • Step 9: Run ALL existing Dialog tests to verify nothing broke
cd packages/core && pnpm vitest run tests/components/dialog/

Expected: ALL existing tests pass + the new schema test passes.

  • Step 10: Commit
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:

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<typeof ButtonPropsSchema>,
    Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, keyof z.infer<typeof ButtonPropsSchema>> {
  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:

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:

export { ButtonPropsSchema, ButtonMeta } from "./button.props";
  • Step 4: Create badge.props.ts

Create packages/core/src/components/badge/badge.props.ts:

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<HTMLSpanElement> {
  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:

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<HTMLDivElement> {
  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:

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<HTMLDivElement> {
  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:

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<typeof LinkPropsSchema>,
    Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, keyof z.infer<typeof LinkPropsSchema>> {
  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:

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<typeof TogglePropsSchema>,
    Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, keyof z.infer<typeof TogglePropsSchema>> {
  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:

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<typeof ProgressRootPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof ProgressRootPropsSchema>> {
  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:

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
cd packages/core && pnpm vitest run

Expected: ALL tests pass — both new schema tests and all existing component tests.

  • Step 18: Commit
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:

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<typeof TextFieldRootPropsSchema> {
  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:

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<typeof CheckboxRootPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof CheckboxRootPropsSchema>> {
  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:

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<typeof SwitchRootPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof SwitchRootPropsSchema>> {
  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:

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<typeof RadioGroupRootPropsSchema> {
  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<typeof RadioGroupItemPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof RadioGroupItemPropsSchema>> {
  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:

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<typeof SliderRootPropsSchema> {
  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:

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<typeof NumberFieldRootPropsSchema> {
  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:

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<typeof ToggleGroupRootPropsSchema> {
  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<typeof ToggleGroupItemPropsSchema>,
    Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, keyof z.infer<typeof ToggleGroupItemPropsSchema>> {
  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:

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
cd packages/core && pnpm vitest run

Expected: ALL tests pass.

  • Step 17: Commit
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:

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<typeof AccordionRootPropsSchema> {
  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<typeof AccordionItemPropsSchema> {
  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:

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<typeof CollapsibleRootPropsSchema> {
  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:

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<typeof AlertDialogRootPropsSchema> {
  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:

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<typeof BreadcrumbsRootPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLElement>, keyof z.infer<typeof BreadcrumbsRootPropsSchema>> {
  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:

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<typeof TabsRootPropsSchema> {
  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<typeof TabsTriggerPropsSchema>,
    Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, keyof z.infer<typeof TabsTriggerPropsSchema>> {
  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<typeof TabsContentPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof TabsContentPropsSchema>> {
  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:

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
cd packages/core && pnpm vitest run

Expected: ALL tests pass.

  • Step 13: Commit
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:

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<typeof TooltipRootPropsSchema> {
  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:

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<typeof PopoverRootPropsSchema> {
  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:

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<typeof HoverCardRootPropsSchema> {
  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:

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<typeof DrawerRootPropsSchema> {
  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:

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<typeof ToastRegionPropsSchema> {
  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:

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
cd packages/core && pnpm vitest run

Expected: ALL tests pass.

  • Step 13: Commit
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:

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<typeof SelectRootPropsSchema> {
  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<typeof SelectItemPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof SelectItemPropsSchema>> {
  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:

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<typeof ComboboxRootPropsSchema> {
  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:

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<typeof DropdownMenuRootPropsSchema> {
  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<typeof DropdownMenuItemPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLDivElement>, keyof z.infer<typeof DropdownMenuItemPropsSchema>> {
  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:

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<typeof ListboxRootPropsSchema> {
  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:

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<typeof SeparatorPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLHRElement>, keyof z.infer<typeof SeparatorPropsSchema>> {}

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:

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<typeof PaginationRootPropsSchema> {
  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:

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
cd packages/core && pnpm vitest run

Expected: ALL tests pass.

  • Step 13: Commit
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
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
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
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:

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(() => (
      <Card>
        <Card.Header>
          <Card.Title>Title</Card.Title>
          <Card.Description>Description</Card.Description>
        </Card.Header>
        <Card.Content>Body content</Card.Content>
        <Card.Footer>Footer</Card.Footer>
      </Card>
    ));
    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(() => (
      <Card data-testid="card">
        <Card.Content>Content</Card.Content>
      </Card>
    ));
    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
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:

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<HTMLDivElement> {
  children?: JSX.Element;
}

export interface CardHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> {
  children?: JSX.Element;
}

export interface CardContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
  children?: JSX.Element;
}

export interface CardFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
  children?: JSX.Element;
}

export interface CardTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
  children?: JSX.Element;
}

export interface CardDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
  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:

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 <div data-scope="card" data-part="root" {...rest}>{local.children}</div>;
}

Create packages/core/src/components/card/card-header.tsx:

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 <div data-scope="card" data-part="header" {...rest}>{local.children}</div>;
}

Create packages/core/src/components/card/card-content.tsx:

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 <div data-scope="card" data-part="content" {...rest}>{local.children}</div>;
}

Create packages/core/src/components/card/card-footer.tsx:

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 <div data-scope="card" data-part="footer" {...rest}>{local.children}</div>;
}

Create packages/core/src/components/card/card-title.tsx:

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 <h3 data-scope="card" data-part="title" {...rest}>{local.children}</h3>;
}

Create packages/core/src/components/card/card-description.tsx:

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 <p data-scope="card" data-part="description" {...rest}>{local.children}</p>;
}
  • Step 5: Create card/index.ts with compound export

Create packages/core/src/components/card/index.ts:

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:

"./card": {
  "solid": "./src/components/card/index.ts",
  "import": "./dist/card/index.js",
  "require": "./dist/card/index.cjs"
}
  • Step 7: Run tests
cd packages/core && pnpm vitest run tests/components/card/

Expected: PASS — all 4 tests.

  • Step 8: Run full test suite + build
cd packages/core && pnpm vitest run && pnpm build

Expected: All tests pass, build succeeds.

  • Step 9: Commit
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:

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(() => (
      <Avatar>
        <Avatar.Fallback data-testid="fallback">MB</Avatar.Fallback>
      </Avatar>
    ));
    expect(screen.getByTestId("fallback")).toBeTruthy();
    expect(screen.getByText("MB")).toBeTruthy();
  });

  it("renders image element", () => {
    render(() => (
      <Avatar>
        <Avatar.Image src="test.jpg" alt="User" data-testid="img" />
        <Avatar.Fallback>MB</Avatar.Fallback>
      </Avatar>
    ));
    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
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:

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<HTMLSpanElement> {
  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<typeof AvatarImagePropsSchema>,
    Omit<JSX.ImgHTMLAttributes<HTMLImageElement>, keyof z.infer<typeof AvatarImagePropsSchema>> {}

export interface AvatarFallbackProps extends JSX.HTMLAttributes<HTMLSpanElement> {
  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:

import { createContext, useContext } from "solid-js";

interface AvatarContextValue {
  imageLoadingStatus: () => "idle" | "loading" | "loaded" | "error";
  setImageLoadingStatus: (status: "idle" | "loading" | "loaded" | "error") => void;
}

const AvatarContext = createContext<AvatarContextValue>();

export function useAvatarContext(): AvatarContextValue {
  const context = useContext(AvatarContext);
  if (!context) {
    throw new Error("[PettyUI] Avatar parts must be used within <Avatar>. Fix: Wrap Avatar.Image and Avatar.Fallback inside <Avatar>.");
  }
  return context;
}

export { AvatarContext };
  • Step 5: Create avatar sub-components

Create packages/core/src/components/avatar/avatar-root.tsx:

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 (
    <AvatarContext.Provider value={{ imageLoadingStatus, setImageLoadingStatus }}>
      <span data-scope="avatar" data-part="root" {...rest}>
        {local.children}
      </span>
    </AvatarContext.Provider>
  );
}

Create packages/core/src/components/avatar/avatar-image.tsx:

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 (
    <img
      data-scope="avatar"
      data-part="image"
      src={local.src}
      alt={local.alt}
      {...rest}
    />
  );
}

Create packages/core/src/components/avatar/avatar-fallback.tsx:

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 (
    <Show when={context.imageLoadingStatus() !== "loaded"}>
      <span data-scope="avatar" data-part="fallback" {...rest}>
        {local.children}
      </span>
    </Show>
  );
}
  • Step 6: Create avatar/index.ts

Create packages/core/src/components/avatar/index.ts:

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
cd packages/core && pnpm vitest run tests/components/avatar/ && pnpm vitest run && pnpm build

Expected: All pass.

  • Step 9: Commit
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:

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(() => (
      <NavigationMenu>
        <NavigationMenu.List>
          <NavigationMenu.Item>
            <NavigationMenu.Link href="/about">About</NavigationMenu.Link>
          </NavigationMenu.Item>
        </NavigationMenu.List>
      </NavigationMenu>
    ));
    expect(screen.getByText("About")).toBeTruthy();
  });

  it("renders trigger with dropdown content", () => {
    render(() => (
      <NavigationMenu>
        <NavigationMenu.List>
          <NavigationMenu.Item>
            <NavigationMenu.Trigger>Products</NavigationMenu.Trigger>
            <NavigationMenu.Content>
              <div>Product A</div>
              <div>Product B</div>
            </NavigationMenu.Content>
          </NavigationMenu.Item>
        </NavigationMenu.List>
      </NavigationMenu>
    ));
    expect(screen.getByText("Products")).toBeTruthy();
  });

  it("has correct nav role", () => {
    render(() => (
      <NavigationMenu data-testid="nav">
        <NavigationMenu.List>
          <NavigationMenu.Item>
            <NavigationMenu.Link href="/">Home</NavigationMenu.Link>
          </NavigationMenu.Item>
        </NavigationMenu.List>
      </NavigationMenu>
    ));
    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
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:

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<typeof NavigationMenuRootPropsSchema>,
    Omit<JSX.HTMLAttributes<HTMLElement>, keyof z.infer<typeof NavigationMenuRootPropsSchema>> {
  onValueChange?: (value: string) => void;
  children: JSX.Element;
}

export interface NavigationMenuListProps extends JSX.HTMLAttributes<HTMLUListElement> {
  children: JSX.Element;
}

export interface NavigationMenuItemProps extends JSX.HTMLAttributes<HTMLLIElement> {
  value?: string;
  children: JSX.Element;
}

export interface NavigationMenuTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
  children?: JSX.Element;
}

export interface NavigationMenuContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
  forceMount?: boolean;
  children?: JSX.Element;
}

export interface NavigationMenuLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
  active?: boolean;
  children?: JSX.Element;
}

export interface NavigationMenuViewportProps extends JSX.HTMLAttributes<HTMLDivElement> {
  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:

import { createContext, useContext } from "solid-js";
import type { Accessor } from "solid-js";

interface NavigationMenuContextValue {
  value: Accessor<string>;
  setValue: (value: string) => void;
  orientation: Accessor<"horizontal" | "vertical">;
}

const NavigationMenuContext = createContext<NavigationMenuContextValue>();

export function useNavigationMenuContext(): NavigationMenuContextValue {
  const context = useContext(NavigationMenuContext);
  if (!context) {
    throw new Error("[PettyUI] NavigationMenu parts must be used within <NavigationMenu>. Fix: Wrap items inside <NavigationMenu>.");
  }
  return context;
}

export { NavigationMenuContext };

interface NavigationMenuItemContextValue {
  value: string;
  isActive: Accessor<boolean>;
}

const NavigationMenuItemContext = createContext<NavigationMenuItemContextValue>();

export function useNavigationMenuItemContext(): NavigationMenuItemContextValue {
  const context = useContext(NavigationMenuItemContext);
  if (!context) {
    throw new Error("[PettyUI] NavigationMenu.Trigger and NavigationMenu.Content must be used within <NavigationMenu.Item>.");
  }
  return context;
}

export { NavigationMenuItemContext };
  • Step 5: Create navigation-menu sub-components

Create packages/core/src/components/navigation-menu/navigation-menu-root.tsx:

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 (
    <NavigationMenuContext.Provider value={{ value, setValue, orientation }}>
      <nav
        data-scope="navigation-menu"
        data-part="root"
        data-orientation={orientation()}
        {...rest}
      >
        {local.children}
      </nav>
    </NavigationMenuContext.Provider>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-list.tsx:

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 (
    <ul data-scope="navigation-menu" data-part="list" role="menubar" {...rest}>
      {local.children}
    </ul>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-item.tsx:

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 (
    <NavigationMenuItemContext.Provider value={{ value: itemValue, isActive }}>
      <li data-scope="navigation-menu" data-part="item" role="none" {...rest}>
        {local.children}
      </li>
    </NavigationMenuItemContext.Provider>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx:

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<HTMLButtonElement, MouseEvent> = () => {
    menuContext.setValue(itemContext.isActive() ? "" : itemContext.value);
  };

  const handlePointerEnter = () => {
    menuContext.setValue(itemContext.value);
  };

  const handlePointerLeave = () => {
    menuContext.setValue("");
  };

  return (
    <button
      data-scope="navigation-menu"
      data-part="trigger"
      data-state={itemContext.isActive() ? "open" : "closed"}
      type="button"
      role="menuitem"
      aria-expanded={itemContext.isActive()}
      onClick={handleClick}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      {...rest}
    >
      {local.children}
    </button>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-content.tsx:

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 (
    <Show when={local.forceMount || itemContext.isActive()}>
      <div
        data-scope="navigation-menu"
        data-part="content"
        data-state={itemContext.isActive() ? "open" : "closed"}
        role="menu"
        onPointerEnter={() => {}} // keep open while hovering content
        {...rest}
      >
        {local.children}
      </div>
    </Show>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-link.tsx:

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 (
    <a
      data-scope="navigation-menu"
      data-part="link"
      data-active={local.active || undefined}
      role="menuitem"
      {...rest}
    >
      {local.children}
    </a>
  );
}

Create packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx:

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 <div data-scope="navigation-menu" data-part="viewport" {...rest} />;
}
  • Step 6: Create navigation-menu/index.ts

Create packages/core/src/components/navigation-menu/index.ts:

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
cd packages/core && pnpm vitest run tests/components/navigation-menu/ && pnpm vitest run && pnpm build

Expected: All pass.

  • Step 9: Commit
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:

{
  "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:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}
  • Step 3: Create cn utility

Create packages/registry/src/utils.ts:

/**
 * 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:

: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:

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<HTMLDivElement>): JSX.Element {
  const [local, rest] = splitProps(props, ["class"]);
  return (
    <DialogPrimitive.Overlay
      class={cn(
        "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
        local.class,
      )}
      {...rest}
    />
  );
}

function DialogContent(props: JSX.HTMLAttributes<HTMLDivElement> & { children?: JSX.Element }): JSX.Element {
  const [local, rest] = splitProps(props, ["class", "children"]);
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Content
        class={cn(
          "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-hsl(var(--border)) bg-hsl(var(--background)) p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
          local.class,
        )}
        {...rest}
      >
        {local.children}
      </DialogPrimitive.Content>
    </DialogPortal>
  );
}

export { Dialog, DialogTrigger, DialogContent, DialogOverlay, DialogPortal, DialogClose, DialogTitle, DialogDescription };
  • Step 6: Write cn utility test

Create packages/registry/tests/utils.test.ts:

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
cd packages/registry && pnpm install && pnpm test

Expected: PASS — 3 cn tests pass.

  • Step 8: Run full workspace build to verify integration
cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm build

Expected: Both packages build cleanly.

  • Step 9: Commit
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:

# 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