- 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
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
- AI-first, human-friendly — Every API decision optimizes for LLM code generation correctness. Consistent patterns, explicit types, machine-readable docs, and no implicit contracts.
- Pure headless — Zero styling, zero theme, zero CSS. Components provide behavior, accessibility, and state. Users own all visual decisions.
- Progressive disclosure — Simple props API for the 90% case. Compound component API for full control. Hook-level escape hatches for the 2%.
- Solid-native — Built from scratch on SolidJS signals and context. No framework translation layers, no state machines, no abstraction overhead.
- SSR-correct from day one — Every component works with SolidStart SSR. Deterministic IDs, no hydration mismatches, proper portal handling.
- Single-purpose components — No overloaded behavior via boolean flags.
SelectandMultiSelectare separate components. AI never guesses modes. - 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, selectssolid-js(peer dependency) — SolidJS core
Build/dev only (not shipped to users):
zod4.x — Schema definitions for AI tooling, type generation, and validation. Zod is used at build time to generatellms.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
optionsprop - 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/onOpenChangefor 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/onValueChangefor 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/onValueChangeorchecked/onCheckedChangefor 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, SwitchonExpandedChange— 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+onValueChangefor value-bearing componentschecked+onCheckedChangefor boolean toggle componentsopen+onOpenChangefor disclosure components
Component Naming
- Single-purpose names:
Select,MultiSelect(notSelectwith amultipleprop) - Parts use dot notation:
Select.Root,Select.Trigger,Select.Content - No abbreviations:
DropdownMenunotDDMenu,NavigationMenunotNavMenu
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. Providesdata-openinganddata-closingattributes.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 todocument.bodyby 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:
- TypeScript types via
z.infer<typeof schema> - Runtime validation for AI-generated output
- 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/domis 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_ENVdead 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-coreon 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:
- AI agents —
llms.txt,openui.yaml, MCP server, Zod schemas. Machine-readable, always derived from source of truth. - Developers getting started — Website with simple API examples first, compound API second. Every component has a copy-paste example that works on first try.
- 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-solid2.11.x - Package manager: pnpm 10.x
- Unit testing: Vitest 4.x +
@solidjs/testing-library0.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-solidonly — 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 (
createResourcein favor of patterns that map to 2.0'screateAsync) - 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@latestandsolid-js@nextto 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:
- Dual-layer API — Simple props + compound components
- AI-native — First component library designed for LLM code generation
- Pit of success errors — Specific, actionable, parseable by AI
- Single-purpose components — No overloaded boolean flags
- Bundle size — Radix-level footprint in the SolidJS ecosystem