@dashforge/tw 0.2.0-beta → 0.3.0-beta

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.
Files changed (41) hide show
  1. package/A11Y.md +130 -0
  2. package/CHANGELOG.md +339 -82
  3. package/CONSUMER-VALIDATION.md +130 -0
  4. package/dist/index.esm.js +351 -47
  5. package/dist/src/components/AppShell/AppShell.d.ts +14 -0
  6. package/dist/src/components/AppShell/AppShell.d.ts.map +1 -1
  7. package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -1
  8. package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -1
  9. package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -1
  10. package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -1
  11. package/dist/src/components/Button/Button.d.ts.map +1 -1
  12. package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -1
  13. package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -1
  14. package/dist/src/components/NumberField/NumberField.d.ts.map +1 -1
  15. package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  16. package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -1
  17. package/dist/src/components/Switch/switch.variants.d.ts.map +1 -1
  18. package/dist/src/components/TextField/TextField.d.ts.map +1 -1
  19. package/dist/src/components/TextField/textField.types.d.ts +29 -3
  20. package/dist/src/components/TextField/textField.types.d.ts.map +1 -1
  21. package/dist/src/components/TextField/textField.variants.d.ts +6 -0
  22. package/dist/src/components/TextField/textField.variants.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +1 -1
  24. package/package.json +3 -3
  25. package/src/components/AppShell/AppShell.tsx +126 -1
  26. package/src/components/AppShell/appShell.variants.ts +8 -2
  27. package/src/components/Autocomplete/Autocomplete.tsx +77 -4
  28. package/src/components/Autocomplete/autocomplete.variants.ts +6 -0
  29. package/src/components/Breadcrumbs/breadcrumbs.variants.ts +5 -0
  30. package/src/components/Button/Button.tsx +11 -0
  31. package/src/components/Checkbox/Checkbox.tsx +63 -5
  32. package/src/components/LeftNav/leftNav.variants.ts +12 -1
  33. package/src/components/NumberField/NumberField.tsx +30 -2
  34. package/src/components/RadioGroup/RadioGroup.tsx +23 -1
  35. package/src/components/Snackbar/snackbar.variants.ts +4 -2
  36. package/src/components/Switch/switch.variants.ts +6 -1
  37. package/src/components/TextField/TextField.tsx +29 -0
  38. package/src/components/TextField/textField.types.ts +23 -3
  39. package/src/components/TextField/textField.variants.ts +18 -0
  40. package/src/index.ts +1 -1
  41. package/LICENSE +0 -21
package/A11Y.md ADDED
@@ -0,0 +1,130 @@
1
+ # A11Y audit — `@dashforge/tw`
2
+
3
+ Per-component accessibility status for every shipped `@dashforge/tw`
4
+ component (24 total). Audit date: 2026-05-17, against the 0.2.0-beta
5
+ codebase (+ the three pending lib fixes for Checkbox / RadioGroup /
6
+ NumberField + the Button `aria-busy` improvement landing in 0.2.1-beta).
7
+
8
+ Reference: [WCAG 2.1 AA](https://www.w3.org/TR/WCAG21/) +
9
+ [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/).
10
+
11
+ ## Summary
12
+
13
+ | Group | Components | Status |
14
+ |---|---|---|
15
+ | Foundation (presentational) | Typography, Box, Stack, Grid, Container, AspectRatio | ✅ N/A — pass-through to HTML semantics |
16
+ | Foundation (a11y primitive) | VisuallyHidden, Divider | ✅ Conformant |
17
+ | Form controls (Radix-backed) | Checkbox, Switch, RadioGroup | ✅ Inherits WAI-ARIA APG from Radix |
18
+ | Form controls (custom) | Button, TextField, Textarea, NumberField, OTPField, Autocomplete, DateTimePicker | ✅ Conformant (see per-row notes) |
19
+ | Layout / Navigation | AppShell, TopBar, LeftNav, Breadcrumbs | ✅ Conformant |
20
+ | Overlays (Radix-backed) | ConfirmDialog | ✅ Inherits from Radix Dialog |
21
+ | Notifications | Snackbar | ✅ Conformant (aria-live polite) |
22
+
23
+ Zero blocking failures. One enhancement landed: Button now exposes
24
+ `aria-busy={loading}` so assistive tech distinguishes loading from
25
+ plain disabled.
26
+
27
+ ## Per-component findings
28
+
29
+ ### Foundation
30
+
31
+ | Component | Status | Notes |
32
+ |---|---|---|
33
+ | **Typography** | ✅ | Renders the variant-appropriate HTML tag (`<h1>`–`<h6>`, `<p>`, `<small>`) so screen-reader heading navigation works out of the box. `as` / `asChild` let consumers re-tag without losing styles. |
34
+ | **Box** | ✅ | Pass-through `<div>` (or `as`/`asChild`-overridden tag). No interactive semantics. |
35
+ | **Stack** | ✅ | Pass-through `<div>`. Display-only flex container. |
36
+ | **Grid** | ✅ | Pass-through `<div>` with `display: grid`. Layout-only. |
37
+ | **Container** | ✅ | Pass-through `<div>` or `<main>` (via `asChild`). Layout-only. |
38
+ | **Divider** | ✅ | Horizontal: `<hr role="separator" aria-orientation="horizontal">`. Vertical: `<div role="separator" aria-orientation="vertical">`. Labeled variant keeps the role on the wrapper + decorative line segments carry `aria-hidden`. |
39
+ | **AspectRatio** | ✅ | Pass-through `<div>` carrying a `style={aspect-ratio: ...}`. No interactive semantics. |
40
+ | **VisuallyHidden** | ✅ | IS the a11y primitive — WebAIM clip technique via Tailwind's `sr-only`. Chooses `<span>` by default (works inside buttons / links without breaking inline flow). |
41
+
42
+ ### Tier-1 form controls
43
+
44
+ | Component | Status | Notes |
45
+ |-------------|--------|-------|
46
+ | **Button** | ✅ | Native `<button>` (or Slot via `asChild`). `disabled` + `aria-disabled` mirrored. **`aria-busy={true}` while loading** (added 2026-05-17) — SR users hear "busy" instead of just "dimmed". Loading spinner is `aria-hidden`. `focus-visible:ring-*` matches the variant color. |
47
+ | **TextField** | ✅ | `<label htmlFor>` via `useId()`. `aria-invalid` on error, `aria-describedby` linked to the helper-text id. Required marker is `aria-hidden` decorative + the real signal is the native `required` attribute. `placeholder:text-neutral-400`. |
48
+ | **Checkbox** | ✅ | Radix `Checkbox.Root` (WAI-ARIA APG). Indicator now uses Radix-native mounting (no React conditional) so checked-state changes from any source — click, programmatic, bridge — propagate to AT. |
49
+ | **Switch** | ✅ | Radix `Switch.Root` (WAI-ARIA APG). `role="switch"` + `aria-checked`. Thumb animation gated on `data-state` — pure CSS, no React state for the visual. |
50
+
51
+ ### Tier-2 / 3 form controls
52
+
53
+ | Component | Status | Notes |
54
+ |-------------------|--------|-------|
55
+ | **RadioGroup** | ✅ | Radix `RadioGroup.Root` + `Item`. `role="radiogroup"` on the container, `role="radio"` + `aria-checked` per option. Group label linked via `aria-labelledby`. Per-option RBAC hide preserves the selected option as disabled (so the user can see what they picked even after a perm change). |
56
+ | **Textarea** | ✅ | Same label + helper-text wiring as TextField. `<textarea>` native element, `resize` axis controlled via CSS. |
57
+ | **NumberField** | ✅ | Native `<input type="number">` — keyboard arrow keys are the accessible way to change the value (browser default). The custom +/− stepper is **intentionally `aria-hidden`** and `tabIndex={-1}` — visible/touch affordance only, with `aria-label="Increment"` / `"Decrement"` for any AT that does walk into hidden subtrees. |
58
+ | **OTPField** | ✅ | One real `<input>` (visually rendered as N segmented slots via decorative `aria-hidden` `<div>`s). `autoComplete="one-time-code"` for SMS autofill on iOS. `inputMode="numeric"` (default) for mobile keyboards. Backspace / Delete / paste all work natively because the underlying element IS a single input. |
59
+ | **Autocomplete** | ✅ | Full WAI-ARIA Combobox pattern: `role="combobox"` + `aria-autocomplete="list"` + `aria-expanded` + `aria-controls` + `aria-activedescendant`. Listbox: `role="listbox"` + per-option `role="option"` + `aria-selected`. Async load row: `aria-live="polite"` + `aria-busy="true"`. Clear / open buttons carry `aria-label`. Chip remove buttons: `aria-label="Remove {label}"`. |
60
+ | **DateTimePicker** | ✅ | Native HTML5 `<input type="date">` / `"time"` / `"datetime-local"`. Browser ships a fully-accessible picker for each. `aria-invalid` + `aria-describedby` wired to the helper text. |
61
+
62
+ ### Layout / navigation
63
+
64
+ | Component | Status | Notes |
65
+ |---------------|--------|-------|
66
+ | **AppShell** | ✅ | `<aside>` for desktop nav, `<aside aria-hidden={!navOpen}>` for the mobile drawer copy (so it doesn't double-announce when hidden). Escape closes the drawer. `<main>` landmark wraps content. `<header>` / `<footer>` are native landmarks. **Known limitation**: no focus trap inside the mobile drawer — focus returns to the toggle button on Escape close, which is acceptable for a docs site / typical web app but not full WAI-ARIA modal-drawer compliance. Can be added in a future revision. |
67
+ | **TopBar** | ✅ | Renders as `<header>` (banner landmark) by default. `asDiv` opt-out for nested layouts where a banner landmark would duplicate. |
68
+ | **LeftNav** | ✅ | `<nav aria-label>` landmark. `aria-current="page"` on the active row. Groups use `aria-expanded` + `aria-controls` linking the header `<button>` to a `role="region" aria-labelledby` panel. Collapse toggle: `aria-pressed` + dynamic `aria-label`. Per-row RBAC hide is matched by an a11y-tree removal (not just visual), so the announced nav matches what the user can actually interact with. |
69
+ | **Breadcrumbs** | ✅ | `<nav aria-label="Breadcrumb">` + `<ol>` (screen readers announce position-in-list). `aria-current="page"` on the last (or `current: true`) crumb. Separators carry `aria-hidden="true"`. Truncation ellipsis: `<span aria-label="More breadcrumbs">`. |
70
+
71
+ ### Overlays + notifications
72
+
73
+ | Component | Status | Notes |
74
+ |------------------|--------|-------|
75
+ | **ConfirmDialog** | ✅ | Radix `Dialog.Root` (WAI-ARIA APG). `role="dialog"` + `aria-modal="true"` + `aria-labelledby` on the title. Radix handles focus trap, focus restoration on close, Escape close, scroll lock. |
76
+ | **Snackbar** | ✅ | Stack is `role="region" aria-label="Notifications" aria-live="polite" aria-atomic="false"`. Polite (not assertive) so notifications don't interrupt the user's screen-reader stream; `atomic="false"` so only the changed item is announced, not the whole queue. Per-item dismiss button: `aria-label="Dismiss notification"`. |
77
+
78
+ ## Cross-cutting concerns
79
+
80
+ - **Focus visibility** — every interactive component carries
81
+ `focus-visible:ring-*` matching its color variant. `outline-none` is
82
+ always paired with a ring so keyboard users never lose the focus
83
+ indicator (regression-tested via the inventory grep:
84
+ `Button / Checkbox / ConfirmDialog / RadioGroup / Switch / TextField
85
+ / Textarea / NumberField / OTPField / Autocomplete / DateTimePicker
86
+ / Breadcrumbs / LeftNav / Snackbar` all have `focus-visible:ring`).
87
+ - **Color contrast** — the design tokens emit foreground/background
88
+ pairs that meet WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large
89
+ text). Independent verification is on the dashforge-tokens roadmap.
90
+ - **Reduced motion** — animations (Switch thumb slide, Snackbar slide-in,
91
+ ConfirmDialog fade) use the default `transition-*` utilities. A
92
+ `prefers-reduced-motion` audit pass is filed as a future enhancement
93
+ (current behavior: animations always play; not a WCAG failure but
94
+ reduces polish for users with motion sensitivity).
95
+ - **Form error association** — every form control with `helperText` +
96
+ `error` wires `aria-invalid` and `aria-describedby` to the helper id
97
+ so the error message is announced alongside the field name.
98
+
99
+ ## Known limitations & future work
100
+
101
+ 1. **AppShell mobile drawer focus trap** — currently relies on Escape
102
+ to close + focus naturally returning to the toggle button. A future
103
+ release can add a real focus trap (probably via `focus-trap-react`
104
+ or hand-rolled).
105
+ 2. **`prefers-reduced-motion` audit** — animations always play. Should
106
+ gate the Switch thumb slide, Snackbar slide-in, ConfirmDialog fade
107
+ on the media query.
108
+ 3. **High-contrast Windows mode** — not explicitly tested. The `border`
109
+ utility renders a 1px border that should survive forced-colors mode,
110
+ but full validation pending.
111
+ 4. **Color contrast regression suite** — should be added to CI so a
112
+ token recolor doesn't silently regress AA compliance.
113
+
114
+ ## Audit methodology
115
+
116
+ - ARIA role / attribute inventory via `grep -nE "aria-[a-z]+=|role=|onKeyDown"`
117
+ per component source.
118
+ - Keyboard handler audit by reading each component's `onKeyDown`,
119
+ `onPaste`, `onFocus`, `onBlur` and tracing into the registered
120
+ bridge / Radix primitive.
121
+ - Focus visibility inventory via `grep "focus-visible:ring"` across
122
+ all `*.variants.ts`.
123
+ - Label association verification: `useId()` → `htmlFor=inputId` +
124
+ `aria-describedby=helperId` chain checked for TextField, Textarea,
125
+ NumberField, OTPField, DateTimePicker.
126
+
127
+ No automated lighthouse / axe scan was run as part of this audit —
128
+ that's a separate CI integration on the roadmap. Manual screen-reader
129
+ verification (NVDA on Windows, VoiceOver on macOS / iOS) should be
130
+ periodically refreshed; results land in this file as they happen.
package/CHANGELOG.md CHANGED
@@ -12,111 +12,368 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
  > duplicated intentionally — no shared "lowest common denominator" headless
13
13
  > layer.
14
14
 
15
+ ## [0.3.0-beta] — 2026-05-18
16
+
17
+ **Sprint 2 release.** Bundle of 9 fixes across 7 components + 1 new
18
+ public API (TextField inline adornments). Two WCAG enhancements
19
+ close known a11y gaps from the 0.2.1 A11Y audit. End-to-end
20
+ consumer validation in the `dash` app (`/test-{foundation,tw,layout,
21
+ providers}`) caught 1 functional bug + 1 cosmetic gap on Autocomplete
22
+ that were both invisible to unit tests + docs lab.
23
+
24
+ **Minor bump because of TextField `slotProps.prefix/suffix`** — new
25
+ public API on the existing `slotProps` surface, strictly additive
26
+ (empty configs add zero layout cost; existing TextField usages keep
27
+ working byte-identical). All other changes are patches that would
28
+ have shipped as `0.2.2-beta` in isolation.
29
+
30
+ ### Added
31
+
32
+ - **TextField inline adornments via `slotProps.prefix` + `slotProps.suffix`** —
33
+ closes a long-standing doc/lib drift where the
34
+ `text-field.mdx` already documented this API but the lib didn't
35
+ expose it. New shape:
36
+ ```tsx
37
+ <TextField
38
+ name="price"
39
+ type="number"
40
+ slotProps={{
41
+ prefix: { children: '$' },
42
+ suffix: { children: 'USD' },
43
+ }}
44
+ />
45
+ ```
46
+ Both slots accept `{ children?: ReactNode; className?: string }`.
47
+ Rendered inside the inputWrapper with `aria-hidden="true"` +
48
+ `pointer-events-none` (purely visual decoration — input remains
49
+ the labeled control, click on adornment doesn't steal focus).
50
+ 21/21 TextField tests still pass.
51
+
52
+ - **AppShell mobile drawer focus trap** (WCAG 2.4.3 Focus Order).
53
+ Hand-rolled (no `focus-trap-react` dep). On drawer open: captures
54
+ `document.activeElement`, moves focus to first focusable inside
55
+ drawer, intercepts `Tab` / `Shift+Tab` to wrap focus within the
56
+ drawer subtree. On close: restores focus to the captured element
57
+ (typically the hamburger toggle). Plus `role="dialog"` +
58
+ `aria-modal="true"` on the drawer `<aside>` while open so screen
59
+ readers announce it as a modal overlay. Verified end-to-end in
60
+ dash: all 5 check points (closed ARIA, open ARIA, focus in,
61
+ tab-wrap, Esc-close) pass.
62
+
63
+ - **`prefers-reduced-motion` gates** on six substantial motions
64
+ (WCAG 2.3.3 Animation from Interactions): Switch thumb slide,
65
+ AppShell drawer slide-in + backdrop fade, Snackbar item enter,
66
+ Autocomplete chevron flip, LeftNav rail-mode width transition.
67
+ Uses Tailwind's `motion-reduce:` variant — the animated end-state
68
+ still applies, only the smooth tween is suppressed for users who
69
+ request reduced motion. Color fades (`transition-colors` on hover
70
+ states) are NOT gated — out of WCAG 2.3.3 scope (vestibular
71
+ concern is translate/rotate/major-state-change, not micro fades).
72
+
73
+ - **Checkbox indeterminate dash glyph**. Previously the Indicator
74
+ rendered `<CheckIcon />` for BOTH the `checked` and `indeterminate`
75
+ Radix states. Now renders `<DashIcon />` (horizontal stroke) for
76
+ indeterminate and `<CheckIcon />` for fully checked. Toggle via
77
+ Tailwind `group-data-[state=…]:hidden` selectors on the Indicator
78
+ — pure CSS, zero React state, Radix `data-state` remains the
79
+ single source of truth. Closes a cosmetic regression introduced
80
+ in 0.2.1-beta when we dropped `forceMount` to fix the indicator
81
+ mount bug.
82
+
83
+ ### Fixed
84
+
85
+ - **Autocomplete `loadOptions` mode — selection now commits** the
86
+ clicked option's label to the input. Previously, in async-loaded
87
+ mode, clicking an option closed the popover but the input kept
88
+ showing the user's search query instead of the selected label.
89
+ Root cause: `commitSelection` searched only the static `options`
90
+ prop (empty `[]` when `loadOptions` is configured) for the label
91
+ lookup, not the effective pool (`asyncOptions` when the fetch
92
+ resolved). The fix uses
93
+ `loadOptions && asyncOptions !== null ? asyncOptions : options`
94
+ and adds those refs to the `useCallback` deps. 38/40 Autocomplete
95
+ tests pass (2 perf-timing flakes unrelated, both >100ms over
96
+ threshold on loaded machine).
97
+
98
+ - **Autocomplete chip remove (×), clear (×), and dropdown caret (▾)
99
+ icons replaced with inline SVG** (`CloseIcon` + `ChevronDownIcon`,
100
+ mirroring CheckIcon's pattern). Previously rendered as Unicode
101
+ glyphs that came out as chunky font characters inconsistent with
102
+ the rest of the design system. Bonus: chevron flips 180° on
103
+ popover open via `[&[aria-expanded=true]>svg]:rotate-180` (CSS-
104
+ only, no React state). Zero icon-library dep added.
105
+
106
+ - **LeftNav `itemLink` + Breadcrumbs `link` slots no longer
107
+ underline by default**. Tailwind's preflight removes the browser-
108
+ default anchor underline globally, but environments that disable
109
+ preflight (e.g. apps coexisting with MUI in the same page tree —
110
+ this is exactly how our docs lab is set up) get the underline
111
+ back. Explicit `no-underline hover:no-underline` on both slots
112
+ makes the appearance consistent regardless of preflight state.
113
+ Same root cause fix covers TopBar too — TopBar typically renders
114
+ Breadcrumbs in its center slot, so fixing the Breadcrumbs link
115
+ fixes TopBar transitively.
116
+
117
+ ### Internal
118
+
119
+ - **`libs/dashforge/tw/CONSUMER-VALIDATION.md`** — Sprint 2 P1
120
+ deliverable. Per-component status table for the dash-consumer
121
+ end-to-end validation pass (24 components, 7-point check each).
122
+ Records the pattern lesson that motivated the Autocomplete
123
+ async fix: the bug was invisible to both unit tests (using
124
+ static `options`) and the docs lab (static demo) — only a real
125
+ consumer with `loadOptions` configured exposed it.
126
+
127
+ ### Compatibility
128
+
129
+ | Compatibility axis | Pre-`0.3.0` | Post-`0.3.0` |
130
+ |---|---|---|
131
+ | Public API surface | unchanged | **+ TextField `slotProps.prefix` / `slotProps.suffix`** (additive — opt-in via slotProps, zero impact on existing usages) |
132
+ | Peer deps | `react ^18 \|\| ^19`, `tw-theme workspace`, `tw-tokens workspace` | unchanged |
133
+ | Bridge deps | `forms` / `rbac` / `ui-core` `workspace:*` | unchanged |
134
+ | Behavior changes that consumers might observe | — | Autocomplete chip remove + clear + caret render as crisp SVG instead of Unicode glyphs (cosmetic); LeftNav + Breadcrumbs links no longer underline in preflight-off environments; Switch / drawer / snackbar / chevron animations respect `prefers-reduced-motion: reduce`; Checkbox indeterminate now shows a dash glyph instead of check |
135
+
136
+ ### Migration
137
+
138
+ No code changes required. Drop-in upgrade from `0.2.1-beta`:
139
+
140
+ ```bash
141
+ pnpm up @dashforge/tw@^0.3.0-beta
142
+ ```
143
+
144
+ To adopt the new TextField adornments, no migration — opt in by
145
+ adding `slotProps={{ prefix: { children: '$' } }}` to any existing
146
+ TextField usage. The two slots are independent (you can use one
147
+ without the other).
148
+
149
+ ## [0.2.1-beta] — 2026-05-17
150
+
151
+ **Hardening release.** Four targeted fixes — three in form-control
152
+ runtime behaviour, one in the Button accessibility contract — surfaced
153
+ while building live-preview demos for the docs site. No public API
154
+ change on any component; strictly additive on the `<Button>` props
155
+ contract (a new `aria-busy` attribute is emitted automatically when
156
+ `loading` is true). Drop-in upgrade from `0.2.0-beta`.
157
+
158
+ Theme of the three form-control fixes: the **same root cause** —
159
+ "controlled-without-an-owner" — under three different surface
160
+ appearances. In standalone uncontrolled mode (no `DashFormProvider`,
161
+ no `value` / `checked` prop, only `defaultValue` / `defaultChecked`),
162
+ each component was sitting in a controlled mode without anyone able
163
+ to update the controlled prop on the user's keystrokes / clicks, so
164
+ React would snap the input right back. The fixes vary by component
165
+ implementation (Radix-backed → discriminated spread of `value` vs
166
+ `defaultValue`; Radix indicator → drop `forceMount` + React
167
+ conditional; native `<input>` → local `useState` for the uncontrolled
168
+ case) but the pattern is identical. A11Y.md (new doc, separate
169
+ commit) documents the broader pattern audit.
170
+
171
+ ### Fixed
172
+
173
+ - **Checkbox** — the indicator's check glyph never appeared when the
174
+ user clicked a Checkbox that was rendered standalone-uncontrolled
175
+ (no `DashFormProvider`, no `checked` prop). The control turned blue
176
+ via `data-[state=checked]:bg-primary-500` but the React-conditional
177
+ `<CheckIcon />` was gated on a stale `resolvedChecked` snapshot.
178
+ Dropped `forceMount` + the conditional; the Radix `Indicator` now
179
+ owns the mount decision, tracking Radix's internal `data-state`
180
+ directly. Mounts in all three modes (controlled, uncontrolled,
181
+ bridge). 14/14 tests pass.
182
+ - **RadioGroup** — clicking a different radio in standalone-uncontrolled
183
+ mode had no visible effect (the selection snapped back to
184
+ `defaultValue`). `<RadixRadioGroup.Root>` was passed `value={…}`
185
+ always, putting Radix in controlled mode against a never-updated
186
+ snapshot. Discriminated spread now picks `value` only in form mode
187
+ or when the consumer explicitly passes `value`; standalone-with-only-
188
+ `defaultValue` uses `defaultValue` so Radix manages its own state.
189
+ 11/11 tests pass.
190
+ - **NumberField** — typing into the input or clicking the +/− stepper
191
+ had no visible effect in standalone-uncontrolled mode, for the same
192
+ reason (controlled `<input value={…}>` with no setter). Added a
193
+ local `useState<string>` seeded from `defaultValue` (mirrors the
194
+ OTPField pattern); `handleChange` + `stepBy` now both update it.
195
+ 8/8 tests pass.
196
+ - **Button** — sets `aria-busy={true}` while `loading`, so assistive
197
+ tech distinguishes "wait for the action to finish" from plain
198
+ "disabled" (which previously was the only signal — same DOM
199
+ attribute regardless of whether the disable came from `loading`,
200
+ `disabled={true}`, or RBAC). 19/19 tests pass.
201
+
202
+ ### Internal
203
+
204
+ - A11Y.md added at package root — per-component status table mapped
205
+ to WCAG 2.1 AA / WAI-ARIA APG. Documents that 23 of 24 components
206
+ were already conformant pre-release (only Button needed the
207
+ `aria-busy` enhancement above). Known non-blocking limitations
208
+ filed: AppShell mobile drawer focus trap, `prefers-reduced-motion`
209
+ pass, color-contrast CI suite, lighthouse/axe automated scan.
210
+
211
+ ### Compatibility
212
+
213
+ | Compatibility axis | Pre-`0.2.1` | Post-`0.2.1` |
214
+ |---|---|---|
215
+ | Public API surface | unchanged | unchanged + `aria-busy` auto-emitted on `<Button loading>` |
216
+ | Peer deps | `react ^18 \|\| ^19`, `tw-theme workspace`, `tw-tokens workspace` | unchanged |
217
+ | Bridge deps | `forms` / `rbac` / `ui-core` `workspace:*` | unchanged |
218
+
15
219
  ## [0.2.0-beta] — 2026-05-17
16
220
 
17
221
  **Foundation release.** Eight layout / structural primitives added on top of
18
- the F3–F7 component catalogue, plus extensive edge-case test hardening across
19
- the whole package.
222
+ the F3–F7 component catalogue (16 components → 24), plus a coverage
223
+ hardening pass bringing the package from **460 → 592 unit tests** across
224
+ **32 files**. End-to-end validated in the `dash` consumer app: mount
225
+ **12.1 ms** / re-render **7–8.6 ms** for a page with 50+ primitive
226
+ instances.
20
227
 
21
- This is a strictly additive minor bump — no breaking changes to the existing
22
- 16 components. Consumers upgrading from `0.1.0-beta` can adopt the new
23
- primitives incrementally; existing code keeps working unchanged.
228
+ **No public API change** on any of the 16 previously-shipped components
229
+ — strictly additive minor bump. Consumers upgrading from `0.1.0-beta`
230
+ can adopt the new primitives incrementally; existing code keeps working
231
+ unchanged.
24
232
 
25
233
  ### Added — Foundation primitives (F9)
26
234
 
27
235
  The `Box ≠ flex`, `Stack = flex 1D`, `Grid = flex 2D` rule is the spine of
28
236
  this layer: each primitive has a single, non-overlapping responsibility so
29
- "which one do I use?" has one answer per scenario.
30
-
31
- - **`Typography`** — semantic typed text. Twelve variants (h1–h6,
32
- subtitle1/2, body1/2, caption, overline) × nine intent colors × five
33
- weight overrides + alignment + truncate / noWrap / gutterBottom. Default
34
- HTML tag inferred from variant (h1→`<h1>`, body1→`<p>`, …), overridable
35
- via `as` or `asChild` (Radix Slot). Reads color from a parent `<Box>` via
36
- `color="inherit"`.
237
+ "which one do I use?" has one answer per scenario. The rule is enforced at
238
+ the TypeScript prop type level — `<Box direction="row">` is a compile error.
239
+
240
+ - **`Typography`** — semantic typed text. Twelve variants (`h1`–`h6`,
241
+ `subtitle1/2`, `body1/2`, `caption`, `overline`) × nine intent colors ×
242
+ five weight overrides + alignment + truncate / noWrap / gutterBottom.
243
+ Default HTML tag inferred from variant (h1→`<h1>`, body1→`<p>`, …),
244
+ overridable via `as` or `asChild` (Radix Slot). Reads color from a
245
+ parent `<Box>` via `color="inherit"`.
246
+ Source: `src/components/Typography/{Typography.tsx, typography.types.ts, typography.variants.ts}`.
247
+
37
248
  - **`Box`** — surface primitive consolidating MUI's Box + Paper + Card +
38
- Surface into one. Five variants (`plain` · `outlined` · `elevated` ·
39
- `soft` · `solid`) × seven intent colors = 21 compound visuals + six
40
- elevation levels (0–5) + token-scale spacing (`p`/`px`/`py`/`m`/`mx`/`my`)
41
- + rounded scale + `fullWidth`/`fullHeight`. Strictly no flex / no grid by
42
- design — wrap in Stack/Grid for layout.
43
- - **`Stack`** — the **only** flex container in `@dashforge/tw`. Direction +
44
- align + justify + token-scale gap + wrap, plus a runtime `divider` prop
45
- that inserts N-1 separators between children (Children.toArray semantics
46
- documented; Fragments count as one child).
249
+ Joy Surface into one. Five variants (`plain` · `outlined` · `elevated` ·
250
+ `soft` · `solid`) × seven intent colors = **21 compound visuals**
251
+ emitted by the TV recipe + six elevation levels (`0`–`5`) + token-scale
252
+ spacing (`p`/`px`/`py`/`m`/`mx`/`my`) + rounded scale +
253
+ `fullWidth`/`fullHeight`. Strictly no flex / no grid by design — wrap
254
+ in Stack/Grid for layout.
255
+ Source: `src/components/Box/box.variants.ts` (compound matrix lives here).
256
+
257
+ - **`Stack`** — the **only** flex container in `@dashforge/tw`.
258
+ `direction` + `align` + `justify` + token-scale `gap` + `wrap`, plus a
259
+ runtime `divider` prop that inserts N-1 separators between children
260
+ (`React.Children.toArray` semantics: Fragments count as one child —
261
+ documented in `Stack.tsx` header + asserted in the test suite).
262
+ Source: `src/components/Stack/{Stack.tsx, stack.types.ts, stack.variants.ts}`.
263
+
47
264
  - **`Grid`** — CSS Grid container + item, polymorphic in role. MUI v2 API
48
- surface (`<Grid container>` + `<Grid xs={6}>`) backed by real CSS Grid
49
- (`display: grid` + `col-span-*`), not flexbox. Discriminated-union
50
- TypeScript — `<Grid container xs={6}>` is a compile error. 70-entry
51
- responsive col-span mapping (xs/sm/md/lg/xl × 1..12/auto/full).
265
+ surface (`<Grid container>` + `<Grid xs={6}>`) backed by **real CSS
266
+ Grid** (`display: grid` + `col-span-*`), not flexbox like MUI v2's own
267
+ internals. Discriminated-union TypeScript: `<Grid container xs={6}>`
268
+ fails compilation. 70-entry responsive `col-span` mapping (xs/sm/md/lg/xl
269
+ × `1..12/auto/full`) in the TV recipe.
270
+ Source: `src/components/Grid/{Grid.tsx, grid.types.ts, grid.variants.ts}`.
52
271
 
53
272
  ### Added — Foundation completions (F10)
54
273
 
55
- Closes the foundation surface to match what Chakra/Mantine/Joy ship at the
56
- layout-primitive level.
274
+ Closes the foundation surface to match what Chakra/Mantine/Joy ship at
275
+ the layout-primitive level.
57
276
 
58
277
  - **`Container`** — centered max-width page wrapper with the canonical
59
278
  responsive padding ramp (`px-4 sm:px-6 lg:px-8`). Six sizes
60
- (sm/md/lg/xl/2xl/fluid) mapped to Tailwind's screen breakpoints +
61
- `centerContent` opt-in for marketing/sign-in layouts.
62
- - **`Divider`** — visual separator with two rendering modes. Line-only
63
- renders `<hr>` with `role="separator"` + `aria-orientation`; labeled
64
- mode (with children) renders the "OR" separator pattern as two flex
65
- segments around the label. orientation / variant (solid/dashed/dotted)
66
- / color (7 intents) / align (start/center/end) axes.
67
- - **`AspectRatio`** — content-shape primitive using the native CSS
68
- `aspect-ratio` property (supported since 2021, ~98% browser coverage).
279
+ (`sm` / `md` / `lg` / `xl` / `2xl` / `fluid`) mapped to Tailwind's
280
+ `max-w-screen-*` aliases + `centerContent` opt-in for marketing /
281
+ sign-in layouts.
282
+ Source: `src/components/Container/{Container.tsx, container.types.ts, container.variants.ts}`.
283
+
284
+ - **`Divider`** — visual separator with two rendering modes selected by
285
+ `children` presence. Line-only renders `<hr>` with `role="separator"` +
286
+ `aria-orientation`; labeled mode renders the "OR" separator pattern as
287
+ two flex segments around the label, with a 32 px stub on the squashed
288
+ side for `align="start"` / `"end"`. orientation × variant
289
+ (solid/dashed/dotted) × color (7 intents) × align (3) axes.
290
+ Source: `src/components/Divider/{Divider.tsx, divider.types.ts, divider.variants.ts}`.
291
+
292
+ - **`AspectRatio`** — content-shape primitive using the **native CSS
293
+ `aspect-ratio` property** (supported since 2021, ~98% browser coverage).
69
294
  Number or CSS-string ratio. Pairs with `sx="rounded-xl overflow-hidden"`
70
- for the canonical clipped media pattern (documented as the #1 gotcha).
295
+ for the canonical clipped media pattern (documented as the #1 gotcha
296
+ in `AspectRatio.tsx` header and the public MDX docs).
297
+ Source: `src/components/AspectRatio/{AspectRatio.tsx, aspectRatio.types.ts}`.
298
+
71
299
  - **`VisuallyHidden`** — the a11y primitive. Uses Tailwind's `sr-only`
72
- (WebAIM clip technique). Hides children from sighted users while keeping
73
- them in the accessibility tree — icon button labels, status announcers,
74
- skip links. Default tag is `<span>` for the 99% case (inline label).
75
-
76
- ### Added — Test coverage hardening (F11-bis)
77
-
78
- - **+132 new edge case tests** across the eight Foundation primitives,
79
- bringing total package coverage from **460 → 592 tests** (32 files).
80
- - **Box (+33)**: all 21 compound variants (outlined / soft / solid × 7
81
- intents) asserted explicitly with light + dark pairs; plain / elevated
82
- color-agnostic invariants; spacing axis coexistence; elevation × variant
83
- interaction; rounded edge values.
84
- - **Grid (+38)**: every responsive breakpoint × representative span (5 ×
85
- 5 = 25), full cascade test (xs→xl), every `autoFlow` value, every `cols`
86
- value, `spacingX`/`spacingY` independence, empty container + orphan item
87
- handling, deep nesting.
88
- - **Stack (+29)**: array divider, conditional / null children, mixed
89
- text + element children, nested Stack with divider key stability, every
90
- gap step (11 token values), every align/justify value (11), empty Stack
91
- with and without divider.
92
- - **Typography, Container, Divider, AspectRatio, VisuallyHidden (+32)**:
93
- multi-axis combinations, full variant catalogues, extreme ratio values,
94
- nested fluid/capped Container pattern, aria-live announcement pattern.
95
-
96
- ### Validated
97
-
98
- End-to-end smoke test in the `dash` consumer app (linked via `file:`
99
- override): all eight primitives mount + render correctly in light and dark
100
- modes; React Profiler shows **mount 12.1 ms / re-render 7–8.6 ms** for a
101
- page with 50+ primitive instances — well within the 60 fps frame budget.
102
- No `React.memo` needed because the primitives are pure (no internal state,
103
- no `useEffect`, only className resolution).
104
-
105
- ### Architecture note
106
-
107
- The `Box ≠ flex` / `Stack = flex` / `Grid = 2D` separation is intentional
108
- and enforced at the type level: passing flex/grid props to `Box` is a
109
- compile error. This rules out the "Box is universal, every `<div>` ends up
110
- as a `<Box display="flex">`" failure mode that drowns the surface-vs-layout
111
- distinction in MUI codebases.
300
+ utility (WebAIM clip technique). Hides children from sighted users
301
+ while keeping them in the accessibility tree — icon button labels,
302
+ status announcers (`aria-live="polite"`), skip links. Default tag is
303
+ `<span>` for the 99% case (inline label inside a button or link).
304
+ Source: `src/components/VisuallyHidden/{VisuallyHidden.tsx, visuallyHidden.types.ts}`.
305
+
306
+ ### Internal
307
+
308
+ - **+132 edge case unit tests** added (`460 → 592` total across `32`
309
+ files). Reorganised here under `Internal` rather than `Added` because
310
+ the tests are not part of the public API surface.
311
+
312
+ - **Box (+33)** — every one of the 21 compound surface variants
313
+ (outlined / soft / solid × 7 intents) asserted explicitly with the
314
+ light + dark pair; plain / elevated color-agnostic invariants;
315
+ spacing axis coexistence (`p` + `px` + `py` × tailwind-merge
316
+ precedence); `elevation` × `variant` interaction; rounded edge
317
+ values. File: `src/components/Box/Box.test.tsx`.
318
+
319
+ - **Grid (+38)** — every responsive breakpoint × representative span
320
+ enumeration (5 × 5 = 25), full cascade test (`xs={12} sm={6} md={4}
321
+ lg={3} xl={2}`), every `autoFlow` value, every `cols` value,
322
+ `spacingX` / `spacingY` independence, empty container + orphan item
323
+ handling, deep nesting (Grid inside Grid item). File:
324
+ `src/components/Grid/Grid.test.tsx`.
325
+
326
+ - **Stack (+29)** — array divider, conditional / null children, mixed
327
+ text + element children, nested Stack with divider, divider key
328
+ stability across re-renders, every gap step (11 token values),
329
+ every `align`/`justify` value (11), empty Stack with and without
330
+ divider. File: `src/components/Stack/Stack.test.tsx`.
331
+
332
+ - **Typography / Container / Divider / AspectRatio / VisuallyHidden
333
+ (+32)** — multi-axis combinations (`variant` + `color` + `weight` +
334
+ `align` + `truncate` + `gutterBottom`), full variant catalogues
335
+ iterated with `it.each`, extreme ratio values (21/9, 9/16, 0.5, 3),
336
+ nested fluid/capped Container pattern, `aria-live="polite"`
337
+ announcer pattern.
338
+
339
+ - **End-to-end consumer validation.** New page
340
+ `~/projects/web/learn/dash/src/pages/TestFoundation.tsx` (linked into
341
+ the `dash` consumer app via a `file:` package override) mounts all
342
+ eight primitives inside `<DashforgeTailwindProvider>` wrapped in a
343
+ React `<Profiler>` with `onRender` logger. Measured: **mount 12.1 ms,
344
+ update 7–8.6 ms** for a page with 50+ primitive instances — within the
345
+ 60 fps frame budget. No `React.memo` applied — the primitives are
346
+ pure (no `useState`, no `useEffect`, only className resolution), so
347
+ React's reconciler trivially handles the re-render.
348
+
349
+ ### Architecture
350
+
351
+ - **`Box ≠ flex`, `Stack = flex 1D`, `Grid = flex 2D`** — single
352
+ responsibility per primitive, enforced at the TypeScript prop type
353
+ level. `Box` exposes no `display` / `flex*` / `grid*` props at all —
354
+ trying to pass them fails compilation. The rule rules out the MUI
355
+ failure mode where every `<Box display="flex" gap={2}>` quietly becomes
356
+ the de facto flex container of the codebase, drowning the
357
+ surface-vs-layout distinction. When you read `<Stack>` in a JSX tree
358
+ you know it's flex without reading any further.
112
359
 
113
360
  ### Compatibility
114
361
 
115
362
  - **Peer deps unchanged**: `@dashforge/tw-theme@^0.1.0-beta` and
116
- `@dashforge/tw-tokens@^0.1.0-beta` (neither was modified).
117
- - **Bridge layer unchanged**: still pinned to `@dashforge/forms` +
118
- `@dashforge/ui-core` + `@dashforge/rbac` at the workspace version.
119
- - **No breaking changes**: existing 16 components' APIs are byte-identical.
363
+ `@dashforge/tw-tokens@^0.1.0-beta` (neither package was modified in
364
+ this cycle). Bridge layer (`@dashforge/forms`, `@dashforge/ui-core`,
365
+ `@dashforge/rbac`) likewise unchanged at the workspace `0.2.3-beta`
366
+ version.
367
+
368
+ - **No breaking changes**: the public API of the 16 previously-shipped
369
+ components is byte-identical. Diff `0.1.0-beta..0.2.0-beta` against
370
+ `src/index.ts` shows only additive exports (the new Foundation
371
+ primitives + their `*Variants` recipes + their `*Props` types).
372
+
373
+ - **Bundle size impact** (gzipped, when fully exercised):
374
+ `dist/index.esm.js` grew from 255 KB to 272 KB (+17 KB / +6.7%) for
375
+ the eight new primitives. Tree-shaking unaffected — consumers only
376
+ pay for what they import.
120
377
 
121
378
  ---
122
379