nuvox 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,554 +1,556 @@
1
- # Building Components on the Nuvox Foundation
2
-
3
- This is the how-to that sits next to `CONTRACT.md`. CONTRACT.md is the
4
- rulebook (why each rule exists). This document is the *procedure* —
5
- what order to build things in, what file goes where, and what each
6
- category of component specifically needs, so you're not re-deriving
7
- the process for every one of the ~44 components you'll eventually
8
- build.
9
-
10
- Every component in the old Nuvox fell into one of six shapes. Each
11
- shape has different requirements beyond the universal ones. Find your
12
- component's category below, but read the **Universal build order**
13
- first — it applies regardless of category.
14
-
15
- ---
16
-
17
- ## Universal build order (every component, no exceptions)
18
-
19
- Build in this order. Don't skip ahead to the React file before the
20
- core file exists — that ordering is itself part of the contract
21
- (Rule: architecture before features, applied at the single-component
22
- scale).
23
-
24
- ```
25
- 1. src/core/<name>.ts — pure logic, no JSX, no DOM
26
- 2. src/react/<Name>.tsx — thin wrapper consuming the core logic
27
- 3. src/react/<Name>.css — CSS variables only, no hardcoded values
28
- 4. tests/<name>.test.tsx — written and RUN before you call it done
29
- ```
30
-
31
- ### 1. Core file (`src/core/<name>.ts`)
32
- Pure functions only. No `import React`, no JSX, ever — this is what
33
- makes the component portable to a future Vue/Angular adapter for free.
34
-
35
- ```ts
36
- import type { Size, Variant } from "./types";
37
-
38
- export interface <Name>Props {
39
- size?: Size;
40
- variant?: Variant;
41
- disabled?: boolean;
42
- // ...component-specific props
43
- }
44
-
45
- export interface <Name>State {
46
- size: Size;
47
- variant: Variant;
48
- isDisabled: boolean;
49
- // ...resolved values
50
- }
51
-
52
- export function resolve<Name>State(props: <Name>Props): <Name>State {
53
- return {
54
- size: props.size ?? "md", // always ??, never || (Rule 3)
55
- variant: props.variant ?? "solid",
56
- isDisabled: props.disabled ?? false,
57
- };
58
- }
59
-
60
- export function get<Name>Classes(state: <Name>State): string {
61
- return ["nx-<name>", `nx-<name>--${state.size}`, `nx-<name>--${state.variant}`]
62
- .filter(Boolean)
63
- .join(" ");
64
- }
65
- ```
66
-
67
- ### 2. React file (`src/react/<Name>.tsx`)
68
- ```tsx
69
- import { forwardRef } from "react"; // Rule 4
70
- import { resolve<Name>State, get<Name>Classes } from "../core/<name>";
71
- import type { BaseComponentProps } from "../core/types"; // Rule 2
72
- import { withThemeOverride, withAccentOverride } from "../theme-engine/NuvoxProvider"; // Rule 5 / 5b
73
-
74
- interface <Name>Props extends BaseComponentProps, /* core props */ {}
75
-
76
- export const <Name> = forwardRef<HTMLElement, <Name>Props>(function <Name>(
77
- { className, style, theme, color, ...rest }, ref
78
- ) {
79
- const state = resolve<Name>State(rest);
80
- const classes = [get<Name>Classes(state), className].filter(Boolean).join(" ");
81
- const el = <div ref={ref} className={classes} style={style}>{/* ... */}</div>;
82
- // theme = shape, color = accent — independent, apply both (Rule 5b).
83
- return withThemeOverride(theme, withAccentOverride(color, el));
84
- });
85
- ```
86
-
87
- ### 3. CSS file (`src/react/<Name>.css`)
88
- Every value is a `var(--x, fallback)`. Never a literal color, radius,
89
- shadow, or duration.
90
- ```css
91
- .nx-<name> {
92
- border-radius: var(--radius-md);
93
- padding: var(--spacing-sm) var(--spacing-md);
94
- background: var(--color-surface);
95
- transition: all var(--motion-durationFast) var(--motion-easeStandard);
96
- }
97
- ```
98
-
99
- **Import it in the `.tsx` file** — `import "./<Name>.css";` near the
100
- top of `<Name>.tsx`. This step is easy to silently skip: the class
101
- names in `get<Name>Classes()` will apply to the DOM either way with
102
- no error from TypeScript, ESLint, or the test suite, so a component
103
- can look completely correct in code review while rendering as an
104
- unstyled, un-themed native element. This exact bug happened to
105
- Button in this project's own foundation — caught only by looking at
106
- the docs site's live `<Preview>` and noticing every variant/theme
107
- rendered identically. Add a `<Preview>` example to the component's
108
- `.mdx` page and actually look at it (`npm run docs:dev`) before
109
- considering a component done — the automated tests don't currently
110
- catch a missing CSS import, since none of them assert on computed
111
- styles.
112
-
113
- ### 4. Tests (`tests/<name>.test.tsx`)
114
- Minimum four, per CONTRACT.md Rule 8 — more if the component is
115
- complex:
116
- ```tsx
117
- it("renders with default props", () => { /* ... */ });
118
- it("scopes a theme/color override to just this component", () => { /* ... */ });
119
- it("forwards its ref", () => { /* ... */ }); // if applicable
120
- it("propagates theme correctly through its portal", () => { /* ... */ }); // if applicable
121
- ```
122
-
123
- Run `npm test` and `npm run typecheck` before moving to the next
124
- component. Not after five components — after each one.
125
-
126
- ---
127
-
128
- ## Category A — Static / display primitives
129
- **Examples:** Badge, Avatar, Divider, Skeleton, Spinner, Progress,
130
- Card, Alert, Tag
131
-
132
- **What makes this category different:** no interactivity, often no
133
- portal, sometimes no ref target at all (Divider).
134
-
135
- **What to keep:**
136
- - Core file still exists even for something this simple — resist the
137
- urge to inline the logic directly in the `.tsx` file. It costs
138
- nothing today and is what makes a future Vue port of Card free.
139
- - `color` is no longer category-specific — it's on `BaseComponentProps`
140
- for every component now (the accent override, see CONTRACT.md Rule
141
- 5b), so every component gets it whether it's in this category or
142
- not. What IS still a legitimate category-A-specific choice is
143
- `radius` as an extra override prop (this was the old codebase's
144
- pattern, on 2/41 components, both in this category) — display
145
- primitives are the case where overriding a single shape property
146
- without a full theme override makes sense. Keep it consistent: if
147
- `Card` gets a `radius` prop, `Skeleton` should offer it the same
148
- way, not a differently-shaped prop.
149
- - **Ref exemption check (Rule 4):** Divider is a legitimate exemption
150
- (no focusable/interactive element). Badge, Avatar, Card are NOT —
151
- they render a real DOM node and should forward.
152
- - No portal, no overlay tests needed.
153
-
154
- **Test minimum:** default render + theme/color override + ref forwarding
155
- (skip ref only for genuine layout-only elements like Divider).
156
-
157
- ---
158
-
159
- ## Category B — Form / input primitives
160
- **Examples:** Input, Textarea, Checkbox, Radio, Toggle, NumberInput,
161
- Slider, OTPInput, PhoneInput, FileUpload
162
-
163
- **What makes this category different:** controlled/uncontrolled state,
164
- a value the consumer cares about, validation/error states, and — this
165
- is the category form libraries actually need refs for.
166
-
167
- **What to keep:**
168
- - **Ref forwarding is non-negotiable here** — this is exactly the
169
- category (Input, Checkbox, etc.) where the old codebase's 1-of-41
170
- problem actually costs real integration pain (react-hook-form,
171
- Formik, imperative `.focus()` on validation error).
172
- - Support both controlled (`value` + `onChange`) and uncontrolled
173
- (`defaultValue`) the same way React's native `<input>` does — don't
174
- invent a third pattern.
175
- - Standard props every component in this category should share:
176
- `value`/`defaultValue`, `onChange`, `disabled`, `error` (or
177
- `invalid`), `placeholder` where applicable. Decide this shape once,
178
- here, rather than letting Input and NumberInput drift (the old
179
- color/radius drift, one category up).
180
- - `aria-invalid` and `aria-describedby` (for an error message) are
181
- part of this category's baseline, the same way Rule 11 sets a
182
- baseline for overlays — worth stating explicitly even though
183
- CONTRACT.md's Rule 11 is scoped to overlays specifically.
184
-
185
- **Test minimum:** default render + ref forwarding (mandatory) +
186
- theme/color override + a controlled-value test (typing/changing updates
187
- what the consumer expects) + an error/invalid-state test.
188
-
189
- ---
190
-
191
- ## Category C — Overlay / portal components (non-composite)
192
- **Examples:** Modal, Drawer, Tooltip, Toast, ContextMenu, Tour
193
-
194
- **What makes this category different:** this is exactly the category
195
- that broke in the old codebase (3 of 15 portal components missing the
196
- theme fix). Treat every rule here as load-bearing, not optional.
197
-
198
- **What to keep:**
199
- - **Always render through `ThemedPortal`, never `createPortal`
200
- directly (Rule 6).** No exceptions, no "this one's simple enough to
201
- skip it" — that reasoning is exactly how Toast and Tooltip broke
202
- last time.
203
- - **Always implement the Rule 11 accessibility baseline**: Escape to
204
- close, focus trap, return focus to trigger, correct ARIA role
205
- (`dialog` for Modal, `alert`/`status` for Toast, etc.). Build this
206
- once as a shared `useOverlayBehavior()` hook and have every
207
- component in this category use it — don't hand-roll focus trapping
208
- five separate times the way the portal-theme fix got hand-rolled
209
- twelve times.
210
- - Toast is a special case within this category: it's a *queue*, not a
211
- single open/closed instance. Its state (the list of active toasts)
212
- lives outside any individual themed subtree by nature — make sure
213
- its `ThemedPortal` anchor is something stable (e.g. the toast
214
- trigger call site, or fall back to whatever element is currently
215
- focused) rather than assuming one obvious anchor the way Popover
216
- can.
217
- - `Tooltip` needs hover AND focus triggers (not just `onMouseEnter`) —
218
- a keyboard user tabbing to a button needs to see its tooltip too.
219
-
220
- **Test minimum:** default render + `ThemedPortal` theme propagation
221
- (mandatory) + Escape-closes + focus-returns-to-trigger + ARIA role
222
- present. This category needs the full Rule 8 floor plus the
223
- accessibility-specific tests — don't stop at four.
224
-
225
- ---
226
-
227
- ## Category D — Composite overlay + input components
228
- **Examples:** Select, Combobox, DatePicker, ColorPicker, Command
229
-
230
- **What makes this category different:** this is exactly what Rule 12
231
- (composition) is for. This category is where the old codebase's
232
- "components know too much" problem would show up hardest if repeated.
233
-
234
- **What to keep:**
235
- - **Compose, don't reimplement (Rule 12).** A `Select` is: an `Input`
236
- or trigger button (Category B/A) + a `ThemedPortal`-based panel
237
- (Category C's pattern) + a shared keyboard-navigable list primitive.
238
- Build that list primitive (`ListBox` — arrow-key navigation, typeahead,
239
- active-descendant ARIA wiring) **once**, and have Select, Combobox,
240
- and Command all use it instead of each implementing arrow-key
241
- handling separately.
242
- - `DatePicker` composes: an `Input` (for the text value) + `Popover`
243
- (for the calendar panel) + a calendar-grid primitive. The calendar
244
- grid itself is a good candidate for a shared internal primitive if
245
- you ever add a date-range picker later — don't decide that now, just
246
- don't make the calendar grid *only* reachable from inside DatePicker
247
- in a way that'd make reuse painful.
248
- - These inherit Category C's overlay requirements in full (ThemedPortal,
249
- `useOverlayBehavior`) *and* Category B's input requirements in full
250
- (ref forwarding on the visible input/trigger, controlled/uncontrolled
251
- value). Don't drop either set of requirements because the component
252
- is "mostly a Select, not really an Input."
253
-
254
- **Test minimum:** everything from both Category B and Category C's
255
- minimums, plus one test proving keyboard navigation through the list
256
- (arrow down moves the active option, Enter selects it).
257
-
258
- ---
259
-
260
- ## Category E — Navigation / structural components
261
- **Examples:** Tabs, Accordion, Breadcrumb, Pagination
262
-
263
- **What makes this category different:** state is about *which item is
264
- active*, not a portal, not a value being typed. Usually no portal at
265
- all.
266
-
267
- **What to keep:**
268
- - **Same `value` + `onChange` (controlled) / `defaultValue`
269
- (uncontrolled) convention as Category B, applied to "which item is
270
- active" instead of "what's typed."** Not `onValueChange` — there is
271
- exactly one controlled-state prop name across this entire library,
272
- regardless of category, and it's `onChange`. (This had to be fixed
273
- once already — Tabs and Accordion shipped with `onValueChange` in
274
- 0.1.0/0.1.1 because this line didn't exist yet. See `tests/no-on-value-change.test.ts`,
275
- which now fails the build if this drifts again.)
276
- - Roving `tabIndex` pattern for keyboard nav within the group (Tabs,
277
- Accordion) — only the active tab is in the natural tab order; arrow
278
- keys move between siblings. This is the same class of "accessibility
279
- baseline" as Rule 11, just for a different interaction shape — worth
280
- treating with the same seriousness even though it's not a portal
281
- component.
282
- - `aria-selected`/`aria-expanded` and matching `role="tablist"` /
283
- `role="tab"` / `role="tabpanel"` etc. — get the ARIA pattern right
284
- once (Tabs) and Accordion's is nearly identical, so build the second
285
- one by extending the first's approach, not from scratch.
286
- - Ref forwarding still applies to the interactive elements (each tab
287
- button, each pagination button) even though the outer container
288
- might not need one.
289
-
290
- **Test minimum:** default render + ref forwarding on interactive
291
- children + keyboard navigation between items + ARIA state updates on
292
- selection change.
293
-
294
- ---
295
-
296
- ## Category F — Complex / stateful components
297
- **Examples:** Table, Treeview, Kanban, Carousel, Timeline, ChatBubble
298
-
299
- **What makes this category different:** these are the components most
300
- likely to become monoliths again if you're not deliberate, because
301
- each one genuinely has a lot of internal state (Kanban was 1,372 lines
302
- in the old codebase — by far the largest).
303
-
304
- **What to keep:**
305
- - **Decompose on paper before writing code (this is Phase 1/Phase 2
306
- from your own original retro, applied per-component now instead of
307
- project-wide).** Before writing `Kanban.tsx`, write down its
308
- sub-pieces: `KanbanBoard`, `KanbanColumn`, `KanbanCard`,
309
- a drag-context hook. Build and test the sub-pieces individually
310
- where possible, the same way `Select` composes `Input` + `Popover` +
311
- `ListBox` in Category D.
312
- - If a sub-piece needs its own portal (a Kanban card being dragged
313
- needs to render above everything, which is effectively an overlay),
314
- it follows Category C's rules too — this is likely where "Kanban
315
- missing the theme fix" happened last time, because the drag layer
316
- wasn't recognized as "a portal component" the way Modal obviously is.
317
- - These components justify the most tests, per Rule 8's "floor, not
318
- ceiling" — expect 15–30 meaningful tests for something like Kanban,
319
- covering drag-and-drop state transitions, keyboard-accessible
320
- reordering (don't ship drag-and-drop as the *only* way to reorder —
321
- that's an accessibility gap, not just a nice-to-have), and the
322
- virtualized-rendering edge cases if the list can get long.
323
- - Table specifically: sorting/filtering state should be lifted to
324
- props the consumer can control (controlled sort state), not trapped
325
- entirely inside the component, the same way Input supports
326
- controlled `value`.
327
-
328
- **Test minimum:** default render + ref forwarding on the outer
329
- container + theme/color override + portal theming for any internal overlay
330
- (drag layer, cell editor, etc.) + a dedicated test per major state
331
- transition specific to the component (drag-drop for Kanban, sort-order
332
- for Table, expand/collapse for Treeview).
333
-
334
- ---
335
-
336
- ## Quick-reference: which categories need what
337
-
338
- | Requirement | A: Static | B: Form | C: Overlay | D: Composite | E: Nav | F: Complex |
339
- |---|:---:|:---:|:---:|:---:|:---:|:---:|
340
- | Core/React split | Yes | Yes | Yes | Yes | Yes | Yes |
341
- | Ref forwarding | Usually | **Always** | Yes | Yes | Yes (children) | Yes |
342
- | `ThemedPortal` | No | No | **Always** | **Always** | No | Sometimes (drag layer, cell editor) |
343
- | `useOverlayBehavior` (Rule 11) | No | No | **Always** | **Always** | No | If it has an overlay |
344
- | Composition over reimplementation (Rule 12) | N/A | N/A | N/A | **Core requirement** | Some | **Core requirement** |
345
- | Controlled/uncontrolled value | No | **Always** | Yes (selection) | Yes | Sometimes (active tab) | Yes |
346
- | Test minimum (beyond Rule 8's 4) | none extra | +controlled-value, +error-state | +Escape, +focus-return, +ARIA role | Category B + C combined | +keyboard-nav, +ARIA state | +per-state-transition |
347
-
348
- ---
349
-
350
- ## Component build checklist (copy this per component)
351
-
352
- ```
353
- [ ] src/core/<name>.ts written — pure logic, ?? not ||, canonical Size/Variant types
354
- [ ] src/react/<Name>.tsx written — forwardRef (unless genuinely exempt),
355
- BaseComponentProps extended, BOTH withThemeOverride AND withAccentOverride
356
- wired (accepting theme/color without applying them is worse than not
357
- accepting them at all)
358
- [ ] src/react/<Name>.css written — every value is var(--x, fallback)
359
- [ ] If it portals: uses ThemedPortal, not createPortal
360
- [ ] If it's an overlay: Escape/focus-trap/ARIA via useOverlayBehavior
361
- [ ] If it composes other components: uses the real component, not a reimplementation
362
- [ ] tests/<name>.test.tsx — Rule 8 floor + category-specific tests, written and RUN
363
- [ ] npm run typecheck — clean
364
- [ ] npm test — full suite still green, not just the new file
365
- [ ] docs-site/content/docs/components/<name>.mdx — import interactive
366
- components from @/components/nuvox-client (not nuvox directly),
367
- use <Preview> for a real live-rendered example, added to that
368
- folder's meta.json, verified via npm run docs:build + grepping the
369
- prerendered HTML for the component's actual class names (a
370
- successful build alone doesn't prove the live preview rendered —
371
- see NUVOX-COMPLETE-REFERENCE.md Part 12)
372
- ```
373
-
374
- ---
375
-
376
- ## Full component list — what each one needs
377
-
378
- All 42 components from the original Nuvox, categorized, with the
379
- requirements from the quick-reference table above applied per
380
- component instead of per category. Use this as the actual build
381
- checklist source — pick a row, build it, check the boxes.
382
-
383
- **Legend:** ✅ required &nbsp;·&nbsp; ❌ not required &nbsp;·&nbsp;
384
- ➖ not applicable &nbsp;·&nbsp; 🟡 conditional / partial &nbsp;·&nbsp;
385
- 🛠️ already built &nbsp;·&nbsp; ⬜ not built yet
386
-
387
- **`theme` and `color` overrides aren't columns here** — unlike the
388
- other requirements, they're universal via `BaseComponentProps` (Rule
389
- 2/5/5b), so every single component below needs both wired, not just
390
- some categories. Missing columns for them isn't an oversight; a
391
- column that's ✅ on all 42 rows wouldn't tell you anything a table is
392
- good at telling you.
393
-
394
- | # | Component | Category | Ref forward | ThemedPortal | Overlay a11y (Rule 11) | Composes (Rule 12) | Controlled value | Status |
395
- |---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
396
- | 1 | Button | B | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
397
- | 2 | Popover | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
398
- | 3 | Avatar | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
399
- | 4 | Badge | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
400
- | 5 | Card | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
401
- | 6 | Divider | A | ✅ *(exempt per Rule 4, forwarded anyway)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
402
- | 7 | Progress | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
403
- | 8 | Skeleton | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
404
- | 9 | Spinner | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
405
- | 10 | Alert | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
406
- | 11 | ChatBubble | A | ✅ | ❌ | ❌ | ➖ | ❌ | ⬜ |
407
- | 12 | Timeline | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
408
- | 13 | Input | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
409
- | 14 | Textarea | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
410
- | 15 | Checkbox | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
411
- | 16 | Radio | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
412
- | 17 | Toggle | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
413
- | 18 | NumberInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
414
- | 19 | Slider | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
415
- | 20 | OTPInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
416
- | 21 | PhoneInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
417
- | 22 | FileUpload | B | ✅ | ❌ | ❌ | 🟡 *(internal trigger button)* | ✅ | 🛠️ Built |
418
- | 23 | Modal | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
419
- | 24 | Drawer | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
420
- | 25 | Tooltip | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
421
- | 26 | Toast | C | 🟡 *(queue, not a single instance)* | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
422
- | 27 | ContextMenu | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
423
- | 28 | Tour | C | ✅ | ✅ | ✅ | ➖ | ❌ | ⬜ |
424
- | 29 | Dropdown | C | ✅ | ✅ | ✅ | 🟡 *(reuses `core/collection` from Category D)* | ❌ | 🛠️ Built *(as `DropdownMenu`)* |
425
- | 30 | Select | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
426
- | 31 | Combobox | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
427
- | 32 | DatePicker | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built *(+ standalone `Calendar`, `core/calendar.ts`)* |
428
- | 33 | ColorPicker | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
429
- | 34 | Command | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built *(as `CommandPalette`)* |
430
- | 35 | Accordion | E | ✅ *(items)* | ❌ | ❌ *(own roving-tabindex baseline instead)* | ➖ | ✅ *(expanded index)* | 🛠️ Built |
431
- | 36 | Tabs | E | ✅ *(tabs)* | ❌ | ❌ *(own roving-tabindex baseline instead)* | ➖ | ✅ *(active tab)* | 🛠️ Built |
432
- | 37 | Breadcrumb | E | ✅ *(links)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
433
- | 38 | Pagination | E | ✅ *(buttons)* | ❌ | ❌ | ➖ | ✅ *(current page)* | 🛠️ Built |
434
- | 39 | Table | F | ✅ | 🟡 *(only if cell editor overlays)* | 🟡 *(only if it has an overlay)* | ✅ | ✅ *(sort/filter state)* | 🛠️ Built |
435
- | 40 | Treeview | F | ✅ | ❌ | ❌ | ✅ | ✅ *(expanded/selected)* | ⬜ |
436
- | 41 | Kanban | F | ✅ | 🟡 *(drag layer)* | 🟡 *(drag layer)* | ✅ | ✅ *(board state)* | ⬜ |
437
- | 42 | Carousel | F | ✅ | ❌ | ❌ *(own baseline: arrow keys, pause-on-hover, not Rule 11)* | ✅ | ✅ *(active slide)* | 🛠️ Built |
438
- | 43 | Rating | B | ✅ | ❌ | ❌ | ➖ | ✅ *(selected value)* | 🛠️ Built *(added outside the original plan — a single-value control, same shape as Slider)* |
439
- | 44 | StatCard | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built *(added outside the original plan — composes `Card` for chrome)* |
440
- | 45 | CodeBlock | A | ✅ *(forwards to the scrollable `<pre>`)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built *(added outside the original plan — tokenizer lives in the shared `core/code-highlight.ts` primitive, not owned solely by this component; `highlighter` prop is a pluggable escape hatch, see that file's header)* |
441
- | 46 | RichTextEditor | F | ✅ *(`RichTextContent` forwards to the contentEditable element)* | ➖ *(the link editor's popover panel portals via the real `Popover`, not directly)* | ➖ *(inherited transitively through `Popover`)* | ✅ *(link editor composes `Popover`, Rule 12)* | ✅ *(`value`/`onChange`, HTML string)* | 🛠️ Built *(added outside the original plan — by far the largest component; deliberately NOT a ProseMirror-style document model, see `core/rich-text.ts`'s header for the v1 scope boundary and the `engine` escape hatch for consumers who need one)* |
442
-
443
- A few things worth noting from the table itself, not just the rows:
444
-
445
- - **Category C has 8 components and all 8 need `ThemedPortal` +
446
- the Rule 11 baseline with no exceptions** — this is the exact
447
- category that produced the 3-missing-out-of-15 bug last time.
448
- If you ever mark a ❌ in either of those columns for a Category C
449
- component, stop and double check it actually belongs in Category A
450
- or E instead.
451
- - **Toast and Dropdown are the two 🟡 rows in Category C** — both
452
- for real structural reasons (Toast is a queue; Dropdown reuses
453
- `core/collection.ts`, Category D's list primitive), not because the
454
- rule is optional for them. Don't let "it's a bit different" quietly
455
- become "it's exempt."
456
- - **Table, Kanban's conditional portal/a11y columns are the most likely
457
- place to repeat the old bug** — a drag layer or cell editor is an
458
- overlay in every way that matters, even though the component's name
459
- doesn't obviously say "overlay" the way Modal's does. Flag it
460
- explicitly in that component's own file comments once you decide
461
- whether it applies.
462
-
463
-
464
- Build roughly in this category order, not alphabetically and not by
465
- whichever component sounds fun that day:
466
-
467
- 1. **Button is settled; Categories A, B, C, D, and E are all proven
468
- out now.** Input, Textarea, Checkbox, Radio, Toggle, and Slider
469
- (Category B) share `core/field.ts`'s disabled/required/invalid/
470
- size shape. Badge, Avatar, Spinner, Divider, Card, Alert, and
471
- Progress (Category A) consistently derive booleans like
472
- "dismissible" or "clickable" from whether a handler was actually
473
- passed (`onDismiss`, `onClick`) rather than adding a redundant flag
474
- prop. Modal, Tooltip, Toast, Dropdown, Popover, and CommandPalette
475
- (Category C) share `useOverlayBehavior`
476
- (Escape/focus-trap/return-focus) and `core/floating.ts` (anchor
477
- positioning). Select, Combobox, and DatePicker (Category D) all
478
- compose across categories rather than picking one — Select and
479
- Combobox both reuse Input's field shape AND Category C's overlay
480
- stack; DatePicker goes one step further and composes Popover
481
- itself (not just Popover's underlying hooks) plus a standalone
482
- Calendar component. Tabs and Accordion (Category E) share
483
- `useRovingFocus`. `core/collection.ts` (arrow-key nav + typeahead)
484
- is the one primitive proven across three different categories at
485
- once — Dropdown (C), Select and Combobox (D), and CommandPalette
486
- (D) all call the exact same functions.
487
- 2. **Category A is down to one: ChatBubble.** Timeline is done — same
488
- proven conventions, no portal/overlay complexity. Skeleton is done;
489
- it also settled a real-world edge case worth carrying forward: a
490
- full-width block built from divs (not a native form element) needs
491
- its own `inline-size: 100%` — nothing in the platform gives it one
492
- for free the way `<input>` gets a UA-stylesheet default width. Any
493
- future component in this shape should declare it explicitly rather
494
- than rely on an ancestor happening to be definite-width.
495
- 3. **Category B is fully built.** OTPInput and PhoneInput both landed
496
- — the ref-forwarding and controlled-value conventions were already
497
- established by Input/Checkbox/Slider/NumberInput/etc. FileUpload is
498
- the reference for Rule 12 inside Category B specifically: its
499
- "Browse" trigger composes the real `Button` rather than hand-styling
500
- a second one, exactly what the `🟡 (internal trigger button)` note
501
- in the table called for.
502
- 4. **Category D is fully built.** ColorPicker landed following the
503
- same "compose Input's field shape + Popover + a new pure-logic
504
- grid/wheel module" shape DatePicker established — Select, Combobox,
505
- and DatePicker had already proven Rule 12 held here.
506
- 5. **The rest of Category C** (Tour) — Drawer and ContextMenu are
507
- done and confirm the prediction: both really were mostly a new
508
- visual shape on `useOverlayBehavior`/`core/floating.ts`'s existing,
509
- tested infrastructure. Drawer is a close copy of Modal.tsx with one
510
- axis added (which edge, and "size" meaning width vs. height
511
- depending on it). ContextMenu is DropdownMenu's sibling — same
512
- portal/keyboard-nav machinery, reusing DropdownMenu's own item
513
- classes and pure logic (Rule 12) rather than duplicating them — with
514
- the one real new piece being a "virtual anchor" (a zero-size element
515
- moved to the click point) that lets `useFloatingPosition` position
516
- relative to a cursor location it was never written to expect,
517
- completely unmodified. Tour is the one left, and will need actual
518
- new choreography (sequencing highlighted-element steps) that neither
519
- Drawer nor ContextMenu needed.
520
- 6. **The rest of Category E** — Breadcrumb and Pagination are done,
521
- and turned out *not* to need `useRovingFocus`: that hook is for a
522
- single widget where only one item belongs in the tab order at a
523
- time (Tabs, Accordion). A breadcrumb trail and a pager are both
524
- just several independently-useful links/buttons in normal tab
525
- order, closer to a browser's own navigation than to a listbox —
526
- worth checking which model actually fits before reaching for
527
- `useRovingFocus` on a future Category E component.
528
- 7. **Category F** — Table and Carousel are both done. Table is the
529
- reference for "compose more than any other category": seven small
530
- pieces on paper first (Table, TableHeader, TableBody, TableRow,
531
- TableHead, TableCell, TableCaption) rather than one component
532
- holding sorting, selection, and layout all at once. Sort state is
533
- controlled by the consumer, the same shape as every value-holding
534
- component in this library; row selection isn't modeled by Table at
535
- all — a "select all"/row checkbox is just a real `Checkbox` (Rule
536
- 12) placed in a cell. Carousel followed the same decompose-first
537
- approach (root, viewport, track, slide, prev/next, indicators) and
538
- owns its own keyboard/pause-on-hover baseline rather than reusing
539
- `useOverlayBehavior`, since it isn't an overlay. Treeview, Kanban,
540
- and ChatBubble remain — each will need its own genuinely new
541
- interaction model the way Table's sort/selection split did, rather
542
- than being a rearrangement of an existing component's pieces.
543
- 8. **Two components shipped outside this table's original plan:**
544
- Rating (Category B — a single-value control, same conventions as
545
- Slider) and StatCard (Category A — composes `Card` for its chrome).
546
- Both follow every rule in `CONTRACT.md` the same as anything on
547
- the original list; they're just proof this table is a starting
548
- roadmap, not a hard ceiling on what Nuvox can become.
549
-
550
- If building a Category D or F component forces a change to the
551
- foundation (`ThemedPortal`, `useOverlayBehavior`, `core/types.ts`),
552
- that's a signal the foundation still has a gap — fix it there, not
553
- with a one-off workaround in the component. That's the whole point of
1
+ # Building Components on the Nuvox Foundation
2
+
3
+ This is the how-to that sits next to `CONTRACT.md`. CONTRACT.md is the
4
+ rulebook (why each rule exists). This document is the *procedure* —
5
+ what order to build things in, what file goes where, and what each
6
+ category of component specifically needs, so you're not re-deriving
7
+ the process for every one of the ~44 components you'll eventually
8
+ build.
9
+
10
+ Every component in the old Nuvox fell into one of six shapes. Each
11
+ shape has different requirements beyond the universal ones. Find your
12
+ component's category below, but read the **Universal build order**
13
+ first — it applies regardless of category.
14
+
15
+ ---
16
+
17
+ ## Universal build order (every component, no exceptions)
18
+
19
+ Build in this order. Don't skip ahead to the React file before the
20
+ core file exists — that ordering is itself part of the contract
21
+ (Rule: architecture before features, applied at the single-component
22
+ scale).
23
+
24
+ ```
25
+ 1. src/core/<name>.ts — pure logic, no JSX, no DOM
26
+ 2. src/react/<Name>.tsx — thin wrapper consuming the core logic
27
+ 3. src/react/<Name>.css — CSS variables only, no hardcoded values
28
+ 4. tests/<name>.test.tsx — written and RUN before you call it done
29
+ ```
30
+
31
+ ### 1. Core file (`src/core/<name>.ts`)
32
+ Pure functions only. No `import React`, no JSX, ever — this is what
33
+ makes the component portable to a future Vue/Angular adapter for free.
34
+
35
+ ```ts
36
+ import type { Size, Variant } from "./types";
37
+
38
+ export interface <Name>Props {
39
+ size?: Size;
40
+ variant?: Variant;
41
+ disabled?: boolean;
42
+ // ...component-specific props
43
+ }
44
+
45
+ export interface <Name>State {
46
+ size: Size;
47
+ variant: Variant;
48
+ isDisabled: boolean;
49
+ // ...resolved values
50
+ }
51
+
52
+ export function resolve<Name>State(props: <Name>Props): <Name>State {
53
+ return {
54
+ size: props.size ?? "md", // always ??, never || (Rule 3)
55
+ variant: props.variant ?? "solid",
56
+ isDisabled: props.disabled ?? false,
57
+ };
58
+ }
59
+
60
+ export function get<Name>Classes(state: <Name>State): string {
61
+ return ["nx-<name>", `nx-<name>--${state.size}`, `nx-<name>--${state.variant}`]
62
+ .filter(Boolean)
63
+ .join(" ");
64
+ }
65
+ ```
66
+
67
+ ### 2. React file (`src/react/<Name>.tsx`)
68
+ ```tsx
69
+ import { forwardRef } from "react"; // Rule 4
70
+ import { resolve<Name>State, get<Name>Classes } from "../core/<name>";
71
+ import type { BaseComponentProps } from "../core/types"; // Rule 2
72
+ import { withThemeOverride, withAccentOverride } from "../theme-engine/NuvoxProvider"; // Rule 5 / 5b
73
+
74
+ interface <Name>Props extends BaseComponentProps, /* core props */ {}
75
+
76
+ export const <Name> = forwardRef<HTMLElement, <Name>Props>(function <Name>(
77
+ { className, style, theme, color, ...rest }, ref
78
+ ) {
79
+ const state = resolve<Name>State(rest);
80
+ const classes = [get<Name>Classes(state), className].filter(Boolean).join(" ");
81
+ const el = <div ref={ref} className={classes} style={style}>{/* ... */}</div>;
82
+ // theme = shape, color = accent — independent, apply both (Rule 5b).
83
+ return withThemeOverride(theme, withAccentOverride(color, el));
84
+ });
85
+ ```
86
+
87
+ ### 3. CSS file (`src/react/<Name>.css`)
88
+ Every value is a `var(--x, fallback)`. Never a literal color, radius,
89
+ shadow, or duration.
90
+ ```css
91
+ .nx-<name> {
92
+ border-radius: var(--radius-md);
93
+ padding: var(--spacing-sm) var(--spacing-md);
94
+ background: var(--color-surface);
95
+ transition: all var(--motion-durationFast) var(--motion-easeStandard);
96
+ }
97
+ ```
98
+
99
+ **Import it in the `.tsx` file** — `import "./<Name>.css";` near the
100
+ top of `<Name>.tsx`. This step is easy to silently skip: the class
101
+ names in `get<Name>Classes()` will apply to the DOM either way with
102
+ no error from TypeScript, ESLint, or the test suite, so a component
103
+ can look completely correct in code review while rendering as an
104
+ unstyled, un-themed native element. This exact bug happened to
105
+ Button in this project's own foundation — caught only by looking at
106
+ the docs site's live `<Preview>` and noticing every variant/theme
107
+ rendered identically. Add a `<Preview>` example to the component's
108
+ `.mdx` page and actually look at it (`npm run docs:dev`) before
109
+ considering a component done — the automated tests don't currently
110
+ catch a missing CSS import, since none of them assert on computed
111
+ styles.
112
+
113
+ ### 4. Tests (`tests/<name>.test.tsx`)
114
+ Minimum four, per CONTRACT.md Rule 8 — more if the component is
115
+ complex:
116
+ ```tsx
117
+ it("renders with default props", () => { /* ... */ });
118
+ it("scopes a theme/color override to just this component", () => { /* ... */ });
119
+ it("forwards its ref", () => { /* ... */ }); // if applicable
120
+ it("propagates theme correctly through its portal", () => { /* ... */ }); // if applicable
121
+ ```
122
+
123
+ Run `npm test` and `npm run typecheck` before moving to the next
124
+ component. Not after five components — after each one.
125
+
126
+ ---
127
+
128
+ ## Category A — Static / display primitives
129
+ **Examples:** Badge, Avatar, Divider, Skeleton, Spinner, Progress,
130
+ Card, Alert, Tag
131
+
132
+ **What makes this category different:** no interactivity, often no
133
+ portal, sometimes no ref target at all (Divider).
134
+
135
+ **What to keep:**
136
+ - Core file still exists even for something this simple — resist the
137
+ urge to inline the logic directly in the `.tsx` file. It costs
138
+ nothing today and is what makes a future Vue port of Card free.
139
+ - `color` is no longer category-specific — it's on `BaseComponentProps`
140
+ for every component now (the accent override, see CONTRACT.md Rule
141
+ 5b), so every component gets it whether it's in this category or
142
+ not. What IS still a legitimate category-A-specific choice is
143
+ `radius` as an extra override prop (this was the old codebase's
144
+ pattern, on 2/41 components, both in this category) — display
145
+ primitives are the case where overriding a single shape property
146
+ without a full theme override makes sense. Keep it consistent: if
147
+ `Card` gets a `radius` prop, `Skeleton` should offer it the same
148
+ way, not a differently-shaped prop.
149
+ - **Ref exemption check (Rule 4):** Divider is a legitimate exemption
150
+ (no focusable/interactive element). Badge, Avatar, Card are NOT —
151
+ they render a real DOM node and should forward.
152
+ - No portal, no overlay tests needed.
153
+
154
+ **Test minimum:** default render + theme/color override + ref forwarding
155
+ (skip ref only for genuine layout-only elements like Divider).
156
+
157
+ ---
158
+
159
+ ## Category B — Form / input primitives
160
+ **Examples:** Input, Textarea, Checkbox, Radio, Toggle, NumberInput,
161
+ Slider, OTPInput, PhoneInput, FileUpload
162
+
163
+ **What makes this category different:** controlled/uncontrolled state,
164
+ a value the consumer cares about, validation/error states, and — this
165
+ is the category form libraries actually need refs for.
166
+
167
+ **What to keep:**
168
+ - **Ref forwarding is non-negotiable here** — this is exactly the
169
+ category (Input, Checkbox, etc.) where the old codebase's 1-of-41
170
+ problem actually costs real integration pain (react-hook-form,
171
+ Formik, imperative `.focus()` on validation error).
172
+ - Support both controlled (`value` + `onChange`) and uncontrolled
173
+ (`defaultValue`) the same way React's native `<input>` does — don't
174
+ invent a third pattern.
175
+ - Standard props every component in this category should share:
176
+ `value`/`defaultValue`, `onChange`, `disabled`, `error` (or
177
+ `invalid`), `placeholder` where applicable. Decide this shape once,
178
+ here, rather than letting Input and NumberInput drift (the old
179
+ color/radius drift, one category up).
180
+ - `aria-invalid` and `aria-describedby` (for an error message) are
181
+ part of this category's baseline, the same way Rule 11 sets a
182
+ baseline for overlays — worth stating explicitly even though
183
+ CONTRACT.md's Rule 11 is scoped to overlays specifically.
184
+
185
+ **Test minimum:** default render + ref forwarding (mandatory) +
186
+ theme/color override + a controlled-value test (typing/changing updates
187
+ what the consumer expects) + an error/invalid-state test.
188
+
189
+ ---
190
+
191
+ ## Category C — Overlay / portal components (non-composite)
192
+ **Examples:** Modal, Drawer, Tooltip, Toast, ContextMenu, Tour
193
+
194
+ **What makes this category different:** this is exactly the category
195
+ that broke in the old codebase (3 of 15 portal components missing the
196
+ theme fix). Treat every rule here as load-bearing, not optional.
197
+
198
+ **What to keep:**
199
+ - **Always render through `ThemedPortal`, never `createPortal`
200
+ directly (Rule 6).** No exceptions, no "this one's simple enough to
201
+ skip it" — that reasoning is exactly how Toast and Tooltip broke
202
+ last time.
203
+ - **Always implement the Rule 11 accessibility baseline**: Escape to
204
+ close, focus trap, return focus to trigger, correct ARIA role
205
+ (`dialog` for Modal, `alert`/`status` for Toast, etc.). Build this
206
+ once as a shared `useOverlayBehavior()` hook and have every
207
+ component in this category use it — don't hand-roll focus trapping
208
+ five separate times the way the portal-theme fix got hand-rolled
209
+ twelve times.
210
+ - Toast is a special case within this category: it's a *queue*, not a
211
+ single open/closed instance. Its state (the list of active toasts)
212
+ lives outside any individual themed subtree by nature — make sure
213
+ its `ThemedPortal` anchor is something stable (e.g. the toast
214
+ trigger call site, or fall back to whatever element is currently
215
+ focused) rather than assuming one obvious anchor the way Popover
216
+ can.
217
+ - `Tooltip` needs hover AND focus triggers (not just `onMouseEnter`) —
218
+ a keyboard user tabbing to a button needs to see its tooltip too.
219
+
220
+ **Test minimum:** default render + `ThemedPortal` theme propagation
221
+ (mandatory) + Escape-closes + focus-returns-to-trigger + ARIA role
222
+ present. This category needs the full Rule 8 floor plus the
223
+ accessibility-specific tests — don't stop at four.
224
+
225
+ ---
226
+
227
+ ## Category D — Composite overlay + input components
228
+ **Examples:** Select, Combobox, DatePicker, ColorPicker, Command
229
+
230
+ **What makes this category different:** this is exactly what Rule 12
231
+ (composition) is for. This category is where the old codebase's
232
+ "components know too much" problem would show up hardest if repeated.
233
+
234
+ **What to keep:**
235
+ - **Compose, don't reimplement (Rule 12).** A `Select` is: an `Input`
236
+ or trigger button (Category B/A) + a `ThemedPortal`-based panel
237
+ (Category C's pattern) + a shared keyboard-navigable list primitive.
238
+ Build that list primitive (`ListBox` — arrow-key navigation, typeahead,
239
+ active-descendant ARIA wiring) **once**, and have Select, Combobox,
240
+ and Command all use it instead of each implementing arrow-key
241
+ handling separately.
242
+ - `DatePicker` composes: an `Input` (for the text value) + `Popover`
243
+ (for the calendar panel) + a calendar-grid primitive. The calendar
244
+ grid itself is a good candidate for a shared internal primitive if
245
+ you ever add a date-range picker later — don't decide that now, just
246
+ don't make the calendar grid *only* reachable from inside DatePicker
247
+ in a way that'd make reuse painful.
248
+ - These inherit Category C's overlay requirements in full (ThemedPortal,
249
+ `useOverlayBehavior`) *and* Category B's input requirements in full
250
+ (ref forwarding on the visible input/trigger, controlled/uncontrolled
251
+ value). Don't drop either set of requirements because the component
252
+ is "mostly a Select, not really an Input."
253
+
254
+ **Test minimum:** everything from both Category B and Category C's
255
+ minimums, plus one test proving keyboard navigation through the list
256
+ (arrow down moves the active option, Enter selects it).
257
+
258
+ ---
259
+
260
+ ## Category E — Navigation / structural components
261
+ **Examples:** Tabs, Accordion, Breadcrumb, Pagination
262
+
263
+ **What makes this category different:** state is about *which item is
264
+ active*, not a portal, not a value being typed. Usually no portal at
265
+ all.
266
+
267
+ **What to keep:**
268
+ - **Same `value` + `onChange` (controlled) / `defaultValue`
269
+ (uncontrolled) convention as Category B, applied to "which item is
270
+ active" instead of "what's typed."** Not `onValueChange` — there is
271
+ exactly one controlled-state prop name across this entire library,
272
+ regardless of category, and it's `onChange`. (This had to be fixed
273
+ once already — Tabs and Accordion shipped with `onValueChange` in
274
+ 0.1.0/0.1.1 because this line didn't exist yet. See `tests/no-on-value-change.test.ts`,
275
+ which now fails the build if this drifts again.)
276
+ - Roving `tabIndex` pattern for keyboard nav within the group (Tabs,
277
+ Accordion) — only the active tab is in the natural tab order; arrow
278
+ keys move between siblings. This is the same class of "accessibility
279
+ baseline" as Rule 11, just for a different interaction shape — worth
280
+ treating with the same seriousness even though it's not a portal
281
+ component.
282
+ - `aria-selected`/`aria-expanded` and matching `role="tablist"` /
283
+ `role="tab"` / `role="tabpanel"` etc. — get the ARIA pattern right
284
+ once (Tabs) and Accordion's is nearly identical, so build the second
285
+ one by extending the first's approach, not from scratch.
286
+ - Ref forwarding still applies to the interactive elements (each tab
287
+ button, each pagination button) even though the outer container
288
+ might not need one.
289
+
290
+ **Test minimum:** default render + ref forwarding on interactive
291
+ children + keyboard navigation between items + ARIA state updates on
292
+ selection change.
293
+
294
+ ---
295
+
296
+ ## Category F — Complex / stateful components
297
+ **Examples:** Table, Treeview, Kanban, Carousel, Timeline, ChatBubble
298
+
299
+ **What makes this category different:** these are the components most
300
+ likely to become monoliths again if you're not deliberate, because
301
+ each one genuinely has a lot of internal state (Kanban was 1,372 lines
302
+ in the old codebase — by far the largest).
303
+
304
+ **What to keep:**
305
+ - **Decompose on paper before writing code (this is Phase 1/Phase 2
306
+ from your own original retro, applied per-component now instead of
307
+ project-wide).** Before writing `Kanban.tsx`, write down its
308
+ sub-pieces: `KanbanBoard`, `KanbanColumn`, `KanbanCard`,
309
+ a drag-context hook. Build and test the sub-pieces individually
310
+ where possible, the same way `Select` composes `Input` + `Popover` +
311
+ `ListBox` in Category D.
312
+ - If a sub-piece needs its own portal (a Kanban card being dragged
313
+ needs to render above everything, which is effectively an overlay),
314
+ it follows Category C's rules too — this is likely where "Kanban
315
+ missing the theme fix" happened last time, because the drag layer
316
+ wasn't recognized as "a portal component" the way Modal obviously is.
317
+ - These components justify the most tests, per Rule 8's "floor, not
318
+ ceiling" — expect 15–30 meaningful tests for something like Kanban,
319
+ covering drag-and-drop state transitions, keyboard-accessible
320
+ reordering (don't ship drag-and-drop as the *only* way to reorder —
321
+ that's an accessibility gap, not just a nice-to-have), and the
322
+ virtualized-rendering edge cases if the list can get long.
323
+ - Table specifically: sorting/filtering state should be lifted to
324
+ props the consumer can control (controlled sort state), not trapped
325
+ entirely inside the component, the same way Input supports
326
+ controlled `value`.
327
+
328
+ **Test minimum:** default render + ref forwarding on the outer
329
+ container + theme/color override + portal theming for any internal overlay
330
+ (drag layer, cell editor, etc.) + a dedicated test per major state
331
+ transition specific to the component (drag-drop for Kanban, sort-order
332
+ for Table, expand/collapse for Treeview).
333
+
334
+ ---
335
+
336
+ ## Quick-reference: which categories need what
337
+
338
+ | Requirement | A: Static | B: Form | C: Overlay | D: Composite | E: Nav | F: Complex |
339
+ |---|:---:|:---:|:---:|:---:|:---:|:---:|
340
+ | Core/React split | Yes | Yes | Yes | Yes | Yes | Yes |
341
+ | Ref forwarding | Usually | **Always** | Yes | Yes | Yes (children) | Yes |
342
+ | `ThemedPortal` | No | No | **Always** | **Always** | No | Sometimes (drag layer, cell editor) |
343
+ | `useOverlayBehavior` (Rule 11) | No | No | **Always** | **Always** | No | If it has an overlay |
344
+ | Composition over reimplementation (Rule 12) | N/A | N/A | N/A | **Core requirement** | Some | **Core requirement** |
345
+ | Controlled/uncontrolled value | No | **Always** | Yes (selection) | Yes | Sometimes (active tab) | Yes |
346
+ | Test minimum (beyond Rule 8's 4) | none extra | +controlled-value, +error-state | +Escape, +focus-return, +ARIA role | Category B + C combined | +keyboard-nav, +ARIA state | +per-state-transition |
347
+
348
+ ---
349
+
350
+ ## Component build checklist (copy this per component)
351
+
352
+ ```
353
+ [ ] src/core/<name>.ts written — pure logic, ?? not ||, canonical Size/Variant types
354
+ [ ] src/react/<Name>.tsx written — forwardRef (unless genuinely exempt),
355
+ BaseComponentProps extended, BOTH withThemeOverride AND withAccentOverride
356
+ wired (accepting theme/color without applying them is worse than not
357
+ accepting them at all)
358
+ [ ] src/react/<Name>.css written — every value is var(--x, fallback)
359
+ [ ] If it portals: uses ThemedPortal, not createPortal
360
+ [ ] If it's an overlay: Escape/focus-trap/ARIA via useOverlayBehavior
361
+ [ ] If it composes other components: uses the real component, not a reimplementation
362
+ [ ] tests/<name>.test.tsx — Rule 8 floor + category-specific tests, written and RUN
363
+ [ ] npm run typecheck — clean
364
+ [ ] npm test — full suite still green, not just the new file
365
+ [ ] docs-site/content/docs/components/<name>.mdx — import interactive
366
+ components from @/components/nuvox-client (not nuvox directly),
367
+ use <Preview> for a real live-rendered example, added to that
368
+ folder's meta.json, verified via npm run docs:build + grepping the
369
+ prerendered HTML for the component's actual class names (a
370
+ successful build alone doesn't prove the live preview rendered —
371
+ see NUVOX-COMPLETE-REFERENCE.md Part 12)
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Full component list — what each one needs
377
+
378
+ All 42 components from the original Nuvox, categorized, with the
379
+ requirements from the quick-reference table above applied per
380
+ component instead of per category. Use this as the actual build
381
+ checklist source — pick a row, build it, check the boxes.
382
+
383
+ **Legend:** ✅ required &nbsp;·&nbsp; ❌ not required &nbsp;·&nbsp;
384
+ ➖ not applicable &nbsp;·&nbsp; 🟡 conditional / partial &nbsp;·&nbsp;
385
+ 🛠️ already built &nbsp;·&nbsp; ⬜ not built yet
386
+
387
+ **`theme` and `color` overrides aren't columns here** — unlike the
388
+ other requirements, they're universal via `BaseComponentProps` (Rule
389
+ 2/5/5b), so every single component below needs both wired, not just
390
+ some categories. Missing columns for them isn't an oversight; a
391
+ column that's ✅ on all 42 rows wouldn't tell you anything a table is
392
+ good at telling you.
393
+
394
+ | # | Component | Category | Ref forward | ThemedPortal | Overlay a11y (Rule 11) | Composes (Rule 12) | Controlled value | Status |
395
+ |---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
396
+ | 1 | Button | B | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
397
+ | 2 | Popover | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
398
+ | 3 | Avatar | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
399
+ | 4 | Badge | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
400
+ | 5 | Card | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
401
+ | 6 | Divider | A | ✅ *(exempt per Rule 4, forwarded anyway)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
402
+ | 7 | Progress | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
403
+ | 8 | Skeleton | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
404
+ | 9 | Spinner | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
405
+ | 10 | Alert | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
406
+ | 11 | ChatBubble | A | ✅ | ❌ | ❌ | ➖ | ❌ | ⬜ |
407
+ | 12 | Timeline | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
408
+ | 13 | Input | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
409
+ | 14 | Textarea | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
410
+ | 15 | Checkbox | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
411
+ | 16 | Radio | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
412
+ | 17 | Toggle | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
413
+ | 18 | NumberInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
414
+ | 19 | Slider | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
415
+ | 20 | OTPInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
416
+ | 21 | PhoneInput | B | ✅ | ❌ | ❌ | ➖ | ✅ | 🛠️ Built |
417
+ | 22 | FileUpload | B | ✅ | ❌ | ❌ | 🟡 *(internal trigger button)* | ✅ | 🛠️ Built |
418
+ | 23 | Modal | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
419
+ | 24 | Drawer | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
420
+ | 25 | Tooltip | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
421
+ | 26 | Toast | C | 🟡 *(queue, not a single instance)* | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
422
+ | 27 | ContextMenu | C | ✅ | ✅ | ✅ | ➖ | ❌ | 🛠️ Built |
423
+ | 28 | Tour | C | ✅ | ✅ | ✅ | ➖ | ❌ | ⬜ |
424
+ | 29 | Dropdown | C | ✅ | ✅ | ✅ | 🟡 *(reuses `core/collection` from Category D)* | ❌ | 🛠️ Built *(as `DropdownMenu`)* |
425
+ | 30 | Select | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
426
+ | 31 | Combobox | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
427
+ | 32 | DatePicker | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built *(+ standalone `Calendar`, `core/calendar.ts`)* |
428
+ | 33 | ColorPicker | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built |
429
+ | 34 | Command | D | ✅ | ✅ | ✅ | ✅ | ✅ | 🛠️ Built *(as `CommandPalette`)* |
430
+ | 35 | Accordion | E | ✅ *(items)* | ❌ | ❌ *(own roving-tabindex baseline instead)* | ➖ | ✅ *(expanded index)* | 🛠️ Built |
431
+ | 36 | Tabs | E | ✅ *(tabs)* | ❌ | ❌ *(own roving-tabindex baseline instead)* | ➖ | ✅ *(active tab)* | 🛠️ Built |
432
+ | 37 | Breadcrumb | E | ✅ *(links)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built |
433
+ | 38 | Pagination | E | ✅ *(buttons)* | ❌ | ❌ | ➖ | ✅ *(current page)* | 🛠️ Built |
434
+ | 39 | Table | F | ✅ | 🟡 *(only if cell editor overlays)* | 🟡 *(only if it has an overlay)* | ✅ | ✅ *(sort/filter state)* | 🛠️ Built |
435
+ | 40 | Treeview | F | ✅ | ❌ | ❌ | ✅ | ✅ *(expanded/selected)* | ⬜ |
436
+ | 41 | Kanban | F | ✅ | 🟡 *(drag layer, cards and columns)* | 🟡 *(drag layer — Escape-to-cancel handled directly, not via `useOverlayBehavior`; see Kanban.tsx's header)* | ✅ | ✅ *(board state)* | 🛠️ Built *(cards are data-driven via `renderCard`, not nested `<KanbanCard>` children; columns stay ordinary JSX children since they never cross a parent boundary, and are drag/keyboard-reorderable via the same value/onChange path — see the docs page)* |
437
+ | 42 | Carousel | F | ✅ | ❌ | ❌ *(own baseline: arrow keys, pause-on-hover, not Rule 11)* | ✅ | ✅ *(active slide)* | 🛠️ Built |
438
+ | 43 | Rating | B | ✅ | ❌ | ❌ | ➖ | ✅ *(selected value)* | 🛠️ Built *(added outside the original plan — a single-value control, same shape as Slider)* |
439
+ | 44 | StatCard | A | ✅ | ❌ | ❌ | ➖ | ❌ | 🛠️ Built *(added outside the original plan — composes `Card` for chrome)* |
440
+ | 45 | CodeBlock | A | ✅ *(forwards to the scrollable `<pre>`)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built *(added outside the original plan — tokenizer lives in the shared `core/code-highlight.ts` primitive, not owned solely by this component; `highlighter` prop is a pluggable escape hatch, see that file's header)* |
441
+ | 46 | RichTextEditor | F | ✅ *(`RichTextContent` forwards to the contentEditable element)* | ➖ *(the link editor's popover panel portals via the real `Popover`, not directly)* | ➖ *(inherited transitively through `Popover`)* | ✅ *(link editor composes `Popover`, Rule 12)* | ✅ *(`value`/`onChange`, HTML string)* | 🛠️ Built *(added outside the original plan — by far the largest component; deliberately NOT a ProseMirror-style document model, see `core/rich-text.ts`'s header for the v1 scope boundary and the `engine` escape hatch for consumers who need one)* |
442
+ | 47 | SearchBar | D | ✅ | ✅ | ✅ *(closeOnEscape: false — same reasoning as Combobox, see its own header)* | ✅ *(reuses `core/combobox.ts`'s ARIA/id helpers directly, Rule 12)* | ✅ *(`value`/`onChange`, plain text — no committed/query split)* | 🛠️ Built *(added outside the original plan — Combobox's shape, but free-text: nothing reverts on blur/Escape, Enter always fires `onSearch` unless a suggestion is highlighted, and `suggestions` is shown as-is rather than filtered internally — see `core/search-bar.ts`'s header for the full contrast with Combobox)* |
443
+ | 48 | ScrollArea | A | ✅ *(forwards to the scrollable viewport, not the outer wrapper)* | ❌ | ❌ | ➖ | ❌ | 🛠️ Built *(added outside the original plan — wraps arbitrary `children` in a custom-scrollbar viewport; `orientation` (`vertical`/`horizontal`/`both`) and `scrollbarVisibility` (`always`/`hover`/`auto`/`never`) are independent props, see `core/scroll-area.ts`'s header)* |
444
+
445
+ A few things worth noting from the table itself, not just the rows:
446
+
447
+ - **Category C has 8 components and all 8 need `ThemedPortal` +
448
+ the Rule 11 baseline with no exceptions** — this is the exact
449
+ category that produced the 3-missing-out-of-15 bug last time.
450
+ If you ever mark a ❌ in either of those columns for a Category C
451
+ component, stop and double check it actually belongs in Category A
452
+ or E instead.
453
+ - **Toast and Dropdown are the two 🟡 rows in Category C** — both
454
+ for real structural reasons (Toast is a queue; Dropdown reuses
455
+ `core/collection.ts`, Category D's list primitive), not because the
456
+ rule is optional for them. Don't let "it's a bit different" quietly
457
+ become "it's exempt."
458
+ - **Table, Kanban's conditional portal/a11y columns are the most likely
459
+ place to repeat the old bug** — a drag layer or cell editor is an
460
+ overlay in every way that matters, even though the component's name
461
+ doesn't obviously say "overlay" the way Modal's does. Flag it
462
+ explicitly in that component's own file comments once you decide
463
+ whether it applies.
464
+
465
+
466
+ Build roughly in this category order, not alphabetically and not by
467
+ whichever component sounds fun that day:
468
+
469
+ 1. **Button is settled; Categories A, B, C, D, and E are all proven
470
+ out now.** Input, Textarea, Checkbox, Radio, Toggle, and Slider
471
+ (Category B) share `core/field.ts`'s disabled/required/invalid/
472
+ size shape. Badge, Avatar, Spinner, Divider, Card, Alert, and
473
+ Progress (Category A) consistently derive booleans like
474
+ "dismissible" or "clickable" from whether a handler was actually
475
+ passed (`onDismiss`, `onClick`) rather than adding a redundant flag
476
+ prop. Modal, Tooltip, Toast, Dropdown, Popover, and CommandPalette
477
+ (Category C) share `useOverlayBehavior`
478
+ (Escape/focus-trap/return-focus) and `core/floating.ts` (anchor
479
+ positioning). Select, Combobox, and DatePicker (Category D) all
480
+ compose across categories rather than picking one — Select and
481
+ Combobox both reuse Input's field shape AND Category C's overlay
482
+ stack; DatePicker goes one step further and composes Popover
483
+ itself (not just Popover's underlying hooks) plus a standalone
484
+ Calendar component. Tabs and Accordion (Category E) share
485
+ `useRovingFocus`. `core/collection.ts` (arrow-key nav + typeahead)
486
+ is the one primitive proven across three different categories at
487
+ once — Dropdown (C), Select and Combobox (D), and CommandPalette
488
+ (D) all call the exact same functions.
489
+ 2. **Category A is down to one: ChatBubble.** Timeline is done — same
490
+ proven conventions, no portal/overlay complexity. Skeleton is done;
491
+ it also settled a real-world edge case worth carrying forward: a
492
+ full-width block built from divs (not a native form element) needs
493
+ its own `inline-size: 100%` — nothing in the platform gives it one
494
+ for free the way `<input>` gets a UA-stylesheet default width. Any
495
+ future component in this shape should declare it explicitly rather
496
+ than rely on an ancestor happening to be definite-width.
497
+ 3. **Category B is fully built.** OTPInput and PhoneInput both landed
498
+ — the ref-forwarding and controlled-value conventions were already
499
+ established by Input/Checkbox/Slider/NumberInput/etc. FileUpload is
500
+ the reference for Rule 12 inside Category B specifically: its
501
+ "Browse" trigger composes the real `Button` rather than hand-styling
502
+ a second one, exactly what the `🟡 (internal trigger button)` note
503
+ in the table called for.
504
+ 4. **Category D is fully built.** ColorPicker landed following the
505
+ same "compose Input's field shape + Popover + a new pure-logic
506
+ grid/wheel module" shape DatePicker established — Select, Combobox,
507
+ and DatePicker had already proven Rule 12 held here.
508
+ 5. **The rest of Category C** (Tour) — Drawer and ContextMenu are
509
+ done and confirm the prediction: both really were mostly a new
510
+ visual shape on `useOverlayBehavior`/`core/floating.ts`'s existing,
511
+ tested infrastructure. Drawer is a close copy of Modal.tsx with one
512
+ axis added (which edge, and "size" meaning width vs. height
513
+ depending on it). ContextMenu is DropdownMenu's sibling — same
514
+ portal/keyboard-nav machinery, reusing DropdownMenu's own item
515
+ classes and pure logic (Rule 12) rather than duplicating them — with
516
+ the one real new piece being a "virtual anchor" (a zero-size element
517
+ moved to the click point) that lets `useFloatingPosition` position
518
+ relative to a cursor location it was never written to expect,
519
+ completely unmodified. Tour is the one left, and will need actual
520
+ new choreography (sequencing highlighted-element steps) that neither
521
+ Drawer nor ContextMenu needed.
522
+ 6. **The rest of Category E** — Breadcrumb and Pagination are done,
523
+ and turned out *not* to need `useRovingFocus`: that hook is for a
524
+ single widget where only one item belongs in the tab order at a
525
+ time (Tabs, Accordion). A breadcrumb trail and a pager are both
526
+ just several independently-useful links/buttons in normal tab
527
+ order, closer to a browser's own navigation than to a listbox —
528
+ worth checking which model actually fits before reaching for
529
+ `useRovingFocus` on a future Category E component.
530
+ 7. **Category F** — Table and Carousel are both done. Table is the
531
+ reference for "compose more than any other category": seven small
532
+ pieces on paper first (Table, TableHeader, TableBody, TableRow,
533
+ TableHead, TableCell, TableCaption) rather than one component
534
+ holding sorting, selection, and layout all at once. Sort state is
535
+ controlled by the consumer, the same shape as every value-holding
536
+ component in this library; row selection isn't modeled by Table at
537
+ all — a "select all"/row checkbox is just a real `Checkbox` (Rule
538
+ 12) placed in a cell. Carousel followed the same decompose-first
539
+ approach (root, viewport, track, slide, prev/next, indicators) and
540
+ owns its own keyboard/pause-on-hover baseline rather than reusing
541
+ `useOverlayBehavior`, since it isn't an overlay. Treeview, Kanban,
542
+ and ChatBubble remain — each will need its own genuinely new
543
+ interaction model the way Table's sort/selection split did, rather
544
+ than being a rearrangement of an existing component's pieces.
545
+ 8. **Two components shipped outside this table's original plan:**
546
+ Rating (Category B — a single-value control, same conventions as
547
+ Slider) and StatCard (Category A — composes `Card` for its chrome).
548
+ Both follow every rule in `CONTRACT.md` the same as anything on
549
+ the original list; they're just proof this table is a starting
550
+ roadmap, not a hard ceiling on what Nuvox can become.
551
+
552
+ If building a Category D or F component forces a change to the
553
+ foundation (`ThemedPortal`, `useOverlayBehavior`, `core/types.ts`),
554
+ that's a signal the foundation still has a gap — fix it there, not
555
+ with a one-off workaround in the component. That's the whole point of
554
556
  having gone through this exercise once already.