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

1029 lines
35 KiB
Markdown

# 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
```bash
# 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
```tsx
// 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.
```tsx
// 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:
```tsx
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:
```tsx
// 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
```tsx
// 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.**
```tsx
// 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:
```tsx
// 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)
```tsx
<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)
```tsx
<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:
```tsx
// 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:**
```tsx
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean // Where applicable (Dialog, AlertDialog)
```
**Consistent parts:**
```tsx
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:**
```tsx
value?: T // Select, Combobox, RadioGroup
defaultValue?: T
onValueChange?: (value: T) => void
disabled?: boolean
required?: boolean
orientation?: "horizontal" | "vertical"
```
**Consistent parts:**
```tsx
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:**
```tsx
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 attributes** — `role="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.
```tsx
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:
```css
.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).
```tsx
// 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
```tsx
// 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:
```tsx
// 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.
```tsx
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:
```yaml
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
```bash
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
```tsx
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.
```bash
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:
```tsx
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 agents**`llms.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