- 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
1029 lines
35 KiB
Markdown
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
|