PettyUI/docs/superpowers/specs/2026-03-28-pettyui-design.md
Mats Bosson db906fd85a Fix linting config and package fields
- Replace .eslintrc.cjs with eslint.config.mjs (ESLint 9 flat config)
  using direct eslint-plugin-solid + @typescript-eslint/parser approach
- Add @typescript-eslint/parser to root devDependencies
- Add main/module/types top-level fields to packages/core/package.json
- Add resolve.conditions to packages/core/vite.config.ts
- Create packages/core/tsconfig.test.json for test type-checking
- Remove empty paths:{} from packages/core/tsconfig.json
2026-03-29 02:35:57 +07:00

35 KiB

PettyUI Design Specification

Date: 2026-03-28 Status: Draft Author: Mats Bosson + Claude

Vision

PettyUI is a pure headless, AI-native UI component library for SolidJS. It provides zero-opinion styling, bulletproof accessibility, and an API designed primarily for AI coding agents to generate correctly — with humans as the secondary audience.

The north star: the easiest component library for AI to use, and the most pleasant for humans when they need to intervene.


Core Principles

  1. AI-first, human-friendly — Every API decision optimizes for LLM code generation correctness. Consistent patterns, explicit types, machine-readable docs, and no implicit contracts.
  2. Pure headless — Zero styling, zero theme, zero CSS. Components provide behavior, accessibility, and state. Users own all visual decisions.
  3. Progressive disclosure — Simple props API for the 90% case. Compound component API for full control. Hook-level escape hatches for the 2%.
  4. Solid-native — Built from scratch on SolidJS signals and context. No framework translation layers, no state machines, no abstraction overhead.
  5. SSR-correct from day one — Every component works with SolidStart SSR. Deterministic IDs, no hydration mismatches, proper portal handling.
  6. Single-purpose components — No overloaded behavior via boolean flags. Select and MultiSelect are separate components. AI never guesses modes.
  7. Pit of success — Wrong usage is hard. Right usage is obvious. Errors are specific, actionable, and include fix instructions.

Architecture

Package Structure

Single npm package with sub-path exports. Monorepo internally, single pettyui package externally.

pettyui/
├── src/
│   ├── primitives/              # Internal shared building blocks (NOT exported)
│   │   ├── create-controllable-signal.ts
│   │   ├── create-disclosure-state.ts
│   │   ├── create-register-id.ts
│   │   ├── create-collection.ts
│   │   ├── create-list-navigation.ts
│   │   ├── create-typeahead.ts
│   │   └── create-form-control.ts
│   │
│   ├── utilities/               # Standalone exported utilities
│   │   ├── presence/            # <Presence> — keeps exiting elements mounted during animation
│   │   ├── dismiss/             # Escape key, click-outside, focus-outside handling
│   │   ├── focus-trap/          # Focus trapping for modals
│   │   ├── scroll-lock/         # Body scroll prevention
│   │   ├── portal/              # SSR-safe portal
│   │   └── visually-hidden/     # Screen-reader-only content
│   │
│   ├── components/              # All UI components
│   │   ├── accordion/
│   │   ├── alert-dialog/
│   │   ├── checkbox/
│   │   ├── collapsible/
│   │   ├── combobox/
│   │   ├── context-menu/
│   │   ├── dialog/
│   │   ├── drawer/
│   │   ├── dropdown-menu/
│   │   ├── hover-card/
│   │   ├── listbox/
│   │   ├── menubar/
│   │   ├── multi-select/
│   │   ├── navigation-menu/
│   │   ├── number-field/
│   │   ├── pagination/
│   │   ├── popover/
│   │   ├── progress/
│   │   ├── radio-group/
│   │   ├── select/
│   │   ├── separator/
│   │   ├── slider/
│   │   ├── switch/
│   │   ├── tabs/
│   │   ├── text-field/
│   │   ├── toast/
│   │   ├── toggle/
│   │   ├── toggle-group/
│   │   └── tooltip/
│   │
│   ├── schemas/                 # Zod schemas for every component (source of truth)
│   │   ├── dialog.schema.ts
│   │   ├── select.schema.ts
│   │   └── ...
│   │
│   ├── ai/                      # AI integration layer
│   │   ├── generate-prompt.ts   # Generates LLM system prompt from schemas
│   │   ├── mcp/                 # MCP server for Claude Code, Cursor, etc.
│   │   └── catalog.ts           # json-render compatible catalog
│   │
│   └── index.ts
│
├── llms.txt                     # Machine-readable component reference for AI
├── openui.yaml                  # OpenUI spec for tooling
├── package.json
└── tsconfig.json

Distribution

# Install
npm install pettyui

# Import (sub-path for tree-shaking)
import { Dialog } from "pettyui/dialog";
import { Select } from "pettyui/select";
import { Presence } from "pettyui/presence";

# CLI for copying styled implementations into user projects
npx pettyui add dialog --style tailwind
npx pettyui add select --style vanilla

Sub-path exports ensure tree-shaking works perfectly. Each component is independently importable. The CLI copies styled example implementations (using PettyUI headless primitives) into the user's project for full ownership — the shadcn model.

Dependencies

Minimal, deliberate:

  • @floating-ui/dom — Positioning for popovers, tooltips, dropdowns, selects
  • solid-js (peer dependency) — SolidJS core

Build/dev only (not shipped to users):

  • zod 4.x — Schema definitions for AI tooling, type generation, and validation. Zod is used at build time to generate llms.txt, openui.yaml, and system prompts. It is NOT a runtime dependency of the component library itself. Uses Zod 4 (stable since early 2026) for improved performance and smaller types.

No other runtime dependencies. Utilities (focus-trap, scroll-lock, dismiss, presence) are built in-house and exported standalone.


Internal State Pattern

Every component follows the same internal architecture:

Signals + Context

// 1. Root creates signals and provides context
function DialogRoot(props: DialogProps) {
  const [open, setOpen] = createControllableSignal({
    value: () => props.open,
    defaultValue: () => props.defaultOpen ?? false,
    onChange: props.onOpenChange,
  });

  const context: InternalDialogContext = {
    open,
    setOpen,
    modal: () => props.modal ?? true,
    contentId: createSignal<string | undefined>(undefined),
    titleId: createSignal<string | undefined>(undefined),
    descriptionId: createSignal<string | undefined>(undefined),
  };

  return (
    <InternalDialogContext.Provider value={context}>
      {props.children}
    </InternalDialogContext.Provider>
  );
}

Dual Context (Public + Internal)

Learned from corvu. Every component exposes two context layers:

  • Internal context — Includes setters, registration functions, refs. Never exposed to consumers. Used only by component parts internally.
  • Public context — Read-only accessors. Exported via Dialog.useContext(). Safe for consumers to build custom parts.
// Internal — only accessible to Dialog.Content, Dialog.Trigger, etc.
const InternalDialogContext = createContext<InternalDialogContextValue>();

// Public — accessible to consumers via Dialog.useContext()
const DialogContext = createContext<DialogContextValue>();

// Public context only exposes read-only accessors
type DialogContextValue = {
  open: Accessor<boolean>;
  modal: Accessor<boolean>;
};

Controllable Signal Primitive

The createControllableSignal primitive handles controlled vs uncontrolled state for every stateful component:

function createControllableSignal<T>(options: {
  value: Accessor<T | undefined>;      // Controlled value (if provided)
  defaultValue: Accessor<T>;           // Uncontrolled default
  onChange?: (value: T) => void;        // Callback on change
}): [Accessor<T>, (value: T) => void];

When value() is not undefined, the component is controlled. Otherwise it manages its own internal signal. This pattern is used by every stateful component: Dialog (open), Select (value), Accordion (expandedItems), Tabs (activeTab), etc.

ID Registration

Child parts register their IDs with the root context for ARIA linking:

// In Dialog.Title
const context = useInternalDialogContext();
const id = createUniqueId(); // SSR-deterministic
createEffect(() => context.titleId[1](id));
onCleanup(() => context.titleId[1](undefined));

// In Dialog.Content — uses the registered ID
<div role="dialog" aria-labelledby={context.titleId[0]()} />

createUniqueId() from SolidJS produces deterministic IDs for SSR hydration.


Component API Design

Dual-Layer API: Simple + Compound

Every component offers two API surfaces. The simple API covers 90% of use cases with minimal cognitive load. The compound API provides full compositional control.

Layer 1: Simple Props API

// Dialog — 2 props to get a working accessible modal
<Dialog open={open()} onOpenChange={setOpen}>
  <Dialog.Content>
    <Dialog.Title>Confirm deletion</Dialog.Title>
    <p>This cannot be undone.</p>
    <Dialog.Close>OK</Dialog.Close>
  </Dialog.Content>
</Dialog>

// Select — flat props, works immediately
<Select
  options={countries}
  value={selected()}
  onValueChange={setSelected}
  placeholder="Choose a country"
/>

// Checkbox — single component
<Checkbox checked={agreed()} onCheckedChange={setAgreed}>
  I agree to the terms
</Checkbox>

In the simple API:

  • Dialog auto-generates Portal, Overlay, focus trap, scroll lock, and dismiss behavior
  • Select auto-generates Trigger, Content, and Items from the options prop
  • Form controls are single components with sensible defaults

Detection mechanism: Root components check for the presence of specific child parts using context registration. When a part mounts, it registers itself with the root. During the first render cycle, if no Trigger/Portal/Overlay registered, Root renders the auto-generated defaults. This is a one-time check — once the component tree stabilizes, defaults are locked in.

Layer 2: Compound Component API

When you need control, decompose into parts. If you render a part explicitly, PettyUI stops auto-generating it.

// Full control over every piece
<Dialog open={open()} onOpenChange={setOpen}>
  <Dialog.Trigger as={MyButton}>Open</Dialog.Trigger>
  <Dialog.Portal target={document.getElementById("modals")}>
    <Dialog.Overlay class="my-overlay" />
    <Dialog.Content
      class="my-content"
      onOpenAutoFocus={(e) => e.preventDefault()}
    >
      <Dialog.Title>Confirm deletion</Dialog.Title>
      <Dialog.Description>This action cannot be undone.</Dialog.Description>
      <div class="actions">
        <Dialog.Close as={MyButton}>Cancel</Dialog.Close>
        <button onClick={handleDelete}>Delete</button>
      </div>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog>

// Select with custom rendering
<Select.Root value={selected()} onValueChange={setSelected}>
  <Select.Trigger>
    <Select.Value placeholder="Choose a country" />
    <Select.Icon />
  </Select.Trigger>
  <Select.Content>
    <Select.Group>
      <Select.GroupLabel>Europe</Select.GroupLabel>
      <For each={europeanCountries}>
        {(country) => (
          <Select.Item value={country.code}>
            <Select.ItemText>{country.name}</Select.ItemText>
            <Select.ItemIndicator></Select.ItemIndicator>
          </Select.Item>
        )}
      </For>
    </Select.Group>
  </Select.Content>
</Select.Root>

Layer 3: Escape Hatches

Public context + children-as-function for cases PettyUI didn't anticipate:

// Access component state for custom behavior
const ctx = Dialog.useContext();

// Children-as-function for full prop control
<Dialog.Trigger>
  {(props) => (
    <MyButton {...props} class="custom" onClick={[props.onClick, trackAnalytics]}>
      Open dialog
    </MyButton>
  )}
</Dialog.Trigger>

Polymorphic Rendering

Two patterns, covering simple and advanced use cases:

as prop (simple — change the element)

<Dialog.Trigger as="a" href="/confirm">Open</Dialog.Trigger>
<Tabs.Trigger as={MyButton} variant="ghost">Tab 1</Tabs.Trigger>

Internally uses SolidJS <Dynamic> component. The as prop accepts a string tag name or a SolidJS component.

Children-as-function (advanced — full control)

<Dialog.Trigger>
  {(props) => (
    <MyButton {...props} class="custom">
      Open
    </MyButton>
  )}
</Dialog.Trigger>

PettyUI passes all necessary props (ARIA attributes, event handlers, data attributes, ref) as the argument. The consumer spreads them onto their element.

Controlled vs Uncontrolled

Every stateful component supports both patterns with the same naming convention:

// Uncontrolled — PettyUI manages state internally
<Dialog defaultOpen={false}>
<Select defaultValue="us">
<Accordion defaultExpandedItems={["item-1"]}>

// Controlled — consumer manages state
<Dialog open={open()} onOpenChange={setOpen}>
<Select value={selected()} onValueChange={setSelected}>
<Accordion expandedItems={items()} onExpandedItemsChange={setItems}>

Data Attributes

Every component part emits data attributes reflecting current state, enabling pure CSS styling:

data-state="open" | "closed"       — Dialog, Popover, Collapsible, Tooltip
data-expanded                      — Accordion.Item
data-disabled                      — Any disabled component
data-highlighted                   — Menu.Item, Select.Item (keyboard focus)
data-checked                       — Checkbox, Radio, Switch
data-indeterminate                 — Checkbox
data-orientation="horizontal" | "vertical" — Tabs, Separator, Slider
data-opening                       — Presence: entering animation
data-closing                       — Presence: exiting animation
data-placeholder                   — Select.Value when no value selected

Animation-aware attributes (data-opening, data-closing) are powered by the Presence utility and persist during enter/exit transitions.


Component Archetypes

Three consistent patterns repeated across all components. Learn one, know them all.

Archetype 1: Overlay

Used by: Dialog, AlertDialog, Drawer, Popover, Tooltip, HoverCard, DropdownMenu, ContextMenu

Pattern:

  • Root manages open/close state
  • Trigger toggles the overlay
  • Content is the overlay body
  • open / onOpenChange for controlled state
  • Auto-generates Portal, Overlay (if applicable), focus trap, scroll lock, dismiss

Consistent props:

open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean               // Where applicable (Dialog, AlertDialog)

Consistent parts:

Component.Root          // or just <Component>
Component.Trigger
Component.Content
Component.Close         // Where applicable
Component.Portal        // Override portal target
Component.Overlay       // Where applicable (Dialog, Drawer)

Archetype 2: Collection

Used by: Select, MultiSelect, Combobox, Listbox, Tabs, RadioGroup, ToggleGroup, Menubar

Pattern:

  • Root manages selected value(s)
  • Items are selectable children
  • Groups organize items with labels
  • value / onValueChange for controlled state
  • Keyboard navigation (arrow keys, typeahead) built in

Consistent props:

value?: T                              // Select, Combobox, RadioGroup
defaultValue?: T
onValueChange?: (value: T) => void
disabled?: boolean
required?: boolean
orientation?: "horizontal" | "vertical"

Consistent parts:

Component.Root
Component.Item
Component.ItemText
Component.ItemIndicator
Component.Group
Component.GroupLabel

Archetype 3: Form Control

Used by: Checkbox, Switch, Toggle, Slider, TextField, NumberField

Pattern:

  • Single component for the simple case
  • Decomposes into Root + Input + Label + Description + ErrorMessage for full control
  • value / onValueChange or checked / onCheckedChange for controlled state
  • Integrates with native form submission

Consistent props:

value?: T | checked?: boolean
defaultValue?: T | defaultChecked?: boolean
onValueChange?: (value: T) => void | onCheckedChange?: (checked: boolean) => void
disabled?: boolean
required?: boolean
readOnly?: boolean
name?: string                          // For form submission

Naming Conventions

Strict consistency across all components. AI generalizes from one component to all others.

Events

Always on[Thing]Change:

  • onOpenChange — Dialog, Popover, Tooltip, DropdownMenu, etc.
  • onValueChange — Select, Combobox, Slider, RadioGroup, etc.
  • onCheckedChange — Checkbox, Switch
  • onExpandedChange — Accordion, Collapsible

Never: onToggle, onVisibilityChange, onChange, onSelect, onClose

Boolean Props

Adjectives, no is prefix:

  • disabled, required, readOnly, modal, multiple

Never: isDisabled, isRequired, isReadOnly

State Props

Always value/defaultValue or checked/defaultChecked or open/defaultOpen:

  • value + onValueChange for value-bearing components
  • checked + onCheckedChange for boolean toggle components
  • open + onOpenChange for disclosure components

Component Naming

  • Single-purpose names: Select, MultiSelect (not Select with a multiple prop)
  • Parts use dot notation: Select.Root, Select.Trigger, Select.Content
  • No abbreviations: DropdownMenu not DDMenu, NavigationMenu not NavMenu

Accessibility

Target

WCAG 2.1 AA compliance for all components. Every component follows WAI-ARIA Authoring Practices.

Built-in Behavior

Every component automatically provides:

  • Correct ARIA roles and attributesrole="dialog", aria-modal, aria-expanded, aria-labelledby, aria-describedby, etc.
  • Keyboard navigation — Arrow keys for collections, Escape for dismissal, Tab/Shift+Tab for focus movement, Enter/Space for activation
  • Focus management — Auto-focus on open, return focus on close, focus trapping for modals
  • Screen reader announcements — Live regions for toasts, status changes
  • Typeahead — Type characters to jump to matching items in Select, Combobox, Menu

Utilities (Exported Standalone)

These utilities power the components internally but are also exported for standalone use:

  • <Presence> — Keeps elements in the DOM during exit animations. Provides data-opening and data-closing attributes.
  • createDismiss — Handles Escape key, click-outside, and focus-outside with a layer stack for nested overlays.
  • createFocusTrap — Traps Tab focus within a container. Handles edge cases (no focusable elements, shadow DOM).
  • createScrollLock — Prevents body scroll. Options for scrollbar shift compensation (padding vs margin), pinch-zoom allowance.
  • <Portal> — SSR-safe portal. Renders to document.body by default, configurable target.
  • <VisuallyHidden> — Screen-reader-only content.

Presence Primitive

The <Presence> component solves the hardest animation problem: keeping exiting elements in the DOM until their animation completes.

import { Presence } from "pettyui/presence";

<Presence present={open()}>
  <div class="dialog-overlay" />
</Presence>

// Or with children-as-function for state access
<Presence present={open()}>
  {(props) => (
    <div
      class="dialog-content"
      data-opening={props.opening || undefined}
      data-closing={props.closing || undefined}
    />
  )}
</Presence>

Data attributes emitted:

  • data-opening — Element is entering (present changed to true)
  • data-closing — Element is exiting (present changed to false, still mounted)

This enables pure CSS animations:

.dialog-content[data-opening] { animation: fadeIn 200ms; }
.dialog-content[data-closing] { animation: fadeOut 150ms; }

No opinion on animation approach. Works with CSS transitions, CSS animations, Motion One, or any JS animation library.


TypeScript Design

Layered Types

Simple types for the common API (fast autocomplete). Type helpers for advanced use cases (full generic precision).

// 95% of users — simple, fast autocomplete, clear errors
<Dialog.Trigger as="a" href="/page">Open</Dialog.Trigger>

// Design system authors — full precision when wrapping
import type { PettyUI } from "pettyui";
type MyTriggerProps = PettyUI.TriggerProps<typeof MyButton>;

Zod Schemas as Source of Truth

Every component's props are defined as a Zod schema. This serves triple duty:

  1. TypeScript types via z.infer<typeof schema>
  2. Runtime validation for AI-generated output
  3. Auto-generation of llms.txt, openui.yaml, and LLM system prompts
// schemas/dialog.schema.ts
import { z } from "zod";

export const DialogPropsSchema = z.object({
  open: z.boolean().optional()
    .describe("Whether the dialog is currently open. When provided, dialog becomes controlled."),
  defaultOpen: z.boolean().default(false)
    .describe("Initial open state for uncontrolled usage."),
  onOpenChange: z.function().args(z.boolean()).optional()
    .describe("Called when the open state should change."),
  modal: z.boolean().default(true)
    .describe("Whether to block interaction outside the dialog and trap focus."),
});

export type DialogProps = z.infer<typeof DialogPropsSchema>;

Discriminated Types for Mode-Specific Behavior

Where a component has distinct modes, use TypeScript discriminated unions:

// Select: value is T
type SelectProps<T> = {
  value?: T;
  onValueChange?: (value: T) => void;
};

// MultiSelect: value is T[]
type MultiSelectProps<T> = {
  value?: T[];
  onValueChange?: (value: T[]) => void;
};

No boolean flag that changes types. Separate components with separate, precise types.

Prop Documentation via JSDoc

Every prop has a JSDoc comment with description and default value. This is the primary documentation surface — TypeScript autocomplete IS the docs.

interface DialogContentProps {
  /**
   * Whether to force the content to remain mounted in the DOM.
   * Useful when controlling animations with external libraries.
   * @default false
   */
  forceMount?: boolean;

  /**
   * Event handler called when auto-focus fires on open.
   * Call `event.preventDefault()` to prevent default focus behavior.
   */
  onOpenAutoFocus?: (event: Event) => void;
}

Error Design

Pit of Success Errors

Every error is specific, actionable, linkable, and dev-only (stripped in production).

Format:

[PettyUI] <Component.Part> error description.
  Fix: Specific instruction on how to fix it.
  Docs: https://pettyui.dev/components/component#section

Examples:

[PettyUI] <Select.Item> was rendered outside of <Select.Content>.
  Fix: Wrap your Select.Item components inside <Select.Content>.
  Docs: https://pettyui.dev/components/select#composition

[PettyUI] <Dialog> received both "open" and "defaultOpen" props.
  Fix: Use "open" + "onOpenChange" for controlled mode, or "defaultOpen" for uncontrolled. Not both.
  Docs: https://pettyui.dev/guides/controlled-vs-uncontrolled

[PettyUI] <Select.Root> received unknown prop "multiple".
  Fix: Use <MultiSelect> instead of <Select> for multi-value selection.
  Docs: https://pettyui.dev/components/multi-select

These errors serve both humans and AI agents. AI can parse the fix instruction and self-correct.


AI Integration Layer

PettyUI ships first-class tooling for AI code generation. This is not an afterthought — it is a core design pillar.

1. llms.txt (Ships in Package)

Located at the package root. Concise, structured component reference optimized for LLM consumption.

# PettyUI Component Reference

## Dialog
Modal overlay that blocks interaction with the page.

### Simple API
<Dialog open={open()} onOpenChange={setOpen}>
  <Dialog.Content>
    <Dialog.Title>Title</Dialog.Title>
    <p>Content here</p>
    <Dialog.Close>Close</Dialog.Close>
  </Dialog.Content>
</Dialog>

### Props
- open: boolean — Whether dialog is visible (controlled)
- defaultOpen: boolean (default: false) — Initial state (uncontrolled)
- onOpenChange: (open: boolean) => void — Called when state should change
- modal: boolean (default: true) — Whether to trap focus and block outside interaction

### Parts (Compound API)
- Dialog.Trigger — Button that opens the dialog. Use `as` prop to customize element.
- Dialog.Portal — Portals content to document.body. Override with `target` prop.
- Dialog.Overlay — Backdrop behind dialog. Auto-generated if not provided.
- Dialog.Content — The dialog panel. Receives focus on open.
- Dialog.Title — Accessible title. Linked via aria-labelledby.
- Dialog.Description — Accessible description. Linked via aria-describedby.
- Dialog.Close — Button that closes the dialog.

### Keyboard
- Escape: Closes the dialog
- Tab: Cycles focus within dialog (when modal)

### Data Attributes
- data-state="open" | "closed" on Trigger, Content, Overlay
- data-opening / data-closing on Content, Overlay (during animations)

2. openui.yaml (Repository Root)

Machine-readable component specification following the OpenUI format:

name: PettyUI
version: 0.1.0
framework: solid
components:
  Dialog:
    description: Modal overlay that blocks interaction with the page.
    props:
      open:
        type: boolean
        description: Whether the dialog is currently open.
      defaultOpen:
        type: boolean
        default: false
        description: Initial open state for uncontrolled usage.
      onOpenChange:
        type: function
        description: Called when the open state should change.
      modal:
        type: boolean
        default: true
        description: Whether to trap focus and block outside interaction.
    parts: [Trigger, Portal, Overlay, Content, Title, Description, Close]
    example: |
      <Dialog open={open()} onOpenChange={setOpen}>
        <Dialog.Content>
          <Dialog.Title>Confirm</Dialog.Title>
          <Dialog.Close>OK</Dialog.Close>
        </Dialog.Content>
      </Dialog>

3. MCP Server

npx pettyui mcp

Exposes to AI agents:

  • Component list with full props, types, descriptions
  • Usage examples per component (simple + compound)
  • Installed components (reads user's project config)
  • Search across components and props
  • Validation — check if generated code uses valid props/parts

4. System Prompt Generator

import { generatePrompt } from "pettyui/ai";

const systemPrompt = generatePrompt({
  components: ["dialog", "select", "tabs", "checkbox"],
  styling: "tailwind",    // or "vanilla" or "panda"
});
// Returns a string containing complete component reference
// derived from Zod schemas — always up to date

5. CLI for Source Ownership (Separate Package)

The CLI is a separate package (pettyui-cli or create-pettyui) — not part of the core headless library. It is a convenience tool, not a requirement.

npx pettyui add dialog --style tailwind
npx pettyui add select --style vanilla
npx pettyui add all --style tailwind

Copies styled example implementations into the user's project. These files:

  • Import PettyUI headless primitives as a dependency
  • Add styling (Tailwind classes, CSS, etc.)
  • Are fully owned and editable by the developer
  • Are readable by AI agents (source code in the project, not node_modules)

Scope note: The CLI is a Wave 2+ deliverable. The core headless library ships first without it.

6. json-render Catalog

Optional export for generative UI applications:

import { catalog } from "pettyui/catalog";

// AI generates JSON conforming to catalog schema
// json-render validates and renders through PettyUI components
const ui = await model.generate({
  schema: catalog.schema,
  prompt: "Create a settings form with email and notification toggle",
});

render(catalog, ui.json);

Component Inventory

Wave 1: Breadth (Simple + Medium Complexity)

Ship first. Proves the architecture, establishes patterns.

Component Archetype Complexity
Accordion Collection Medium
AlertDialog Overlay Simple
Checkbox Form Control Simple
Collapsible Form Control Simple
Dialog Overlay Medium
Drawer Overlay Medium
HoverCard Overlay Simple
Pagination Collection Medium
Popover Overlay Medium
Progress Form Control Simple
RadioGroup Collection Simple
Separator Trivial
Slider Form Control Medium
Switch Form Control Simple
Tabs Collection Medium
TextField Form Control Simple
Toggle Form Control Simple
ToggleGroup Collection Simple
Tooltip Overlay Simple
VisuallyHidden Utility Trivial

Wave 2: Depth (Complex Interaction Components)

Ship after Wave 1 is stable. These components stress-test every primitive.

Component Archetype Complexity
Select Collection + Overlay High
MultiSelect Collection + Overlay High
Combobox Collection + Overlay High
DropdownMenu Collection + Overlay High
ContextMenu Collection + Overlay High
Menubar Collection High
NavigationMenu Collection + Overlay High
NumberField Form Control Medium
Toast Overlay High
Listbox Collection Medium

Wave 3: Advanced (High-Demand Gaps)

Components neither Kobalte nor corvu provide well. Market differentiators.

Component Notes
Command Command palette (Cmd+K pattern)
DatePicker Calendar-based date selection
Calendar Standalone calendar display
TreeView Hierarchical list with expand/collapse
DataTable Headless table with sort/filter/pagination primitives
Carousel Slide-based content display
Stepper Multi-step wizard flow
ColorPicker Color selection with swatch/slider/area
FileUpload Drop zone + file list management

SSR Strategy

Deterministic IDs

Use SolidJS createUniqueId() which produces deterministic IDs during SSR. Server and client generate the same IDs, preventing hydration mismatches.

Portal Handling

The <Portal> utility detects SSR and renders content inline during server rendering, then moves it to the target container on hydration.

No Browser-Only APIs at Import Time

All browser APIs (document, window, MutationObserver, ResizeObserver) are accessed lazily inside onMount or guarded by isServer checks. Importing any PettyUI component in an SSR context never throws.

SolidStart Compatibility

  • Test against every SolidStart release
  • Export conditions: "solid" for SolidJS compilation, "import" for ESM, "require" for CJS
  • No use of APIs that differ between Solid's server and client builds

Bundle Size Strategy

Target

Each component should add less than 5KB gzipped to a production bundle. Complex components (Select, Combobox) may be up to 10KB gzipped.

For reference: Radix Dialog is ~9.2KB gzipped. Kobalte Popover is ~35KB gzipped. We target Radix-level or better.

How

  • No heavy dependencies (no @internationalized/*, no state machine libraries)
  • Shared primitives are tiny (signals + context, not state machines)
  • @floating-ui/dom is the only significant dependency (~3KB gzipped) and only loaded by components that need positioning
  • Sub-path exports ensure only imported components are bundled
  • Development-only error messages are stripped via process.env.NODE_ENV dead code elimination

Testing Strategy

Unit Tests

Every component gets:

  • Rendering tests (basic, with props, with children)
  • ARIA attribute verification
  • Keyboard interaction tests
  • Controlled and uncontrolled state tests
  • SSR rendering tests (no hydration mismatch)

Accessibility Testing

  • Automated: axe-core on every component in every state (via Vitest)
  • E2E (Wave 2+): Playwright tests across Chromium, Firefox, and WebKit — verifies real keyboard navigation, focus management, and ARIA behavior in actual browsers including Safari
  • Manual: Test with VoiceOver (macOS/iOS), NVDA (Windows), TalkBack (Android)
  • Keyboard-only navigation tests for every interactive component

Bundle Size Monitoring

CI checks bundle size on every PR. Any component exceeding the target triggers a warning.


Documentation Strategy

Three audiences, three formats:

  1. AI agentsllms.txt, openui.yaml, MCP server, Zod schemas. Machine-readable, always derived from source of truth.
  2. Developers getting started — Website with simple API examples first, compound API second. Every component has a copy-paste example that works on first try.
  3. Design system authors — API reference with full TypeScript types, public context docs, composition patterns, and styling guides.

Development Tooling

Pinned versions as of 2026-03-28:

  • Runtime: SolidJS 1.9.x (stable). Architecture is Solid 2.0-ready — no use of deprecated APIs.
  • Build: Vite 8.x with vite-plugin-solid 2.11.x
  • Package manager: pnpm 10.x
  • Unit testing: Vitest 4.x + @solidjs/testing-library 0.8.x
  • E2E testing: Playwright (Wave 2+) — real browser a11y testing across Chromium, Firefox, and WebKit/Safari. Critical for verifying keyboard navigation and screen reader behavior in Safari/VoiceOver.
  • Type checking: TypeScript 6.x strict mode
  • Formatting: Biome — 25x faster than Prettier, handles formatting + general lint rules
  • Linting: Minimal ESLint config with eslint-plugin-solid only — Biome has no Solid-specific equivalent, so ESLint handles framework-specific reactive rules (signal access, effect dependencies). All other lint rules handled by Biome.
  • Bundling: tsdown 0.x for package builds (ESM + CJS + .d.ts) — Rust-based successor to tsup (same author), 3-5x faster, Rolldown engine (same as Vite 8). Near-identical config to tsup. If edge cases arise, tsup 8.x is the drop-in fallback.
  • Schemas: Zod 4.x (build-time only) — z.toJSONSchema() directly generates OpenUI spec
  • Positioning: @floating-ui/dom 1.7.x
  • CI: GitHub Actions — biome check, eslint (solid rules), type-check, vitest, bundle size check, SSR verification
  • Docs site: SolidStart + MDX

SolidJS 2.0 Readiness

Solid 2.0 is in beta (2.0.0-beta.4 as of writing). PettyUI targets Solid 1.9.x stable but:

  • Avoids deprecated Solid 1.x APIs (createResource in favor of patterns that map to 2.0's createAsync)
  • Uses createUniqueId, createSignal, createEffect, createContext — all stable across both versions
  • Will ship a Solid 2.0 compatibility update when 2.0 reaches stable release
  • CI runs tests against both solid-js@latest and solid-js@next to catch breaking changes early

Competitive Positioning

Feature Kobalte corvu PettyUI
Components 50+ 9 25+ (Wave 1-2)
Bundle per component ~35KB gzip Small <5KB gzip target
Simple props API No No Yes
Compound API Yes Yes Yes
as prop Yes Yes Yes
Children-as-function No Yes Yes
Dual context No Yes Yes
Animation data attrs No Yes Yes
Presence primitive External External Built-in
SSR-first Fragile Unknown Yes
AI integration None None llms.txt, MCP, OpenUI, CLI
Zod schemas No No Yes
Typed error messages No No Yes
Extracted utilities No Yes Yes

PettyUI's unique differentiators:

  1. Dual-layer API — Simple props + compound components
  2. AI-native — First component library designed for LLM code generation
  3. Pit of success errors — Specific, actionable, parseable by AI
  4. Single-purpose components — No overloaded boolean flags
  5. Bundle size — Radix-level footprint in the SolidJS ecosystem