Architecture spec

This commit is contained in:
Mats Bosson 2026-03-29 10:06:48 +07:00
parent d9285cc524
commit 292db3a422

View File

@ -0,0 +1,689 @@
# Spec A: Core Primitives, Floating Components, and Collection Components
## Goal
Add 3 new primitives and 9 new components to PettyUI, covering positioned overlays (Tooltip, Popover, HoverCard) and list-based interactions (Listbox, Select, Combobox, DropdownMenu, ContextMenu, Menubar). After this spec, PettyUI matches Kobalte's coverage for all interactive compound components.
## Architecture
Three new primitives power all 9 components. Components are built in dependency order — each one builds on the previous. The primitives use value-based identity (not index-based) so they work correctly with filtered/reordered lists.
**Dependency chain:**
```
createFloating ─────────────────────────────────────────────┐
createListNavigation ───────────────────────────────────┐ │
createRovingFocus (optional refactor) ──────────────┐ │ │
│ │ │
Tooltip ────────────────────────────────────────────│───│───┤
Popover ────────────────────────────────────────────│───│───┤
HoverCard ──────────────────────────────────────────│───│───┤
Listbox ────────────────────────────────────────────│───┤ │
Select ─────────────────────────────────────────────│───┤───┤
Combobox ───────────────────────────────────────────│───┤───┤
DropdownMenu ───────────────────────────────────────│───┤───┤
ContextMenu ────────────────────────────────────────│───┤───┤
Menubar ────────────────────────────────────────────┤───┤───┤
```
## Tech Stack
- SolidJS 1.9.x
- `@floating-ui/dom` (already in dependencies)
- TypeScript with `exactOptionalPropertyTypes: true`
- Vitest + `@solidjs/testing-library`
- Biome for linting
---
## Primitive 1: `createFloating`
**File:** `packages/core/src/primitives/create-floating.ts`
Thin reactive wrapper around `@floating-ui/dom`. Computes position of a floating element relative to an anchor element.
### Interface
```ts
import type { Middleware, Placement, Strategy } from "@floating-ui/dom";
import type { Accessor } from "solid-js";
import type { JSX } from "solid-js";
interface CreateFloatingOptions {
/** Reference/anchor element. */
anchor: Accessor<HTMLElement | null>;
/** The floating element to position. */
floating: Accessor<HTMLElement | null>;
/** Desired placement. @default "bottom" */
placement?: Accessor<Placement>;
/** Floating UI middleware (flip, shift, offset, arrow, etc.). */
middleware?: Accessor<Middleware[]>;
/** CSS positioning strategy. @default "absolute" */
strategy?: Accessor<Strategy>;
/** Only compute position when true. @default () => true */
open?: Accessor<boolean>;
}
interface FloatingState {
/** Computed x position in px. */
x: Accessor<number>;
/** Computed y position in px. */
y: Accessor<number>;
/** Actual placement after middleware (may differ from requested). */
placement: Accessor<Placement>;
/** Ready-to-spread CSS: { position, top, left }. */
style: Accessor<JSX.CSSProperties>;
}
```
### Behavior
- Calls `computePosition` from `@floating-ui/dom` reactively when anchor, floating, placement, or middleware change.
- Sets up `autoUpdate` (scroll/resize listener) when `open()` is true. Cleans up when `open()` becomes false or on disposal.
- Returns reactive `x`, `y`, `placement`, and a convenience `style` accessor.
- Does not render any DOM — purely computational.
---
## Primitive 2: `createListNavigation`
**File:** `packages/core/src/primitives/create-list-navigation.ts`
Value-based keyboard navigation, selection/activation, and typeahead for list-like components. Uses `aria-activedescendant` (virtual focus) so the container/input retains DOM focus while items are visually highlighted.
### Interface
```ts
import type { Accessor } from "solid-js";
interface CreateListNavigationOptions {
/** Ordered list of valid item values. Reactive — can change (e.g., filtering). */
items: Accessor<string[]>;
/** "selection" for Listbox/Select/Combobox. "activation" for Menu. */
mode: "selection" | "activation";
/** @default "vertical" */
orientation?: "vertical" | "horizontal";
/** Wrap at list boundaries. @default true */
loop?: boolean;
// --- Selection mode props ---
/** Controlled selected value. */
value?: Accessor<string | undefined>;
/** Initial uncontrolled value. */
defaultValue?: string;
/** Called when selection changes. */
onValueChange?: ((value: string) => void) | undefined;
/** Allow multiple selection. @default false */
multiple?: boolean;
/** For multiple mode: controlled values. */
values?: Accessor<string[]>;
/** For multiple mode: initial uncontrolled values. */
defaultValues?: string[];
/** For multiple mode: called when selection changes. */
onValuesChange?: ((values: string[]) => void) | undefined;
// --- Activation mode props ---
/** Called when an item is activated (Enter/click in activation mode). */
onActivate?: (value: string) => void;
// --- Typeahead ---
/** Enable typeahead. @default true */
typeahead?: boolean;
/** Return the display label for a value. Used for typeahead matching. */
getLabel?: (value: string) => string;
// --- ID generation ---
/** Base ID for generating item IDs. Uses createUniqueId() if not provided. */
baseId?: string;
}
interface ListNavigationState {
/** Currently highlighted item value (virtual focus). */
highlightedValue: Accessor<string | undefined>;
/** Currently selected value (selection mode, single). */
selectedValue: Accessor<string | undefined>;
/** Currently selected values (selection mode, multiple). */
selectedValues: Accessor<string[]>;
/** Props to spread on the list container (div/ul). */
containerProps: {
role: string; // "listbox" or "menu"
"aria-orientation": string;
"aria-activedescendant": string | undefined;
onKeyDown: (e: KeyboardEvent) => void;
onPointerLeave: () => void;
};
/** Get props for a specific item by value. */
getItemProps: (value: string) => {
id: string;
role: string; // "option" or "menuitem"
"aria-selected"?: string; // selection mode only
"aria-disabled"?: string;
"data-highlighted": "" | undefined;
"data-state"?: string;
onPointerEnter: () => void;
onPointerMove: () => void;
onClick: () => void;
};
/** Imperatively set highlighted value. */
highlight: (value: string | undefined) => void;
/** Highlight the first item. */
highlightFirst: () => void;
/** Highlight the last item. */
highlightLast: () => void;
/** Clear highlight. */
clearHighlight: () => void;
}
```
### Keyboard behavior
| Key | Action |
|-----|--------|
| ArrowDown (vertical) / ArrowRight (horizontal) | Highlight next item |
| ArrowUp (vertical) / ArrowLeft (horizontal) | Highlight previous item |
| Home | Highlight first item |
| End | Highlight last item |
| Enter / Space | Select (selection mode) or activate (activation mode) |
| Escape | Clear highlight (consumers handle close) |
| Printable characters | Typeahead — jump to first item whose label starts with typed characters |
### Typeahead behavior
- Accumulates keystrokes within a 500ms window.
- Matches prefix of `getLabel(value)` (case-insensitive).
- After timeout, buffer resets.
- If `getLabel` is not provided, uses the value string itself.
### Disabled items
Items are not in the `items` accessor when disabled — the consumer filters them out. This keeps the primitive simple. The consumer renders disabled items with `aria-disabled` but excludes their values from the `items()` list.
---
## Primitive 3: `createRovingFocus`
**File:** `packages/core/src/primitives/create-roving-focus.ts`
Extracted from existing Tabs/RadioGroup/Accordion keyboard navigation. Manages `tabIndex` roving — one item has `tabIndex={0}`, rest have `tabIndex={-1}`. Arrow keys move real DOM focus.
### Interface
```ts
interface CreateRovingFocusOptions {
/** @default "horizontal" */
orientation?: "horizontal" | "vertical" | "both";
/** Wrap at boundaries. @default true */
loop?: boolean;
}
interface RovingFocusState {
/** Spread on the container element. */
containerProps: {
onKeyDown: (e: KeyboardEvent) => void;
};
/** Get tabIndex for an item. Pass the item's value and current active value. */
getTabIndex: (value: string, currentValue: Accessor<string | undefined>) => number;
}
```
### Behavior
- Arrow keys find focusable items within the container via `querySelectorAll("[data-roving-item]:not([disabled])")`.
- Items must have `data-roving-item` attribute.
- Supports Home/End.
- Orientation filters which arrow keys are active.
- This is a refactor/extraction — existing components (Tabs, RadioGroup, Accordion) can optionally migrate to it. Not a blocker for new components.
---
## Component 1: Tooltip
**Directory:** `packages/core/src/components/tooltip/`
**Files:** `tooltip-context.ts`, `tooltip-root.tsx`, `tooltip-trigger.tsx`, `tooltip-content.tsx`, `tooltip-arrow.tsx`, `index.ts`
### Anatomy
```
Tooltip (root — manages open state with delay timers)
Tooltip.Trigger (the anchor element — hover/focus opens)
Tooltip.Content (positioned floating panel)
Tooltip.Arrow (optional decorative arrow)
```
### Props (Root)
- `open?: boolean` — controlled
- `defaultOpen?: boolean` — uncontrolled
- `onOpenChange?: (open: boolean) => void`
- `openDelay?: number` — ms before showing, default 700
- `closeDelay?: number` — ms before hiding, default 300
### ARIA
- Content: `role="tooltip"`, unique `id`
- Trigger: `aria-describedby={contentId}` when open
### Behavior
- Open on: pointer enter trigger (after openDelay), focus trigger (after openDelay)
- Close on: pointer leave trigger (after closeDelay), blur trigger, Escape key, scroll
- Instant open if another tooltip was recently closed (within 300ms) — "tooltip group" behavior. Implemented via a module-level timestamp of last close.
- No focus trap. No scroll lock. No outside click dismiss.
### Tests (7)
1. Content has `role="tooltip"` when open
2. Trigger has `aria-describedby` pointing to content
3. Content not rendered when closed
4. Opens on trigger focus
5. Closes on Escape
6. Controlled mode works
7. Content positions via floating (has `style` with position)
---
## Component 2: Popover
**Directory:** `packages/core/src/components/popover/`
**Files:** `popover-context.ts`, `popover-root.tsx`, `popover-trigger.tsx`, `popover-content.tsx`, `popover-arrow.tsx`, `popover-close.tsx`, `popover-portal.tsx`, `index.ts`
### Anatomy
```
Popover (root — disclosure state)
Popover.Trigger (click toggles)
Popover.Portal (optional)
Popover.Content (floating panel with focus trap)
Popover.Arrow (optional)
Popover.Close (button inside content)
```
### Props (Root)
- `open`, `defaultOpen`, `onOpenChange`
- `modal?: boolean` — default false. When true: focus trap + scroll lock + outside dismiss.
### ARIA
- Content: `role="dialog"`, `aria-labelledby`, `aria-describedby`
- Trigger: `aria-haspopup="dialog"`, `aria-expanded="true"/"false"`, `aria-controls`
### Behavior
- Open on: trigger click
- Close on: Escape, outside click (via createDismiss), Close button click
- When `modal`: focus trap active, scroll lock active
- When non-modal: no focus trap, no scroll lock, Tab moves out of popover and closes it
### Tests (8)
1. Content has `role="dialog"` when open
2. Trigger has correct ARIA attributes
3. Click trigger opens
4. Escape closes
5. Close button closes
6. `aria-labelledby` links to title
7. Controlled mode
8. Content is positioned (has style)
---
## Component 3: HoverCard
**Directory:** `packages/core/src/components/hover-card/`
**Files:** `hover-card-context.ts`, `hover-card-root.tsx`, `hover-card-trigger.tsx`, `hover-card-content.tsx`, `hover-card-arrow.tsx`, `index.ts`
### Anatomy
```
HoverCard (root — manages open state with delays)
HoverCard.Trigger (hover opens)
HoverCard.Content (floating panel)
HoverCard.Arrow (optional)
```
### Props (Root)
- `open`, `defaultOpen`, `onOpenChange`
- `openDelay?: number` — default 700
- `closeDelay?: number` — default 300
### ARIA
- No specific role on content (supplementary, not announced)
- Trigger: `data-state="open"|"closed"`
### Behavior
- Open on: pointer enter trigger (after delay)
- Close on: pointer leave trigger AND content (after delay). If pointer moves from trigger to content, stays open.
- Safe area: pointer can move diagonally from trigger to content without closing (grace area / safe triangle).
- Escape closes immediately.
- No focus trap. No scroll lock.
### Tests (6)
1. Content not rendered when closed
2. Opens with defaultOpen
3. Closes on Escape
4. Content has `data-state`
5. Controlled mode
6. Content is positioned
---
## Component 4: Listbox
**Directory:** `packages/core/src/components/listbox/`
**Files:** `listbox-context.ts`, `listbox-root.tsx`, `listbox-item.tsx`, `listbox-group.tsx`, `listbox-group-label.tsx`, `index.ts`
### Anatomy
```
Listbox (root — createListNavigation in selection mode)
Listbox.Group (optional)
Listbox.GroupLabel
Listbox.Item (value, disabled, textValue)
```
### Props (Root)
- `value`, `defaultValue`, `onValueChange` — single selection
- `values`, `defaultValues`, `onValuesChange` — multiple selection
- `selectionMode?: "single" | "multiple"` — default "single"
- `items: string[]` — ordered list of active (non-disabled) item values
- `orientation?: "vertical" | "horizontal"` — default "vertical"
- `loop?: boolean` — default true
- `getLabel?: (value: string) => string` — for typeahead
- `disabled?: boolean`
### ARIA
- Root: `role="listbox"`, `aria-orientation`, `aria-activedescendant`, `aria-multiselectable` (when multiple)
- Item: `role="option"`, `aria-selected="true"|"false"`, `aria-disabled`
- Group: `role="group"`, `aria-labelledby` pointing to GroupLabel
### Behavior
- Uses `createListNavigation` in selection mode
- Container holds DOM focus. Items get virtual focus via `aria-activedescendant`.
- `data-highlighted` on currently highlighted item for CSS styling
- `data-state="active"|"inactive"` on items reflecting selection
### Tests (8)
1. Root has `role="listbox"`
2. Items have `role="option"`
3. ArrowDown highlights next item
4. Enter selects highlighted item
5. `aria-selected` reflects selection
6. Home/End navigate to first/last
7. Multiple selection mode works
8. Disabled items are skipped
---
## Component 5: Select
**Directory:** `packages/core/src/components/select/`
**Files:** `select-context.ts`, `select-root.tsx`, `select-trigger.tsx`, `select-value.tsx`, `select-content.tsx`, `select-item.tsx`, `select-group.tsx`, `select-group-label.tsx`, `index.ts`
### Anatomy
```
Select (root — disclosure + list navigation)
Select.Trigger (button)
Select.Value (displays selected text)
Select.Content (floating listbox)
Select.Group
Select.GroupLabel
Select.Item (value, disabled, textValue)
```
### Props (Root)
- `value`, `defaultValue`, `onValueChange`
- `open`, `defaultOpen`, `onOpenChange`
- `items: string[]` — active item values
- `disabled?: boolean`
- `required?: boolean`
- `name?: string` — renders hidden input for form submission
- `getLabel?: (value: string) => string`
### ARIA
- Trigger: `role="combobox"`, `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
- Content: `role="listbox"`, `aria-activedescendant`
- Item: `role="option"`, `aria-selected`
### Behavior
- Closed: Trigger click or ArrowDown/Up/Enter/Space opens. Typeahead on trigger navigates without opening.
- Open: Arrow keys navigate. Enter/Space selects and closes. Escape closes. Tab closes and moves focus.
- Content positioned via `createFloating` (default placement "bottom-start", with flip + shift + offset(8))
- When `name` is provided, renders a hidden `<input>` with the selected value for form integration.
### Tests (9)
1. Trigger has `role="combobox"`
2. Content has `role="listbox"` when open
3. Click trigger opens
4. ArrowDown on trigger opens and highlights first
5. Enter selects and closes
6. Escape closes
7. `aria-selected` on selected item
8. Controlled mode
9. Hidden input rendered when `name` provided
---
## Component 6: Combobox
**Directory:** `packages/core/src/components/combobox/`
**Files:** `combobox-context.ts`, `combobox-root.tsx`, `combobox-input.tsx`, `combobox-trigger.tsx`, `combobox-content.tsx`, `combobox-item.tsx`, `combobox-group.tsx`, `combobox-group-label.tsx`, `combobox-empty.tsx`, `index.ts`
### Anatomy
```
Combobox (root)
Combobox.Input (text input — the anchor)
Combobox.Trigger (optional toggle button)
Combobox.Content (floating listbox)
Combobox.Group
Combobox.GroupLabel
Combobox.Item (value, disabled, textValue)
Combobox.Empty (shown when items is empty)
```
### Props (Root)
- `value`, `defaultValue`, `onValueChange` — selected item
- `inputValue`, `onInputChange` — text in the input (consumer controls filtering)
- `open`, `defaultOpen`, `onOpenChange`
- `items: string[]` — filtered active item values (consumer filters, we navigate)
- `disabled?: boolean`
- `required?: boolean`
- `name?: string`
- `allowCustomValue?: boolean` — if true, Enter on non-matching input fires onValueChange with the text
- `getLabel?: (value: string) => string`
### ARIA
- Input: `role="combobox"`, `aria-expanded`, `aria-activedescendant`, `aria-autocomplete="list"`, `aria-controls`
- Content: `role="listbox"`
- Item: `role="option"`, `aria-selected`
### Behavior
- Typing in input: consumer receives `onInputChange`, updates their filter, passes new `items`. We highlight first match.
- ArrowDown on input opens if closed. If open, navigates.
- Enter: if item highlighted, selects it and closes. If `allowCustomValue` and no highlight, fires onValueChange with input text.
- Escape: if open, closes. If closed, clears input.
- Content positioned via `createFloating`.
- Empty component shown when `items().length === 0` and content is open.
### Tests (9)
1. Input has `role="combobox"`
2. Content has `role="listbox"` when open
3. Typing opens content
4. ArrowDown highlights first item
5. Enter selects highlighted item
6. Escape closes
7. Empty message shown when no items
8. Controlled value works
9. `allowCustomValue` works
---
## Component 7: DropdownMenu
**Directory:** `packages/core/src/components/dropdown-menu/`
**Files:** `dropdown-menu-context.ts`, `dropdown-menu-root.tsx`, `dropdown-menu-trigger.tsx`, `dropdown-menu-content.tsx`, `dropdown-menu-item.tsx`, `dropdown-menu-group.tsx`, `dropdown-menu-group-label.tsx`, `dropdown-menu-separator.tsx`, `dropdown-menu-checkbox-item.tsx`, `dropdown-menu-radio-group.tsx`, `dropdown-menu-radio-item.tsx`, `dropdown-menu-sub.tsx`, `dropdown-menu-sub-trigger.tsx`, `dropdown-menu-sub-content.tsx`, `index.ts`
### Anatomy
```
DropdownMenu (root — disclosure + list navigation in activation mode)
DropdownMenu.Trigger
DropdownMenu.Content (floating menu)
DropdownMenu.Item (value, onSelect)
DropdownMenu.Group
DropdownMenu.GroupLabel
DropdownMenu.Separator
DropdownMenu.CheckboxItem (checked, onCheckedChange)
DropdownMenu.RadioGroup (value, onValueChange)
DropdownMenu.RadioItem (value)
DropdownMenu.Sub (nested submenu)
DropdownMenu.SubTrigger
DropdownMenu.SubContent
```
### Props (Root)
- `open`, `defaultOpen`, `onOpenChange`
### ARIA
- Content: `role="menu"`, `aria-activedescendant`
- Item: `role="menuitem"`
- CheckboxItem: `role="menuitemcheckbox"`, `aria-checked`
- RadioItem: `role="menuitemradio"`, `aria-checked`
- Separator: `role="separator"`
- Trigger: `aria-haspopup="menu"`, `aria-expanded`
### Behavior
- Trigger click opens. ArrowDown on trigger opens and highlights first. ArrowUp opens and highlights last.
- Items: Enter/Space activates (fires `onSelect`). Menu closes after activation unless the item's `onSelect` calls `event.preventDefault()`.
- CheckboxItem: toggles `checked` state on activation.
- RadioGroup/RadioItem: selects the radio value on activation.
- Submenus: ArrowRight on SubTrigger opens submenu. ArrowLeft in submenu closes it and returns to parent. Pointer enter on SubTrigger opens after ~100ms delay.
- Typeahead navigates within current menu level.
- Escape closes current level (submenu first, then root).
### Tests (10)
1. Content has `role="menu"` when open
2. Items have `role="menuitem"`
3. Click trigger opens
4. Enter activates item
5. Escape closes
6. ArrowDown navigates
7. CheckboxItem toggles
8. RadioItem selects
9. Submenu opens on ArrowRight
10. Submenu closes on ArrowLeft
---
## Component 8: ContextMenu
**Directory:** `packages/core/src/components/context-menu/`
**Files:** Same structure as DropdownMenu but with `context-menu-` prefix. Internally reuses the same `createListNavigation` activation mode.
### Anatomy
Same as DropdownMenu. Only difference is the trigger mechanism.
### Trigger behavior
- `onContextMenu` (right-click) on the trigger area opens the menu.
- Menu positions at pointer coordinates (not relative to an anchor element). `createFloating` receives a virtual element at `{ x: event.clientX, y: event.clientY }`.
- Long-press on touch devices opens the menu (via `onPointerDown` with 700ms timeout, canceled by `onPointerMove`/`onPointerUp`).
### ARIA
Same as DropdownMenu. Trigger area does not get `aria-haspopup` (context menus are discoverable by convention, not announced).
### Tests (7)
1. Right-click opens menu
2. Content has `role="menu"`
3. Items have `role="menuitem"`
4. Enter activates item
5. Escape closes
6. ArrowDown navigates
7. Menu positions at click coordinates
---
## Component 9: Menubar
**Directory:** `packages/core/src/components/menubar/`
**Files:** `menubar-context.ts`, `menubar-root.tsx`, `menubar-menu.tsx`, `menubar-trigger.tsx`, `menubar-content.tsx`, `menubar-item.tsx`, `menubar-group.tsx`, `menubar-group-label.tsx`, `menubar-separator.tsx`, `menubar-checkbox-item.tsx`, `menubar-radio-group.tsx`, `menubar-radio-item.tsx`, `index.ts`
### Anatomy
```
Menubar (root — horizontal bar, roving focus between triggers)
Menubar.Menu (wraps one dropdown — its own disclosure state)
Menubar.Trigger (opens this menu)
Menubar.Content (floating menu)
Menubar.Item / .Group / .GroupLabel / .Separator
Menubar.CheckboxItem / .RadioGroup / .RadioItem
```
### Props (Root)
- `loop?: boolean` — wrap trigger navigation, default true
- `orientation?: "horizontal"` — always horizontal (for now)
### ARIA
- Root: `role="menubar"`, `aria-orientation="horizontal"`
- Trigger: `role="menuitem"`, `aria-haspopup="menu"`, `aria-expanded`
- Content, items: same as DropdownMenu
### Behavior
- Triggers use `createRovingFocus` for horizontal arrow key navigation.
- Click trigger opens its menu. When a menu is open, hovering another trigger opens that menu instead (instant switch).
- ArrowRight on the last item of a menu closes it and opens the next menu. ArrowLeft does the reverse.
- Escape closes the current menu and returns focus to its trigger.
- When no menu is open, ArrowRight/Left moves between triggers without opening.
### Tests (8)
1. Root has `role="menubar"`
2. Click trigger opens menu
3. ArrowRight on trigger moves to next trigger
4. ArrowRight in menu opens next menu
5. ArrowLeft in menu opens previous menu
6. Escape closes menu
7. Hover trigger switches open menu
8. Items have `role="menuitem"`
---
## Implementation Phases
### Plan 3: Primitives + Floating Components
Build order:
1. `createFloating` primitive
2. `createListNavigation` primitive (with typeahead)
3. `createRovingFocus` primitive (optional)
4. Tooltip
5. Popover
6. HoverCard
Estimated: ~40 tests
### Plan 4: Collection Components
Build order:
1. Listbox
2. Select
3. Combobox
4. DropdownMenu
5. ContextMenu
6. Menubar
Estimated: ~51 tests
### Package exports
Each new component gets a sub-path export in `package.json`:
`./tooltip`, `./popover`, `./hover-card`, `./listbox`, `./select`, `./combobox`, `./dropdown-menu`, `./context-menu`, `./menubar`
Each new primitive is exported from the existing `./primitives` sub-path (or individual sub-paths if preferred).
---
## Patterns enforced across all components
1. **splitProps** — never destructure SolidJS props
2. **JSDoc** on every exported function/interface
3. **createUniqueId()** for all generated IDs
4. **aria-\* as explicit strings**`"true"/"false"`, never booleans
5. **hidden={expr || undefined}** — never `hidden="false"`
6. **Compound component pattern**`Object.assign(Root, { Part })`
7. **Dual context** — Internal (for parts) + Public (for consumers) on multi-part components
8. **Error messages**`[PettyUI] Component.Part used outside <Component>. Fix: ...`
9. **Consistent anatomy** — every floating/overlay component follows: Root, Trigger, Content, Arrow/Close pattern
10. **Value-based identity** — list items identified by string values, not indices