# Changelog

All notable changes to `pixelize-design-library`.

Versions follow semver and every release ships on the `latest` dist-tag, from `develop`. There are no
prereleases from 3.0.0 onward; earlier `2.4.2-beta.N` entries below are history.

Found a bug or a gap? Report it as a GitHub issue and track it to closure — see
[COMPONENT-GOTCHAS.md](COMPONENT-GOTCHAS.md#found-a-gap-or-a-bug-report-it-then-track-it). A fix's
line below names its issue number.

## [Unreleased]

### Fixed

- **`Table` (windowed, >30 rows) flickered at the bottom of a `tableMaxHeight` scroller.** The scroll limit ignored the
  sticky header, so the clamp stopped one header-height (41px) short of the real bottom and fought the browser's own
  scroll there. Rows now get the viewport minus the header.

## [3.1.0] — 2026-10-23

### Added

- **`Switch` `size`** (`"sm"`, `"md"` or `"lg"`, default `"sm"`) passes through to `SegmentedControl`, so the pill takes the button
  scale (32, 40 and 48px) like Button, Tabs and Dropdown. `"sm"` is the height it already rendered at, so omitted, the output is
  exactly as before. The `/switch` demo gains a Sizes variant, a row beside a field and a button, and a Playground `size` control.
- **`NotificationCenter`** — the bell and its panel for any app. The panel has exactly two tabs, Needs you and Activity, with
  inline Approve and Reject, a six-second Undo, threads, mark-all-read and loading, error, empty, offline and no-access
  states. A popover on desktop and a full-height `variant="sheet"` on phones. The bell shows the server's Needs-you
  count (99+), else a dot for unread, else nothing.
- **`NotificationInbox`** — the full-page inbox: Needs you, Activity and Archived tabs, search, a type filter, unread-only,
  bulk approve, read and archive, a reading pane, and a phone list-to-detail flow. `tab` and `selectedId` can be
  controlled, so a URL can deep-link to one entry.
- **`NotificationPreferences`** — the notification settings page: a type-by-channel grid with locked cells, an optional
  email-summary choice, autosave per cell, and a rollback with a toast when `onChange` rejects.
- **`NotificationCenterProvider` / `useNotificationCenter`** — one optimistic overlay, one undo queue and one polite live
  region shared by every notification component inside it. Pass the source props to a component directly, or to a
  provider and none to the components, so the bell and the inbox move together.
- **`NotificationRow` / `NotificationDetail`** — one entry as a list row (`panel`, `inbox` and `phone` variants) and one
  entry in full, for building your own surface on the same engine.
- **`notificationActionError` / `isNotificationActionError` / `toNotificationActionError`** — reject `onAction` with a kind
  (`conflict`, `forbidden`, `reasonRequired` or `failed`) to get the matching row state, status line and toast. A plain
  HTTP 409 or 403 maps to conflict and forbidden.
- **`defaultNotificationLabels(locale)` / `DEFAULT_NOTIFICATION_LOCALE`** — the English strings every notification component
  falls back to; override any key through `labels`. Dates and plurals use `Intl` with the `locale`.
- Types for all of the above, including `NotificationEntry`, `NotificationAction`, `NotificationCategories` and
  `NotificationSourceProps`.

### Deprecated

Each of these still ships its full implementation and behaves as before.

- **`NotificationPanel`** — use `NotificationCenter` for the bell panel and `NotificationInbox` for the page.
- **`NotificationList`** — use `NotificationInbox`, or `NotificationCenter` for the bell panel.
- **`NotificationItem`** — use `NotificationRow` inside a `NotificationCenterProvider`.

`NotificationBell` stays: `NotificationCenter` wraps it. `Notification` (the countdown card) is unrelated and not deprecated.

### Fixed

- **`SegmentedControl`'s default track is pale on Zinc light.** Zinc's `background[600]` is a mid-grey (`#71717a`), so the
  track rendered dark with near-white labels. A light track now stays no darker than the control outline (`boxborder[500]`),
  so Zinc light uses its own `background[400]` (`#d4d4d8`) with dark labels. The other eight brands and every dark palette
  are unchanged.
- **`TableToggle` gets the same pale track on Zinc light, with a visible hover.** It shares `SegmentedControl`'s track
  step and keeps its hover fill and icons at the same offsets from it: Zinc light now has a `#d4d4d8` track, `#f4f4f5`
  hover and `#71717a` icons. Zinc light's `brandSlide` and `underlineSlide` resting icons were light grey on the white
  canvas (1.60:1) and now clear 3:1. The other eight brands and every dark palette are unchanged.
- **`SideBar` drawer rows use the docked row height, not 44px.** Below `lg`, and on desktop after the footer's Hide, the
  rail rows, pinned rows, group captions, dividers, Add New and the user footer each had taller metrics of their own: rows
  sat 46px apart in the drawer against 34px docked, so a 20-row menu's list was 1108px tall instead of 904px. The drawer now
  renders the docked metrics (32px rows, 34px apart) and every row stays above the WCAG 2.5.8 24px target.
  `SIDEBAR_ROW.overlayHeight` is removed (internal; 0 references in hrms, lfour, tickets, crm-frontend, account-frontend)
  and `RenderMenuType.overlay` is `@deprecated`, now unread.
- **`SideBar` drawer: the `SecondaryBar` no longer overflows the panel on narrow phones.** The panel is capped at 88vw but the
  primary column (14.7rem) plus the secondary's label floor need about 329px, so on a 320px phone the secondary ended
  47.6px past the panel and 9px past the screen, on a 360px phone 12.4px past the panel, and a long label overflowed even
  at 375px (33px) and 390px (20px), pushing its toggle off screen at 320px. In the drawer the secondary now shrinks
  to its icon strip (`SecondaryBarProps.shrinkable`, set by `SideBar`), the primary column gives up the difference
  (`max-width: calc(88vw - 1px - 3.5rem)`), and from 23.3em (about 373px) down it starts as the strip; its header toggle
  stays visible when squeezed. Measured in Chromium emulation at 320, 360, 375, 390, 430 and 844px wide: every width
  now ends at the panel edge in both states; at 375px and wider the default look is unchanged (at 375px the secondary is 0.17px
  narrower, which removes its sub-pixel overlap of the panel border). Docked layout is untouched.

## [3.0.0] — 2026-10-21

The theme-tokens/dark-mode line, shipped as the `2.4.2-beta.N` prereleases (betas carry no per-version headings) and
now the first stable release since 2.3.x. `3.0.0` is the code of `2.4.2-beta.69`. It is a major because the
breaking items under "Breaking in 3.0.0" land together: read them before upgrading from 2.3.x, and run `tsc --noEmit`
in the consumer, which names every affected call site. Consumers pinned to a beta move by changing the pin to `3.0.0`.

### Added

- **`Select` Playground gains `native` and `variant` controls.** The `/select` demo page can now switch between native and popup
  mode and between the four native variants, and its native examples keep the option you pick.

- **Development warning for an unthemed `colorScheme`.** `Tag` (dot), `RadioButton` and every style that resolves a scale
  by name now log one `console.warn` per unknown name outside production, then fall back exactly as before (gray, or
  primary for `RadioButton`). Nothing throws and production is silent.
- **Six prop types reach the package root.** `CopyButtonProps`, `FilterSidebarProps`, `LazyWrapperProps`,
  `ScrollToTopProps`, `FieldSelectModalProps` and `ThemedSwitchProps` (the props of `InputSwitch`) were declared beside
  their exported components but not re-exported. Type-only, additive.
- **`Tag` `removeLabel` and `TagInput` `labels.remove`.** Name the chip remove button in your own language: `removeLabel`
  takes a string or a function of the label, `labels.remove` a function of the tag. Both default to `Remove <label>`, so
  existing output is unchanged.
- **`TextInput` `onRightIconClick`**: the correctly spelt name for the right-icon button handler. It wins when both
  names are set; omitted, the output is exactly as before.
- **`TimePicker` `onClear`**: makes the "--" option pickable once a time is set; choosing it in any select calls `onClear`
  instead of `onChange` (which still only emits a `Date`). Omitted, the output is exactly as before.
- **`Slider` `showPauseControl`** (default off) adds a play/pause button for the auto-advance (WCAG 2.2.2). It shows only
  while the timer can run (`interval` > 0, two or more slides, no reduced motion). Omitted, the output is exactly as before.
- **`formatRelativeTime` takes `{ labels, locale }`.** `labels` ({`justNow`, `minutesAgo(n)`, `hoursAgo(n)`, `daysAgo(n)`})
  replaces the English wording and `locale` sets the short date shown past a week (default `en-US`). Omitted, the output is
  exactly as before.
- **`Tag` and `IconButton` accept any typed `data-*` prop.** The type moved from `Button` to `Common/dataAttributes.ts`; `Tag`
  now puts every `data-*` prop on its root (chip and dot), not only `data-testid`. Omitted, the output is exactly as before.
- **`ChoiceCardGroup` `columns` and `gap`; `ChoiceCard` `isDisabled`.** `columns` (default `1`, today's column) lays the
  cards out in an equal-width grid, `gap` sets the space between them, and `isDisabled` is an alias of `disabled`
  (either disables the card and skips it for the tab stop). Omitted, the output is exactly as before.
- **`VerifyEmailOtp` `subtitle` and `labels`**: `subtitle` replaces the hardcoded second line "your new Pixel Account"
  (`""` drops it); `labels` ({`verificationCode`, `digit(index, total)`, `didntReceive`, `resend`}) replaces the English
  UI text. Both optional; omitted, the text is exactly as before.
- **`WorkspaceTrayPreview` render-prop children**: `{({ triggerProps }) => <button {...triggerProps} />}` hands the one
  focusable trigger `aria-expanded`, plus `aria-controls` and `aria-describedby` pointing at the card while it is open.
  Plain `ReactNode` children work as before and get no ARIA (a bare wrapper `div` cannot hold `aria-expanded`).
- **`Select` (`native={false}`) gains nine additive props, all default-off.** Search: `searchFields`
  (`["label","description"]` makes the description searchable) and `filterOption(option, query)` (replaces the
  substring match). Multi: `isSelectAll` (Select All / Unselect All row over the rows the filter shows, skipping
  disabled ones), `chipMaxLength`, `isChipRemovable`. Trigger: `rightIcon` (static, or a labelled button via
  `onClick` + `ariaLabel`) and `isFieldLoading` (spinner in the trailing slot, `aria-busy`). Create: `createWhen`
  (`"no-exact-match"` offers Create beside partial matches) and `footerAction` (a persistent last row that calls
  `onSelect(query)` and closes). The Select All, Create and footer rows are keyboard stops.
- **`TagInput` `labels`**: translate the rejection messages (`maxTags`, `duplicate`, `invalid`) and the live-region announcements
  (`added`, `removed`); omitted keys keep today's English text. The chips' `Remove <tag>` buttons are named by `Tag` and stay English.

- **`FilteredEmptyState`, and `QueryStateBoundary` `isFiltered` / `filteredEmpty`.** The "nothing matches your filters" panel:
  `filterCount`, `onClearFilters`, `onRemoveLastFilter` (+ `removeLastFilterLabel`, `clearLabel`), `scale` `"page" | "card"`,
  `title`, `description`. On the boundary, `isEmpty` + `isFiltered` shows it instead of `empty`/`emptyFallback`; omitted, the
  boundary is unchanged.

- **`ConfirmDialog` consequences.** `entityName` / `entityKind` (default title `Delete leave type "Sabbatical"?`), `dependents`,
  `consequence` (defaults to "This cannot be undone." with `entityName`), `alternative` (`label`, `description`, `onSelect`,
  `isLoading`; Confirm turns outline, focus still opens on Cancel) and `isConfirmDisabled`. `iconBg` / `iconColor` are now
  optional and derive a soft tint from `tone`; `title` is optional when `entityName` is set. Existing call sites render as before.

- **`PIXELIZE_SUITE`, `resolveSuiteApps` and `buildSuiteApps` `devFallbackBaseUrl`.** One catalogue of the six suite apps with
  per-app `preRelease` and `overrides`; `devFallbackBaseUrl` lets a plain-`localhost` host derive the other apps from a labelled
  dev URL instead of throwing (the throw stays the default).

- **`RangeDatePicker` `presets`**: `{ label, range: { from, to } }[]` replaces the five built-in shortcuts; `[]` hides the row.
  Omitted, the list is unchanged.

- **`WorkspaceTray` is usable over an open modal.** It is portalled above the modal layer, labelled as a live region
  (so Chakra's modal aria-hidden skips it) and outside the focus trap, so a window minimised over a wizard can be
  restored by pointer without closing the wizard; Tab does not reach the tray until the modal closes. New props:
  `placement` (`"bottom-end"` | `"bottom-start"`), `offset`,
  `closeLabel(item)`, `dirtyLabel`, `label`; `WorkspaceTrayItem` gains `isDirty` (unsaved dot) and `subtitle`.
  `WorkspaceWindow` gains `labels` ({minimize, close, openFullPage}) and `finalFocusRef`.
  The tray now renders in a portal, so a test that queries inside its render container must query `document.body`.
- **`WorkspaceTray` `renderItem` receives the built-in pill** as a third argument, `renderItem(item, api, pill)`: the pill
  already carries the unsaved dot, subtitle and close button, so a consumer can return it or wrap it (in
  `WorkspaceTrayPreview` for a hover preview, say) instead of rebuilding it. Two-argument callbacks and the default
  pill are unchanged.
- **`FormDrawer` `isSubmitDisabled`**: disables the primary button and makes the form ignore Enter and any other submit,
  so `onSubmit` never fires, not even from `requestSubmit()` or a dispatched submit event (the drawer is a real
  `<form>`; the button alone does not guard those). Unlike `isSubmitting` it shows no spinner, and Cancel and every
  close path stay live. Omitted, nothing changes.
- **`WizardDialog` `chrome="inline"`** (default `"modal"`): draws the wizard (title, stepper, step, review, footer) in place
  with no Modal, for a host that is already the dialog, such as a `WorkspaceWindow`: no overlay, focus trap, close X,
  Esc-to-close or `role="dialog"`. It renders nothing while `isOpen` is false, ignores `size`, and Cancel still calls
  `onClose`. It reuses the same flow as the modal (`useWizardFlow`, `validate`, `finishDisabled`, `finishBlockedReason`,
  `stepId`); test ids are `wizard-inline` / `${data-testid}-inline`. `Wizard` does not take the prop.
- **`optionRowFill(rows, isActive)`** and **`optionRowCursorSx(rows)`** are exported from the package root: the one
  fill an option row paints (the cursor's), and its `:hover`/`:focus` form for roving-focus lists.
- **`accentTextOnPlanes(scale, planes)`** is exported from the package root: the first accent rung clearing AA on every
  plane in the list, for chrome that can land on the canvas, a card or an overlay.
- **`ruleOn(colors, plane)`** and **`MOTION_PRESS_SCALE`** are exported from the package root. `ruleOn` draws a divider at the
  in-card weight on any plane (`cardRule` is `ruleOn` on `background[50]`); the press scale was only reachable by deep import.
- **`Card variant="elevated"`**: borderless, `background[50]` lifted by `shadows.card`; declares a 0 border width so it
  never inherits an outline parent's rule.
- **`bandOn`, `recessOn`, `cardRule`**: plane-step helpers that return the input unchanged in light and a visible step
  off the dark plane a fill or rule sits on (`raisedRule` now matches `cardRule`'s weight).
- **`buildDarkPalette`** takes an optional `{ planes: "lifted" | "flat" }` and `buildSurfaceRaised` an optional list of
  filled marks to keep at 3:1.
- **`formatDate` / `formatDateTime` / `formatDateRange`**: Intl-based date text, en-IN by default ("09 Oct 2026"),
  with `locale`, `timeZone`, `year: "never"`, `time: "12h" | "24h"` and a `fallback` (default "—") for null or
  invalid input instead of "Invalid Date". A bare `YYYY-MM-DD` is that calendar day in every zone; ranges write a
  shared month and year once. Output is the same across browser and Node ICU versions (no "Sept", no thin or
  narrow spaces).
- **`TimePicker`**: inline hour, minute and AM/PM selects as a field (label, required, error, helper), with
  `use24Hour`, `minuteStep`, `baseDate` and `name`. Same look as DatePicker's time selects. `TimePickerProps` now
  describes this component; the old `date`, `dateFormat` and `disabled` fields still type-check as deprecated
  aliases.
- **`TagInput`**: free-text chips. Enter or comma adds, Backspace removes the last, a pasted list splits on commas
  and new lines; optional `validate`, `maxTags` and `allowDuplicates`. Same look as `MultiSelect`, without its
  email lock.
- **`IconButton`** is now owned: it renders exactly what the Chakra `IconButton` rendered (same props, theme,
  sizes `xs 24 · sm 32 · md 40 · lg 48 · xl 56`, `isLoading` keeps the box) and, in development, warns once per
  mount when it has neither `aria-label` nor `aria-labelledby`. `aria-label` is optional in its type.
- **`FormField`**: label, required asterisk, helper text and error message around any one control. A single
  child element receives `id`, `aria-labelledby`, `aria-describedby`, `aria-invalid` and `aria-required`, or pass
  a function that places them on a composite. `TextInput` inside inherits the error and required state.
  `layout="horizontal"` puts the label in a `labelWidth` column (default 12rem) from `md` up. Root attributes
  and `ref` land on the root `div`.
- **`VisuallyHidden`**: text for screen readers only, pinned to the viewport so a hidden live region can never
  extend a scrolled page. It takes `as`, `ref`, every attribute and style prop.
- **`SkipLink`**: a "Skip to main content" link that paints nothing until keyboard focus, then shows top-left on
  the `skipLink` layer. Activating it focuses `#targetId` (default `main-content`) without changing the URL hash.
- **`Pagination`**: standalone, controlled page navigation. Numbered pages collapse behind ellipses (first
  and last always show, `siblingCount` defaults to 1), with previous and next buttons and an optional
  rows-per-page picker (`pageSizeOptions` + `pageSize` + `onPageSizeChange`). `page` is 1-based; the count
  comes from `pageCount` or from `totalItems` + `pageSize`. `size` follows the Button scale, `isDisabled`
  disables the pager, `labels` overrides the names. It renders a named `nav` of real buttons and the
  current page carries `aria-current="page"`. `Table` keeps its own pager unchanged.
- **`ChoiceCard` / `ChoiceCardGroup`**: radio cards for picking one of a few options that each need a line of
  explanation. Group: `value` / `defaultValue` / `onValueChange`; card: `label`, `description`, `icon`,
  `disabled`. One tab stop; arrows, Home and End move focus and selection. Same look as `PaymentOption`.
  `Pagination` and `ChoiceCard`: reviewed on screen by the owner 2026-10-15, take as-is.
- **Plain HTML attributes on the page primitives**: `PageHeader`, `StatCard`, `SaveBar`, `EmptyState`,
  `DescriptionList`, `FilterBar` and `Skeletons` pass `id`, `className`, `style`, `data-*`, `aria-*` and
  event handlers to their root element; the component's own props still win and Chakra style props are
  still not accepted.
- **`ref` forwarding**: `Checkbox`, `RadioButton`, `ToggleSwitch` and `DatePicker` (single, range and time,
  including the deep-imported `SingleDatePicker` / `RangeDatePicker` / `TimeOnlyPicker`) forward to their
  native `<input>`; `SegmentedControl` to its `role="radiogroup"` root; `Tab` to its `role="tab"` button;
  `Avatar` and `Tag` to their root element.
- **`PinInput` `error`, `errorMessage`, `helperText` and `isDisabled`**: the message renders under the
  cells and every digit gets `aria-invalid` / `aria-describedby`.
- **`WizardDialog` / `Wizard` `finishDisabled` and `finishBlockedReason`**: disable Finish on the last
  step (a pending upload, say) and show why beside it; the reason is Finish's `aria-describedby`.
  Back and the stepper keep working; nothing changes when they are not passed.
- **`CircularProgress` `size` takes a px number** (clamped to 24..160) as well as `sm | md | lg`; stroke
  and label scale with it (`CircularProgressDiameter` type). The presets render exactly as before.
- **Token foundation** (web premium/perf campaign; nothing changes on screen until you use them):
  `RADIUS_ROLE` (radius by surface role), `BORDER_WIDTHS` (px hairline), `DISABLED_OPACITY` (0.5),
  `NUMERIC_TEXT` (tabular figures), `zIndices.popoverNested` (1510), `fontSizes["xs-minus"]` (11px) and
  `fontSizes["xs-plus"]` (13px), and `TEXT_STYLES` bound as Chakra `textStyles` (`title`, `heading`,
  `subheading`, `body`, `ui`, `label`, `caption`, `overline`). Motion: `MOTION_MS.enter` / `.exit`
  (200ms), `MOTION_BEZIER`, `MOTION_EASE_OUT`, `MOTION_EASE_IN`, `MOTION_LOOP_MS`, `motionTransition()`;
  `transitionFor` takes an optional curve. `shadowsFor(mode)` returns the shadow ramp a theme carries.
  Opt-in `reducedMotion="global"` on `PixelizeThemeProvider` and `createBrandTheme` adds one
  document-wide `prefers-reduced-motion` rule; `pz-motion-keep` (`MOTION_KEEP_CLASS`) opts an element out.
  The default is unchanged. `CustomThemeProps` gains `zIndices.popoverNested` and the two font sizes, so
  code that hand-builds a full `CustomThemeProps` needs those keys.
- **`LauncherSearchEngine.matches` and `.createMatcher`**: the boolean form of the engine for table and list filters.
  `matches(query, ...texts)` is true when every query word hits some text (same folding, typo tolerance and `synonyms`
  option as `search`, no ranking); `createMatcher(query)` compiles the query once for a loop over rows, and an empty query
  matches every row. `LauncherMatcher` is exported as a type.
- **`LauncherSearchEngine` searches Hindi and Tamil by written letter**: Latin accents, the Devanagari nukta and
  zero-width joiners are ignored, chandrabindu folds into anusvara, and every other combining mark stays part of its
  letter, so a bare vowel sign never matches ("का" does not find "कमाई"). Typo length and distance count letters
  ("कम्मचारी" finds "कर्मचारी"), and a Tamil word ending in ம் also finds titles that drop it ("நேரம்" finds "நேர அட்டவணை").
  `editDistance` accepts arrays of letters. Latin results are unchanged. `engine/conformance.fixture.json` is the
  behaviour contract shared with HRMS mobile search, asserted by `engine/conformance.test.ts`.
- **`NavLauncher` spotlight search** (all additive; an app passing none of these renders as before):
  - `searchMode="spotlight"` ranks every row by relevance (exact > prefix > word > word-prefix >
    initialism > substring > typo > subsequence, label > keyword > context), tolerates typos
    (Damerau-Levenshtein 1 from 4 letters, 2 from 8), folds case and accents, expands `synonyms`
    both ways, boosts `pinnedIds` / `frequentIds` / `recentIds`, and shows the best row first as a
    **Top hit** card naming the keyword or fact that matched. Groups collapse to `groupLimit` rows
    (default 5 in spotlight, unlimited in classic) behind a "Show N more" option.
  - `scopes` + `activeScope` / `onScopeChange`: filter chips in the header row beside the field
    (their own row below `md`), always shown so they say what can be searched; while typing each
    chip shows its live match count and is named "People, 3 results". One Tab stop (radiogroup,
    roving tabindex, arrows / Home / End); the strip scrolls sideways behind an edge fade with a
    mouse page arrow and keeps the selected chip in view, so the field never drops below 15rem (or a
    third of a wide row). "All" is prepended, `screens`
    and `actions` are built in and `LauncherResultGroup.scope` (default its `id`) places any other
    group. A chip other than All / Screens picked with an empty field lists that scope's items as
    passed, with the facts pane; `LauncherScope.placeholder` narrows the field's placeholder.
  - `quickActions`: up to 5 pill buttons at the top of the menu while the query is empty and the
    scope is All.
  - `LauncherSearchItem.facts` (`{ label, value, copyable? }`): a right preview pane at `lg`+ for the
    highlighted row with Copy buttons (blocked clipboard selects the value instead), docked under the
    list below `lg`. `answers` (items with `answer: { label, value, hint? }`) render as direct-answer
    cards above every result; Enter copies an answer that has no `run` / `href`.
  - `LauncherResultGroup.matchLocally`: the launcher filters and ranks a static group itself.
  - `LauncherSearchEngine` — the framework-free engine behind it (`createIndex`, `search`,
    `searchDocuments`, `highlightRanges`, `usageBoosts`, `foldText`, `tokenize`, `editDistance`; options
    `synonyms`, `boosts`, `fuzzy`, `relativeCutoff`, `limit`) for any other list or palette; `highlightSegments` now exported, with an opt-in `{ fuzzy, synonyms }`.
  - New optional `LauncherLabels` keys: `topHit`, `matchedOn`, `answers`, `hintCopy`, `scopes`,
    `scopeAll`, `scopeChanged`, `scopeCount`, `scopeEmpty`, `quickActions`, `showMore`, `preview`,
    `copy`, `copied`, `copySelected`.
  - The dialog is now always named `labels.title`; the search value and `headerActions` no longer
    join its accessible name.
  - Types `LauncherFact`, `LauncherAnswer`, `LauncherScope`, `LauncherSearchMode`, `HighlightOptions`,
    `LauncherSearch*` / `LauncherSynonyms` / `LauncherMatchKind` / `LauncherTokenMatch`.
- `CopyButton` `onCopyError` — fires when the clipboard is missing or denied, instead of failing silently.
- `Drawer` `aria-label` / `aria-labelledby` — name the dialog instead of its whole header (a header
  holding a field or buttons otherwise becomes the name); `contentProps["aria-labelledby"]` never
  worked.
- **HRMS library requests** — a navigation launcher, key caps, a merge-field `Editor`, a wizard and
  smaller gaps found by the HRMS migration:
  - `NavLauncher` — full-height navigation launcher (browse modules, pins, Frequent, Recent, search
    with actions and remote groups, optional suite-apps section below `md`), with `PinToggle`,
    `NewTabLink`, `searchLauncher`, `searchLauncherSections` (sectioned search: modules, then their
    screens, keyword-only hits get a `· keyword` hint) and `flattenScreens`. The pin control is a
    star, shown with the ↗ link on row hover/focus only (always on touch); pinned rail items are tiles. It never opens itself or binds a global chord (Cmd/Ctrl+K and `/` only refocus its field while it is open). `LauncherTrigger`
    is the chip (10px radius, 36px, accent ink, one ⌘K hint) that opens an app-owned launcher (`aria-haspopup="dialog"`, `aria-expanded`,
    `aria-keyshortcuts`); `display="icon"` is a 36px icon-only variant for a nav bar's `moreIcon`
    (label is the accessible name, tooltip "Menu (⌘K)"). Types: `NavLauncherProps`, `LauncherScreen`, `LauncherModule`,
    `LauncherSection`, `LauncherSearchItem`, `LauncherLabels` and friends, `LauncherTriggerProps`.
    `NavLauncher` follows canvas `Launcher.dc.html`: from `lg` up, stacked sections with "(n)" counts and a
    1/2/3-column grid beside a "Quick access" rail of `clamp(20rem, 30%, 26rem)` in a panel whose `maxWidth`
    defaults to 1080px (Pinned tiles, Frequently visited, Recent). Below `lg` (phone and tablet) the body is a
    Menu / Quick access / Apps tab bar (labels `tabsLabel`, `tabMenu`, `tabQuickAccess`, `tabApps`; Apps follows
    `appsVisibility`) above one scrolling panel with only the selected panel mounted, rows are 44px, and search
    keeps the stacked results plus rail with no tab bar. The header is the search field (fills the row; 44px at
    `md`+, 40px below, 16px type), a muted live result count (`labels.resultsCount`, with a separate polite
    announcement), optional `headerActions` (an app's own icon button) and close; there is no "Menu" pill and
    `labels.title` names the dialog and the results list. "Pins follow your account" footer; its key hints
    are key caps (arrows, ENTER, ⌘ ENTER / CTRL ENTER, ⌘ + click, P, ESC) and are hidden on touch devices
    (`labels.hintClickNewTab`; the ↗ link's tooltip is `labels.newTabTip`, `NewTabLink` takes `tip`). New optional props `frequentLimit`, `suggestions` (no-results chips), `searchHint`,
    `LauncherModule.isNew`, and labels `quickAccess`, `newBadge`, `screens`, `pinShort`, `footerNote`,
    `suggestionsHint`, `found`, `pinTip`, `unpinTip`. The rail landmark is named "Quick access".
    Keyboard: in the field, the first result is active as soon as there is a query, ↑/↓ follow the
    visual rows of the result grid, → at the end of the text enters the grid and ←/→/Home/End then move
    through results until the query changes, and PageUp/PageDown page; Esc clears the query first and
    closes only when it is empty; Cmd/Ctrl+K and `/` focus the field. In lists ←/→ and ↑/↓ are grid-aware
    (row-major, ↑/↓ cross sections), Home/End/PageUp/PageDown work, `P` pins or unpins the focused item
    (never while typing) and typing anywhere else in the launcher refocuses the field; ArrowUp from the
    first item returns to the search field (or the selected tab) and pinned tiles are a 2-column grid
    (3 in the tablet Quick access tab). The keyboard-active row highlights as a whole (soft fill plus
    one 1px inset ring, at least 3:1), the whole row is the link target (Cmd/middle-click opens a new
    tab), and truncated titles, rail rows, pinned tiles, app names and search rows show a tooltip with
    the full text. New label `hintPin`; hint label defaults are lowercase and follow the canvas order.
    `LauncherLabels` gained required keys (`hintClickNewTab`, `newTabTip`, `resultsCount`, `tabsLabel`,
    `tabMenu`, `tabQuickAccess`, `tabApps`): a consumer that types a full `LauncherLabels` object needs
    them, while the `labels` prop (`Partial<LauncherLabels>`) is unaffected. Consumer tests: below `lg`
    the Quick access and Apps sections are behind their tabs, and the dialog's accessible name now comes
    from a visually hidden "Menu" heading.
  - `OrgSwitcher` `display` (`"full"` default | `"avatar"` | `"auto"`) — `"avatar"` is a 36px avatar plus
    chevron trigger whose accessible name is `Switch organization, <name>` with the name in a tooltip;
    `"auto"` is avatar below `md` and full from `md` up. Types `OrgSwitcherProps`, `OrgSwitcherSize` and
    `OrgSwitcherDisplay` are now exported from the package root.
  - `Kbd` / `KbdShortcut` — a key cap and a chord (`mod+k` renders ⌘ K or Ctrl K), in the
    `AppSwitcher` notation. `Search` and `AppSwitcher` render their shortcut hints with `Kbd`.
  - `NavigationBar` `leading` — a slot between the sidebar toggle/back button and the title, outside
    the title `<p>`.
  - `SideBar` pinned section: `pinnedItems`, `pinnedLabel`, `onUnpin`, `activePinnedId`,
    `pinnedLimit`, `pinnedLabels`; `collapseTransition` (`"none"` or `{ speed, animateShadow }`);
    `autoCollapse` (default `true`; `false` stops the rail calling `changeToggle` itself, which also
    silences the `open` prop). Types `SidebarPinnedItem`, `SidebarPinnedLabels`,
    `SidebarCollapseTransition`.
  - `Breadcrumbs` `overflow` (`"nowrap"` | `"wrap"` | `"truncate"`), `maxItems` (default 3) and
    `labels.showMore`; `"truncate"` folds middle crumbs into a "…" menu. A `BreadcrumbItem` without
    `path` renders as plain text.
  - `PageHeader` `titleSize` (`"md"` | `"lg"` | `"xl"`) and `actionsLayout`
    (`"auto"` | `"stacked"` | `"inline"`).
  - `Card` `padding` (`"none"` | `"2xs"` | `"xs"` | `"sm"` | `"md"` | `"lg"`) — one padding for
    header, body and footer.
  - `Editor` merge fields: `mergeFields` adds an Insert field menu (grouped, searchable),
    non-editable token chips (stored HTML keeps the raw `${path}`), unknown-field error chips with
    `onUnknownTokens`, and a Make dynamic dialog; plus `tokenSyntax`, `showMakeDynamic`,
    `toolbarButtons`, `apiRef` / `onReady` (`EditorApi`) and `labels` (incl. `dynamicFieldPlaceholder`, default "Choose a field"). `Editor` also takes `label`,
    `isRequired`, `helperText`, `error`, `errorMessage` and `data-testid`; the editable area gets
    `role="textbox"` with `aria-labelledby` / `aria-invalid` / `aria-describedby` / `aria-required`.
    Types `EditorProps`, `EditorApi`, `EditorMergeField`, `EditorTokenSyntax`, `EditorToolbarButton`,
    `EditorLabels`.
  - `WizardDialog` and inline `Wizard` — a multi-step flow that owns step state (validated
    Continue, locked future steps, error summary, review on the last step); `wizardTransition` and
    `firstInvalidStep` are exported.
  - `PdfViewer` `getBlob` (authed files: the viewer makes and revokes the object URL, skeleton while
    loading), `fallback`, `onLoad`, `onError`, `labels`; inline support is detected with
    `navigator.pdfViewerEnabled`. `PdfViewerProps` / `PdfViewerLabels` are now exported types.
  - Page examples: **App launcher** (`/examples/launcher`), **Wizard** (`/examples/wizard`),
    **Template editor** (`/examples/template-editor`).
  - `Tag` `display="dot"` — an 8px tone dot plus plain label for quiet rows (`variant` and icons
    are ignored; the dot ink is lifted to 3:1 on both canvases). `StatusTag` (+ `StatusTagProps`,
    `StatusAppearance`) maps an app's status union to a `Tag` tone once; an unknown status renders
    neutral and warns once in development. (HRMS #13)
  - `Drawer` / `Modal` `portalChildren` — rendered **inside** the dialog content after the slots (not
    a sibling in the portal, which Chakra would `aria-hide`), for nested dialogs and live regions;
    a nested `AlertDialog` still portals out, stacks above and returns focus. (HRMS #20)
  - `FormDrawer` (+ `FormDrawerProps`, `FormDrawerSection`, `FormDrawerLabels`) — a sectioned
    create/edit form in a `Drawer`: `role="alert"` error summary focused after a failed submit,
    sticky footer with a real `<form>` submit, discard-changes confirm on every close path while
    `isDirty`, all close paths blocked while `isSubmitting`. (HRMS #14)
  - `Table` row detail expander — `renderRowDetail(row)` (null = no chevron), `expandedRows` /
    `onExpandedRowsChange` (uncontrolled when `expandedRows` is omitted). Chevron column after the
    checkbox, full-width detail row; works with pagination, virtualisation, frozen columns and
    grouped mode. The chevron is a button with `aria-expanded` / `aria-controls`. (HRMS #17)
  - `EmptyState` `description` accepts a `ReactNode` — a string keeps today's styling, a node sits in
    the same muted block above `action`. (HRMS #18)
  - `QueryStateBoundary` `isForbidden` (boolean or `(error) => boolean`), `forbidden`
    (`{ title, description, icon, action }`) and `hasData`; type `QueryStateForbiddenProps`. Order is
    now loading (only when `!hasData`) → forbidden → error → empty → children; forbidden is a lock
    `EmptyState` with `role="status"`. Defaults unchanged. (HRMS #21)
  - `Tabs` link mode — `TabItem.href` and `TabsProps.linkAs`: when every item has an `href`, `Tabs`
    renders a `<nav aria-label>` of links (same variants and sizes, no panels, not a tablist) with
    `aria-current="page"` on `value`; modifier and middle clicks are never intercepted. (HRMS #19)
  - `BulkSelectionBar` (+ `BulkAction`, `BulkActionTone`, `BulkSelectionBarProps`) — selection bar for
    beside a `Table`: "{n} selected", facts about the whole selection, tone-ranked actions, clear,
    `inline` or `sticky`, polite count announcement; renders nothing at 0. (HRMS #15)
  - `SectionRail` (+ `RailSection`, `SectionRailProps`) — section navigation for long settings
    screens: a vertical `nav` with `aria-current` and a dirty dot per section, collapsing to a
    `Select` below `collapseBelow` (default `md`). (HRMS #16)
  - `StatCard` `iconTone` (neutral / info / success / warning / error) paints the icon chip with the
    soft tone; unset keeps today's brand-primary chip. (HRMS #23)
  - `CircularProgress` (+ `CircularProgressProps`, `CircularProgressSize`) — SVG ring, sm/md/lg =
    32/48/72px, `thickness`, `tone`, centred `label`, `role="progressbar"`. (HRMS #24)
  - `CalendarGrid` (+ `CalendarEvent`, `CalendarGridProps`, `CalendarGridView`, `CalendarGridLabels`) —
    a controlled month/week/day grid of date-granularity events: multi-day spans, "+N more" opens the
    day, `role="grid"` with arrow/Home/End/Page keys, link or button chips in `Tag` tones, `locale`,
    `weekStartsOn`, `isLoading`. (HRMS #25)
  - `AudioRecorder` (+ `AudioRecorderProps`, `AudioRecorderLabels`) — microphone recorder: asks for
    the mic on Start, mm:ss timer, `maxDurationSec` auto-stop, hands a `File` named for the recorded
    mime to `onRecorded`, permission-denied and unsupported states, releases the mic on unmount.
    (HRMS #26)

- **Commerce kit** — the components for a shop front, designed mobile-first (Pebble principles:
  chromeless tonal cards, one loud action per view, selection never by colour alone). Product:
  `Rating`, `PriceBlock`, `StockStatus`, `ProductBadges`, `WishlistToggle`, `OptionChip`,
  `VariantSelector`, `QuantityStepper`, `MediaGallery` / `MediaLightbox`, `ProductCardGrid`,
  `ProductCardSkeleton`, `QuickAddSheet`. Detail page: `StickyBuyBar`, `PincodeChecker`,
  `TrustBadges`, `OffersList`, `SpecTable`, `ReviewSummary`, `ReviewList`, `DetailSections`.
  Bag and checkout: `CartItem`, `OrderSummary`, `CouponField`, `AddressCard` / `AddressCardGroup`,
  `PaymentOption` / `PaymentOptionGroup`, `OrderStatusTimeline`, `BagBar`. `VariantSelector` is
  config-driven (`axes` + `variants`, any vertical) and derives per-combination availability.
  Helpers: `stockState`, `findVariant`, `isOptionAvailable`, `remainingStock`, `priceRange`,
  `firstAvailableSelection`, `selectionComplete`, `formatPrice`, `discountPercent`,
  `discountAmount`, `unitPrice`. A **Storefront** page example in the playground wires them into a
  working shop app (home, listing, product, wishlist, bag, checkout, order tracking).
- **`star` palette token.** Rating-star gold, derived (`buildStar`) and identical in every brand.
- **`ProductDetails` — `size`.** `md` | `lg` height of add-to-bag, buy-now and quantity controls;
  defaults to `md` on the split layout, `lg` on the phone page. The desktop wishlist follows it.
- **`ProductMedia` — `srcSet` / `sizes`.** Forwarded to the `ProductCard` `<img>` so a grid tile
  downloads a tile-sized rendition. `thumb` now has JSDoc.

- **`Spinner` — `testId`.** Maps to `data-testid` on the rendered node, wins over a raw
  `data-testid` passthrough. Lets consumers select a loading spinner in tests.
- **`SegmentedControl` / `Switch` — `surface`.** The background the control is drawn on. Optional:
  the track is an opaque plate on any backdrop, so the labels read without it. Pass it to aim the
  plate's hairline edge at the real backdrop rather than at the page canvas.
- **`MultiSelect` — `addOnBlur`.** Commits the typed address on focus-out as well as on Enter.
  Defaults to `false`, so existing call sites keep Enter-only behaviour.
- **`StatCard` — `footer` / `sparkline`.** `footer` pins content below a divider at the bottom of
  the card; `sparkline` sizes and positions a small inline trend chart beside the value (bring your
  own — the library doesn't ship a mini-chart primitive). Neither renders while `isLoading`.
- **`Table` — `columns[].label` accepts `ReactNode`, plus `labelText`.** A rich header (icon, badge,
  tooltip trigger) is now safe everywhere a header used to be assumed to be text — sort/filter
  button names, Manage Columns, the Group picker, filter chips, and saved column preferences all
  read through a new `columnLabelText()` fallback chain instead of stringifying the node. Pass
  `labelText` to control the derived text explicitly; a wide `ReactNode` header falls back to a
  fixed width rather than auto-sizing from text length. `string`/`number` labels are unaffected.
- **`SegmentedControl` — `shortLabel` / `orientation`.** `shortLabel` shows in place of `label`
  below the `md` breakpoint (the full label is always restated as the accessible name);
  `orientation="vertical"` stacks segments column-wise. Both optional, default unchanged.
- **`SideBar` — `lockedReason`.** A tooltip explaining why a menu item is locked, shown on hover/
  focus of a locked row (top-level, submenu, or a top-level item's `secondaryItems`).
- **`useChartTheme`.** A hook wrapping the existing `chartInk`/`getCategoricalSeriesColors`
  primitives into a ready-made theme for `recharts` consumers — series colors, grid/axis ink, and
  tooltip/legend styling, all dark-mode aware. `recharts` itself is not a dependency of this
  library; bring your own.

- **Dark mode.** `PixelizeThemeProvider` (defaults to `system`), `useThemeMode`, `ThemeSwitcher`.
  Dark palettes are derived per brand from that brand's own light palette: surfaces are hue-neutral,
  and the brand hue carries through on the accent, ink, rules and rail. `withTheme` stays light-only,
  so existing apps are visually unchanged — dark is opt-in.
- **`MediaViewer`** — a full-screen viewer for images, video, audio, PDF, Office documents, text
  and archives, with a filmstrip, keyboard navigation and per-kind fallbacks. `index` is optional:
  omit it and the viewer owns navigation itself.
- **`NotificationBell` / `NotificationPanel` / `NotificationList` / `NotificationItem`** — a
  notification tray with zones, filters, day bucketing and all four load states.
- **`Backdrop`** — a fading scrim matching MUI's Backdrop exactly (black at 0.5, no blur, 225ms in /
  195ms out, 40px spinner), with `container` scoping, `invisible`, `onClick` dismissal and
  `allowBackgroundInteraction` to let clicks reach the page behind. Prefer it over any hand-rolled
  overlay. `Loading` gains `variant="dim"` to route through it; its default stays `"frosted"`, so
  existing screens are visually unchanged.
- **`ConfirmDialog`** — the shared Cancel/Confirm shape, including typed confirmation
  (`confirmationText`).
- **`Avatar` / `AvatarGroup`** as owned components. The fill is derived from `name` through the
  theme's colour scales, so it follows brand and dark mode on its own.
- **`RangeSlider`** as an owned dual/single-thumb component with a `marks` prop.
- `resolveFilledSurface(scale, colors)` — picks readable ink for any filled surface.
- `size="2xs"` (16px) on `Avatar`, and `getInitials` to override the built-in derivation.
- **`Drawer` / `FormDrawer` warn in development when an open drawer has no accessible name.** A drawer with no
  `DrawerHeader`, `aria-label` or `aria-labelledby` is announced as an unnamed dialog; it now logs
  `UNNAMED_DRAWER_WARNING` once per mount (production is silent and unchanged).
- **77 prop types now reach the package root**, so a consumer can type a wrapper without a deep import of `dist/`:
  `AccordionProps`, the `Apex*ChartProps` and their variant unions, `PaymentCardProps` (+ `Plan` as `PaymentCardPlan`),
  `DropdownProps`, `FilePreviewProps`, `NoteTextAreaProps`, `NotificationProps`, the `NotificationBell*` family,
  `PhoneNumberInputProps`, `Reorder*`, `Select{Single,Multi,Field}Props`, `SelectV2{Single,Multi}Props`, the Drawer, Modal
  and ProfileCard slot props, `UpgradeButtonProps`, `CustomThemeProps` and more (type-only, no runtime change).
  `src/index.propTypes.test.ts` now fails when a prop type in a `*Props` file is neither exported nor marked internal.
- **`toCsv`, `downloadBlob`, `safeFileName` and the `TabularCell` / `TabularColumn` / `ToCsvOptions` types** (`Utils/tabular.ts`):
  RFC 4180 CSV with a formula-injection guard (a leading `=`, `+`, `-`, `@` gets a `'`, plain signed numbers excepted) and an
  optional BOM; a browser download that revokes its object URL; a file name safe on every OS. Port of the Mobile library's
  `core/files/tabular.ts`. No `.xlsx` and no `Table` export button yet.
- **Editor token helpers on the root:** `fieldToken`, `extractTokens`, `unknownTokens`, `chipHtml`, `chipify`, `serializeChips`
  and the `ChipContext` type, so an app no longer copies `chipHtml`. `EditorMergeField` gains optional `object`,
  `objectDescription`, `description`, `example`, `source` and `image` for a field browser; `Editor` itself ignores them.
- **`DarkPaletteOptions` and `DarkPlanes` types** are exported, so the options of `buildDarkPalette` can be named.
- **README Exports index.** Every value the package root exports is listed in `README.md`, grouped as components,
  hooks, constants and helpers, so a developer or LLM with only this package can find any export. It is generated
  (`UPDATE_README_EXPORTS=1 npx jest src/readmeExportsIndex.test.ts`) and `readmeExportsIndex.test.ts` fails when it
  drifts from `src/index.ts`. `DESIGN-GUIDE.md` gains the type ramp (`textStyle`, `NUMERIC_TEXT`), radius by role,
  the `Table` studio default and a table of parts that belong to a parent (`DrawerBody`, `ProfileCardFooter`,
  `StageItem`, `MediaScrubber`, `LazyWrapper`, `AppearanceSettings`, `ApexPolarChart` and the rest).
- **Repo tooling, no package change.** `mouseOnlyHandlers.test.ts` is a lower-only ratchet on click-only elements (host
  elements and Chakra layout primitives with `onClick` and no `role`, `tabIndex` or key handler).
- **Repo tooling, no package change.** `npm run size:web` bundles the root barrel with esbuild and fails over the beta.65
  size +3% (opt-in, not wired to CI).
- **Repo tooling, no package change.** `scripts/verify-consumers.sh` now covers `lfour/frontend`, finds consumers from a git
  worktree (`CONSUMERS_ROOT`), writes nothing in a consumer, and exits non-zero when nothing was compared or `tsc` rejected
  its own config, instead of printing a clean pass.
- **`Select` popup rows: `renderOptionEnd`, `optionAction` and `checkPlacement`.** `renderOptionEnd(option, state)` puts
  non-interactive content (a badge) between the row and its check. `optionAction={{ icon, label, onAction }}` adds one
  pointer-only, `aria-hidden` row button whose click calls `onAction(option)` and never selects the row, which a button
  placed inside `renderOption` could not do (it bubbled to the row and failed axe `nested-interactive`).
  `checkPlacement="start"` moves the single-select check to the leading column the multi checkbox uses. Omitted, the
  output is exactly as before.

### Changed

- **`Table` rows are keyboard-reachable when `onRowClick` is set.** Each row becomes a tab stop (`tabIndex` 0) and Enter or Space
  on the focused row fires `onRowClick` with the first visible column that has no `node`, the same call a click on that cell makes.
  Rows of a table without `onRowClick`, with every column on `node`, or while `isLoading` are unchanged. **Consumers that pass
  `onRowClick` gain one Tab stop per row**: update tab-order tests and expect long tables to need more Tab presses to cross.

- **`ProfileCard` shares the dropped-slot dev warning with `Modal` and `Drawer`.** The helper takes any owner name and
  warns once per owner and type (previously one set across owners); `ProfileCard` keeps its own message text.
- **`MediaViewer` image pan no longer re-renders the viewer on every pointer move.** The pan offset is held in a ref and
  written to the image's transform directly, so dragging a zoomed photo re-renders nothing (the toolbar and its five
  tooltip-wrapped buttons used to re-render per move). Zooming out or resetting mid-drag now ends the drag.
- **`MediaViewer` no longer loads `date-fns`** for its one "Uploaded" date; `Intl.DateTimeFormat` prints the same
  `Oct 12, 2026` text (-5.6 KB gzip for an app that does not also use `DatePicker`).
- **NavLauncher** search rows are `memo` with stable handlers: moving the active option with the arrow keys or the pointer re-renders the 2 rows that change instead of every row (80 rows: 80 renders before, 2 after); the consumer's `onClose`, `onNavigate` and `onTogglePin` may be inline functions without defeating it. Pixel-identical.
- **`ProfileCard` warns in development when it drops a child**, once per dropped type, like `Drawer` and `Modal`: anything
  that is not a `ProfileCardHeader`/`Body`/`Footer` (or a Fragment of them) never renders.
- **`Table` rows render once on mount, not three times.** Measured column widths and the select slot's width only feed frozen columns and the row-detail chevron, but a fresh offsets array on every measurement re-rendered every row. Tables without a frozen column or `renderRowDetail` now keep a stable offsets array; frozen and detail tables still update when widths settle. No visual change.
- **`Table`, `Button` and `Toaster` hairlines and small text read the tokens.** 18 `0.063rem` borders now use `BORDER_WIDTHS.hairline` and 12 pixel font sizes use the `fontSizes` keys; the token-ownership baselines drop to match (`remHairline` 24 to 6, `fontSizePx` 54 to 42). Computed styles and layout of the demo page, filter pane, chips and settings sheet are identical before and after, in light and dark.
- **`KanbanBoard` cards no longer re-render when `onOpen` or `onDelete` is an inline arrow.** The board hands cards stable wrappers that call the latest prop, so a parent re-render leaves every card alone and a click still reaches the current handler. No visual change.
- **`Table` header controls skip renders they do not need.** Each column's sort, search and filter-open handlers keep their identity, so hovering a header or sorting one column no longer re-renders every other column's sort icon and filter control. No visual change.
- **Performance, no visual change** (web premium/perf campaign):
  - Theme colour math is memoised (`resolveFilledSurface`, `liftToContrast`, `softenToContrast`,
    `resolveSoftTone`, luminance) in bounded, content-keyed caches: the hot calls are 5-20x faster and
    restyling 50 `Button`s and 50 `Checkbox`es dropped from about 1.4 ms to 0.15 ms.
  - Brand palettes (`lightPalettes`, `darkPalettes`, `palettes`, `<brand>Dark`) and the eight
    non-default light themes are built on first read instead of at import; keys, key order, values and
    object identity are unchanged, and importing the theme module is about 2x faster. The `ThemeSwitcher`
    brand picker no longer builds every brand's Chakra theme when it mounts.
  - Themes from `createBrandTheme` cache each component style function's output per theme, keyed on the
    props that function reads: output is identical and style resolution per component render is about 2x
    faster.
  - Switching light/dark no longer blocks: the switcher repaints first and the app re-themes in an
    interruptible render. Document `color-scheme`, `data-mode` and the `--color-*` variables follow the
    new palette together, never ahead of it. `PixelizeThemeProvider` memoises its Chakra subtree, so the urgent
    half of a switch re-renders only the switcher.
  - `KanbanBoard` adopts a new `data` prop during render instead of in an effect, so a board whose data
    follows the theme renders once per theme flip instead of twice.
  - `Table`: passing an equal `columns` array again no longer re-renders every visible row; column
    search popovers and row action menus mount their popover only while open (closed rows register no
    global listeners); virtualised rows are measured through one shared `ResizeObserver` with no forced
    layout read on mount.
  - `ToolTip` mounts Chakra's tooltip only after the first hover or focus of its trigger; until then
    (and while disabled or without a label) it renders just the child, with no document listeners.
  - `SideBar`: the swipe-to-close drag no longer re-renders the sidebar or creates a CSS rule per
    pointer move; row labels share one `ResizeObserver` and pause measuring while the rail width
    animates, and their tooltips arm on first use. `useIsTruncated` uses a single shared
    `ResizeObserver`; its signature is unchanged.
  - `SearchSelect` option rows are memoised, so hovering, ArrowDown and toggling an option re-render only
    the rows whose state changed (300 options: 300 row renders per hover to 1-2), and the menu's
    scroll-driven position is an inline style instead of a new Emotion class per position.
  - `KanbanBoard`: hovering a column and expanding a card no longer re-render the whole board (4 x 30
    board, hover sweep: 960 card renders to 0); virtualized cards share one `ResizeObserver`.
  - `Search` instances share one window `keydown` listener for Ctrl+K (it was one per instance);
    with several on a page the last mounted still takes focus.
  - `Dropdown` and `SelectSearch` add their outside-click `document` listener only while open, so closed
    instances cost nothing on every page click.
  - `MediaVideoPlayer` and `MediaAudioPlayer` no longer re-render the whole player on each `timeupdate`;
    only the scrubber and the time readout update.
  - `RangeSlider`, `MediaImageViewer` (pan/zoom), `FilterSidebar` (range slider), the media scrubber and
    the `Dropdown` menu position apply per-frame values as inline styles, so dragging, panning and
    scrolling no longer insert a CSS rule per value (40 slider moves used to leave 160 rules behind).
  - `Notification` and `UpgradeButton`: the card and dismiss control transition named properties instead
    of `all` (same 0.2s ease), and both drop their transitions, the shimmer sweep and the hover lift under
    `prefers-reduced-motion`.
  - `CalendarGrid` reuses `Intl.DateTimeFormat` instances per locale and options (was about 70 per
    month render). `Toaster` cards no longer hold a permanent `will-change` layer.
- **`PageHeader` `actionsLayout` defaults to `"auto"`**: at `md` and up the actions wrap below the
  title when they do not fit (before, they squeezed the title). Identical whenever title and
  actions fit on one row; pass `actionsLayout="inline"` for the old behaviour.
- **`Card` `padding` is no longer Chakra's style prop** — it is omitted from the type and now takes
  the `CardPadding` steps. A consumer passing `padding={4}` gets a compile error; use `p` or `sx`.
  Breaking in 3.0.0 (see Breaking); consumer blast radius not measured.
- **`SideBar` `collapseTransition` defaults to `{ speed: "base" }`** (200ms width and shadow, was 260ms).
  Pass `{ speed: "slow" }` for the old pace.
- **`StageProgress variant="stepper"` renders an `ol` of `li`** (`StepperStageProps.asListItem`).
  Visual layout is unchanged; selectors that expected a `div` list no longer match.
- **`PdfViewer` `pdfUrl` is optional** — pass exactly one of `pdfUrl` / `getBlob`. In `getBlob` mode
  the toolbar Open and Download are real buttons that fetch through it.
- `Editor` binds Jodit's `change` / `blur` itself on every Jodit instance and renders Jodit only once
  its popup root exists (no throwaway first instance). Behaviour for existing props is unchanged.
- `BreadcrumbItem.path` is optional (type widening).
- **`Drawer` / `Modal` warn when they drop a child.** A child that is not a Header/Body/Footer slot
  still renders nothing, but now logs a dev-only `console.warn` (once per child type) pointing at
  `portalChildren`. (HRMS #20)
- **`Table` `studio` variant on touch devices:** under `@media (pointer: coarse)` the row checkbox is
  always visible and the row number is hidden, instead of a hover-only reveal. (HRMS #17)
- **`ProfilePhotoViewer` re-syncs when `photoUrl` changes** (it used to seed on mount only), so
  consumers can drop `key={photoUrl}`. A locally picked preview is replaced by the new URL — update
  `photoUrl` only once an upload has finished. (HRMS #22)
- **Page examples:** `records.tsx` uses `BulkSelectionBar` instead of the `Table`'s `headerActions`;
  `settings.tsx` uses `SectionRail` instead of a hand-built section nav.

- **`ProductCard` and `ProductDetails` are rewritten** — see Breaking below. `ProductCard` is a
  chromeless 4:5 card (grid / list / rail, swatch dots that swap the photo, quick-add sheet or
  direct add that morphs into a stepper, sold-out "Notify me", slots). `ProductDetails` is a
  composed, controlled/uncontrolled product page with a sticky buy bar on mobile.

- **Control heights: `xs` 24px → 30px, `sm` 32px → 38px, `md` 40px → 42px.** `lg` (48px) is
  unchanged. `CONTROL_SIZES` is the one scale behind every field, select and button, so `Button`,
  `TextInput`, `InputTextArea`, `NumberInput`, `PinInput`, `Select`, `SelectV2`, `SearchSelect`,
  `SelectSearch`, `Search`, `Dropdown`, `SegmentedControl`, `Tabs`, `TableToggle` and the
  `DatePicker` family all grow together and still line up side by side. `sm` is the default size,
  so most call sites gain 6px of height without passing anything.
- **`PhoneNumberInput` defaults to `size="sm"`, not `md`.** It was the one field still defaulting to `md`, so in a form it stood 4px taller than the `TextInput`s around it. Pass `size="md"` to keep the old height.
- **Option rows follow the scale: 24/30/34/40px → 26/32/36/40px** in `Select`, `SelectV2`,
  `SearchSelect`, `SelectSearch`, `Dropdown`, table filter selects and Chakra `MenuItem` — always
  6px inside the trigger that opens them (4px at `lg`).
- **`OrgSwitcher` and `AppSwitcher` sit on the control scale.** `OrgSwitcher` sm/md/lg were
  32/40/48px and are now 38/42/48px; the `AppSwitcher` trigger was 36px and is now 38px, so both
  line up with an `sm` button beside them.

- **`SideBar` sizing is one scale.** Rail, submenu, collapsed flyout and `SecondaryBar` rows now all
  read `SIDEBAR_ROW`, so every nav label is 13px at every level. Visible changes: submenu and flyout
  rows grow 20px → 28px with their labels 12/12.5px → 13px, the "Add New" label 12.5px → 13px, and
  `SecondaryBar`'s `headerFontSize`/`itemFontSize` defaults 0.75rem → 13px. Child rows previously
  rendered a step smaller than their parent; pass the `SecondaryBar` font props to keep the old size.
- **Charts are lazy.** `react-apexcharts` now loads behind a dynamic `import()`. The main chart
  entry dropped from 227,454 B to 2,448 B gzip, and an app that never renders a chart no longer
  pays for one. Verified against real ESM build output.
- `src/index.publicApi.test.ts` is now **bidirectional** — removing a public export fails the
  build instead of passing unnoticed (249 → 387 guarded names).
- `Accordian` still resolves, now as a `@deprecated` alias for the owned `Accordion`.
- **`ToggleSwitch` absorbs every `InputSwitch` capability**, so `InputSwitch`'s deprecation is now
  true: `isChecked` is optional (uncontrolled via `defaultChecked`), `fieldLabel` (+ `isInformation`/
  `informationMessage`) for a label above the track distinct from the inline `label` beside it,
  `colorScheme` (any brand/global scale, via `resolveFilledSurface`), `isReadOnly` with real
  `aria-readonly`, and `m`/`mt`/`mb`/`ml`/`mr`/`mx`/`my` on the wrapper. `InputSwitch` is now a
  genuine thin wrapper — no prop it previously accepted is silently dropped.
- `jsdoc/require-jsdoc` now also covers exported `*Props` / `*Options` types outside `*Props` files; around 30 types gained a one-line doc.

### Changed (visual)

- **`OrgSwitcher` rows match the `Select` popup (screenshots pending).** Rows take the option radius and a 4px side inset, the
  active org gets the selected fill (hover and focus on it the stacked rung), keyboard focus draws the neutral 1px inset ring
  instead of the browser outline, the name weight is 500 for the active org and 400 otherwise (was 600/500), and names and roles
  use `Select`'s ink ladder (dark unchanged). Props, ids and ARIA are unchanged.
- **`SelectSearch` rows match the `Select` popup (screenshots pending).** The keyboard-focused row (real DOM focus, `:focus-visible`
  only) draws the neutral 1px inset ring instead of the browser's outline; a reopened single selection gets the selected fill,
  and hover or focus on it the stacked rung. Light labels and `email` lines use `Select`'s neutral ink ladder (dark unchanged).
  Uncontrolled model, multi's removal of picked options, ids and ARIA are unchanged.
- **`SearchSelect` rows match the `Select` popup (screenshots pending).** Checked or selected rows get the selected fill, the
  cursor on a selected row the stacked rung, and Arrow/Home/End draw the same neutral 1px inset ring (also on Select All and
  Add New); the mouse clears it. Labels use `Select`'s neutral ink ladder in light (dark unchanged). Selected-label weight,
  radius, `activeIndex` sentinels, `insideSelect` and every prop, id and ARIA attribute are unchanged.
- **`Select` popup, light: the selected row gets a fill (screenshots pending).** Hover, selected and
  selected+hover are neutral greys, identical on all nine brands (`#eeeeee`, `#e3e3e3`, `#dadada`; selected about 1.28:1 on the
  panel; the beta.68 build carried each brand's tint instead, strongest on meadow, and beta.69 removes it), so the three
  read as one family and every adjacent pair is at least 1.10:1 (selected vs stacked 1.08:1). Arrow-key navigation now
  also draws the neutral 1px inset ring in light (`#7b7b7b`, 3:1 on all four fills), because keyboard-on-selected is only
  1.08:1 from a plain selected row; the mouse never draws it. Multi checked rows get the same fill (checkbox fill stays
  at least 3.2:1). The text ladder holds on the new fills.
- **Native `Select` `filled`, `flushed` and `unstyled` variants are themed.** They had no keyboard focus indicator, `filled` had
  square corners at `xs`/`sm`, and `flushed` rested on a 1.2:1 hairline. Each now shows the brand border plus glow on `:focus-visible`
  (`unstyled` gets the two-tone ring), `filled`/`unstyled` use the field radius, and `flushed` uses the outline border rung. `outline` is unchanged.

- **`Select` popup layout polish, light and dark (screenshots pending).** Fills are unchanged. The single-select check sits
  in a reserved 16px slot so long labels no longer shift by the check's width; Select All has a hairline rule under it; the
  list is padded by the row inset (6px at `sm`, was 4px) so rows sit evenly from the panel edge; the scrolling list shows top
  and bottom scroll shadows (`scrollShadowStylesVertical`, a vertical twin of the Table helper); and a disabled row no longer
  dims as a whole (was opacity 0.4), so its cursor fill and the dark keyboard ring stay visible: light sets its content to a
  solid `#868686` (3.6:1), dark dims the content to 0.55.
- **`Select` popup, light: row text is one neutral grey ladder, identical on all nine brands (screenshots pending).** The
  brand-derived `text[700]`/`text[900]`/`textMuted` read as soft purple, green or brown. Now (built with `neutralStep` from
  the panel, no mode argument) the label is `#323232` (12.8:1), the selected label `#242424` at weight 500 beside its
  check, the description `#595959` (7:1), and the filter input's placeholder `#757575` (4.6:1, filter only; every other
  field keeps `placeholderInk`). Dark text is unchanged, and `Menu`, `DropDown`, `SearchSelect`, `SelectSearch`,
  `OrgSwitcher` and `CompactSelect` keep reading `optionRowInk`, so their light rows stay brand-tinted.
- **`Select` popup, dark: selected, hover, stacked and keyboard states are four neutral rungs (screenshots pending).** Hover
  stays `#363636`; the selected row now fills `#3d3d3d`, hovering it `#424242`, and arrow-key navigation draws a 1px
  `#8d8d8d` inset ring (never under the mouse), identical on all nine brands and derived from the panel,
  not a mode. The description line is lifted against the busiest rung. Light follows in the entry below. `SelectPopup` only; `listRowBackgrounds`, `optionRowFill` and the
  other readers of the shared helpers are untouched.
- **`Table` rows with `onRowClick` show a keyboard focus ring.** A focused row draws the library's two-tone ring inset around its
  cells (sticky cells included), on `:focus-visible` only, so a mouse click draws nothing. It clears 3:1 on every row plane in light and dark.

- **`NavLauncher` slides in with the library's 200 ms enter tween instead of Chakra's drawer spring.** The spring took about 570 ms to come to rest on a full-height panel; the tween (`motionTransition("enter", "out")`, instant under `prefers-reduced-motion`) lands in 200 ms. Measured at 4x CPU throttle, 1280x800, 12 runs against an unchanged build: click to panel at rest and search field focused, median 765 to 413 ms (spotlight, -46%), 819 to 481 ms (classic, -41%), 787 to 460 ms (dark spotlight, -42%). The resting layout and the exit are unchanged.
- **`NavLauncher` text snaps to the type scale.** The trigger, panel and spotlight (`NavLauncher`, `launcherParts`, `spotlightParts`) read
  `xs-plus` (13px) where they drew `12.5px` and `xs-minus` (11px) where they drew `11.5px`; the `13.5px` sizes stay. Each size moves by 0.5px.
- **Disabled `Button` dims to the shared 0.5.** The base style drew `opacity: 0.6` while every other disabled control reads
  `DISABLED_OPACITY` (0.5); disabled buttons, and the `IconButton`s that inherit them, now read slightly dimmer.
- **`Toaster` card no longer slides twice.** The card added a 24px `x` slide on top of Chakra's own wrapper slide (400ms), so a toast travelled twice and settled late. The card now only fades; the wrapper's slide is the only one.
- **Focus on dark chrome draws the library's two-band ring.** `Header` edit/create buttons, the `MediaViewer` controls (icon buttons, scrubber, volume and seek thumbs) and the `Dropdown` option rows replace a single 2px band with halo + ring (`surfaceFocusRing` on the dark surface; `insetFocusRingShadow` for the rows), so the ring is 4px at its outer edge like every other control.
- **Overlay and card elevation read the shadow tokens.** The `AppSwitcher` and `OrgSwitcher` panels use `shadows.float` (was a single `0 10px 30px` layer), `SignInActivityTable` uses `shadows.sm`, and the `SearchSelect` open-state inset border and `Timeline` node rings are `BORDER_WIDTHS.hairline` (1px) instead of `0.063rem`.
- **Hover fades and small transforms take the motion tokens.** `CopyButton`, `CustomModulesTable`, `UserDetails`, `SignInActivityTable`, `RolesPermission`, `OrgSwitcher`, `AppSwitcher`, `NavigationBar`, `ButtonGroupIcon`, `DatePicker` days, `Checkbox`, `PlanPill`, `FilePreview` and `MediaViewer` replace literal `0.12s` / `0.15s` / `0.2s` with `transitionFor`: fades are 150ms and `PlanPill` / zoom are 200ms, on the standard curve (the `ease-out` drags use the out curve). Differences are at most 30ms.
- **`PlanPill` and the `SideBar` chrome snap to the type scale.** `12.5px` becomes `13px` (`xs-plus`; `PlanPill` label, `SIDEBAR_CHROME.titleFontSize`) and `11.5px` / `9.5px` become `11px` (`xs-minus`; `SIDEBAR_CHROME.labelFontSize`, section captions in `MenuItems`, `OtherApps`, `PinnedSection`). Sizes change by at most 0.5px; `10px` and `15px` stay as they are.
- **Hover lift and the `Timeline` divider use whole pixels.** `KanbanBoard` account cards and clickable `Timeline` cards lift by `-1px` (was `-0.063rem`, 1.008px at the default root size, which anti-aliases a transform), and the `Timeline` divider is `BORDER_WIDTHS.hairline` tall.
- **Disabled and blocked rows dim to `DISABLED_OPACITY` (0.5).** `CouponField` options, `CheckoutKit` selectable cards, `SideBar` blocked items (primary and secondary), `StageProgress` / `StepperStage` and `UpgradeButton` dimmed to 0.6 or 0.55; they now match `Tag` and the token. Busy-state dims are unchanged.
- **NavLauncher Top hit and answer cards show their ENTER chip only while active.** Both drew "ENTER" (and an answer its "copy" hint) permanently, so while another row was active two results claimed Enter; search rows already showed the chip on the active row only. The chip is hidden with `visibility`, so nothing shifts, and a card that just copied still shows "Copied".
- **NavLauncher footer stays one row on a tablet.** Between `md` and `lg` (768-1023px) the legend plus the footer note wrapped to two rows, 53px; the "+ click for new tab" hint (a mouse hint) now shows from `lg` up, so the footer is 32px at 768px and 820px. Unchanged from `lg` up.
- **NavLauncher footer legend follows the active entry on a Show more option and for copy-only answers.** ENTER read "open here" while a Show N more row was active although Enter expands the group; it now reads `labels.hintExpand` (new, default "expand"). The ⌘ ENTER "new tab" hint dims while the active result cannot open in a tab (a copy-only answer, an action, Show more). The ENTER label reserves the widest of its three texts, so nothing after it moves.
- **Inset focus ring: ring on the outer edge, halo inside.** `insetFocusRingShadow` drew them the other way round, so a child
  covering the inner band left only the surface-coloured halo. It now matches `Accordion` (unchanged). Affects NoteTextArea,
  CalendarGrid, SearchSelect, SelectSearch, MediaGallery and the FilterSidebar range and options sections.
- **Focus ring settles against the table header band.** `focusRing.ring` is now also lifted to 3:1 on the table header band
  (raw and as stepped): rosewood light and lavender, meadow, skyline and slate dark move one or two lightness steps from
  2.97-2.99:1; every other brand and mode keeps its ring.
- **`Timeline` attachment chip hover ink clears AA in light.** The hovered chip kept the rest ink on the darker
  `primary[100]` fill (3.86:1 radiant, 4.05:1 lavender, 4.39:1 slate); the ink is now picked against the hover fill in
  both modes. Only light hover on brands whose rest ink fell short changes.
- **Commerce hover fills are a visible step in dark.** `OffersList`, `CouponField`, `DetailSections`, `StickyBuyBar` (notify), `QuantityStepper`, `WishlistToggle` (soft, ghost), `ProductCard`'s off-photo action, `ProductDetails`' share button, selectable `AddressCard`/`PaymentOption` and `OptionChip` hovered `background[100]` to `[200]` (`[300]` to `[400]` for the chip), 1.05-1.08:1 in dark. The hover now steps 1.15:1 or more off the resting fill; light is unchanged.
- **`ApexPieChart` `variant="pie-with-image"` no longer calls imgur** (two hard-coded URLs that answered 403, so the slices
  painted nothing). Each slice now gets an inline striped texture in its own colour, light and dark. New optional
  `images?: string[]` supplies your own sources (one per slice; the first repeats past the end).
- **`ApexLineChart` toolbar and its "Download SVG/PNG/CSV" menu are themed in dark.** The chart never told ApexCharts the
  canvas mode, so its menu rendered light text on a white panel (1.13:1); it now follows the palette (15:1). Light is unchanged.
- **`FilterSidebar`'s rules inside the card use the in-card weight** (`cardRule`): the section dividers, the Filters header rule,
  the cards-variant section edge, the range track and the range value chips. Dark lifts them off the card; light is unchanged.
  The outer container outline keeps `boxborder[200]`.
- **`SelectSearch`'s separator above the bottom action** uses the overlay rule (`raisedRule`) like the action row below it,
  not Chakra's default border colour. Dark gets the in-card weight; light moves by a few channel values.
- **`SelectSearch` colour-option swatch** uses the `full` radius token instead of `50%`. The dot is the same circle; Chromium
  anti-aliases its edge pixels slightly differently.
- **`Notification` countdown label keeps counting.** It was computed once at first render and stayed at "due in 5
  hours" for the life of the card; it now refreshes every minute while a `dueDate` or `endDate` is set (no timer
  otherwise, cleared on unmount).
- **`NoteTextArea` dark footer band separates from the note area.** In dark the active band under the note was 1.11:1
  against it and read as one slab; it now steps to 1.19:1, and the attachment chip fill, its remove-hover and the control
  hover follow so a chip keeps a visible step and its text stays above 4.5:1. Light mode is byte-identical.
- **`Notification` overdue label** reads "Task overdue by 3 hours", without the trailing "ago".
- **`Notification` dismiss control** shows the focus ring on keyboard focus, like the title button beside it. It was
  focusable with no visible indicator.
- **`WorkspaceWindow` header and `WorkspaceTrayPreview` card rule in dark**: the header strip read 1.08:1 off the raised
  window and its rule 1.00-1.07:1; both now step off `surfaceRaised` (`bandOn`, `raisedRule`) to 1.16:1 and about 1.35:1.
  The preview card's border takes the same rule. Light is unchanged.
- **`Table` settings sheet option cards and column-row hover outline are visible in dark.** The Density and Group option cards and the Manage Columns row hover used `boxborder[200]` on the raised surface (about 1.0-1.07:1); they now use `raisedRule`. Light is unchanged.
- **`Table` filter pane rules and expanded row are visible in dark.** The pane's edge, header, search and Apply-bar rules use `cardRule` instead of `boxborder[200]` (1.08-1.16:1 on the lifted card), and an expanded section steps off the card with `bandOn`. Light is unchanged.
- **`Table` active-filter strip reads as a band in dark.** The strip behind the filter chips was 1.06:1 from the card; it now uses `bandOn` (about 1.19:1), and the chip tone and Clear All ink are derived against that strip. Light is unchanged.
- **`Table` bulk-action hover fill is visible in dark.** The hover on Delete and Mass Update used `background[100]`, which sits about 1.06:1 from the card; it now steps off the card with `bandOn` and the accent ink is re-derived against the lifted fill. Light is unchanged.
- **`Table`'s date filter loads on demand.** The filter pane's `DatePicker` (and date-fns) no longer ships in `Table`'s static graph: the Table's initial JS drops about 7.5 KB gzip (51.9 to 44.4 KB measured with peers external) and the picker loads as its own 8.8 KB chunk, preloaded when the pane has a date item. Once loaded the control is pixel-identical; a control-height skeleton shows only if it renders before the chunk arrives.
- **Dark surfaces are hue-neutral on every brand.** The canvas, card, overlay, named `backgroundColor` ladder,
  `background[50-900]` and `table.hover` no longer take a brand tint (`SURFACE_SAT` 14 to 0): all 9 brands share
  canvas `#121212`, card `#212121` (1.163:1 over the canvas) and overlay `#2c2c2c` (1.153:1 over the card), so
  switching brand no longer recolours the app background. The brand hue stays on the accent, ink, rules and the
  sidebar rail. Light palettes are byte-identical to before (SHA-256 of all 9 resolved light palettes). Muted dark
  text re-derives against the neutral surfaces and shifts a few hex values on lavender, rosewood, slate and zinc
  (lavender `#9f92aa` to `#a498ae`); every muted token still passes the same AA gate. `planes: "flat"` keeps its
  meaning (card = canvas) on the same canvas.
- **`Table` loading state.** With `isLoading` and no rows, each row now draws one skeleton per column (a checkbox
  placeholder in the checkbox column, blank spacer cells) with the variant's row rule, instead of one bar spanning the
  table. If `columns` is still empty while loading, the header shows four placeholder cells and a disabled select-all
  instead of a lone checkbox stretched across the row. The select-all checkbox is disabled while `isLoading`.
- **`Table` defaults to `variant="studio"`.** Every `Table` rendered without `variant` (or with `variant={undefined}`)
  now draws studio: flat small-caps header, one row rule, the select slot resting on the row number, status as a pill.
  Keep the old look with `variant="classic"`. Measured 2026-10-14: crm-frontend has 38 `<Table>` call sites, none
  passing `variant` (all 38 change); account-frontend 8 of 18 change (the other 10 already pass `"studio"`); hrms,
  tickets and lfour render `Table` only through an `AppTable` wrapper that already defaults to studio (0 change).
- **Option rows fill only under the cursor; selection is the trailing check and weight 500.** `Select`, `SearchSelect`,
  `SelectSearch`, `CompactSelect` and `OrgSwitcher` no longer fill the selected row: the selected fill sat 1.00-1.02:1
  from the hover fill on all 18 brand x mode palettes, so the two read as one state. `SelectSearch`/`CompactSelect` no
  longer fill row 0 on open beside the hovered row. `Dropdown` items keep `text[700]` under the cursor instead of turning
  `accentText`. `SearchSelect` rows sit inset (`mx`, rounded) with the check at the trailing edge and no hairline
  between rows. Text on every row state measures 7.69:1 or more (selected 12.43-19.90:1).
- **Chakra `Menu` items use option-row ink:** `text[700]` instead of `text[900]`; a checked `MenuItemOption` is weight
  500 with an `accentText` check, matching `Select`.
- **Dark `Button` `outline`/`ghost`/`link` ink is chosen against the canvas, the card and the overlay.** It cleared AA
  on the canvas only, so in a dialog or popover it fell to 4.06-4.50:1 in 24 scheme x brand combos (lavender outline
  4.46:1, rosewood 4.10:1); now AA on all three planes (lavender 6.28:1, rosewood 5.57:1). Light mode is unchanged
  (0 light combos move); 62 dark combos step lighter (emerald primary `#0f9d8a` -> `#14e3c7`).
- **Dark rules and pressed solids (owner review before release).** In dark only: the `NavigationBar` header rule and
  leading divider, the `KanbanBoard` column edge, header rule and count pill, the filled `TextInput` (a visible band off
  the card) and the Kanban drag-over column's dashed border (`focusRing.ring`, 3.1:1 or more instead of 1.7-2.7:1)
  now read at the in-card rule weight instead of 1.03-1.16:1. Solid `hover`/`active` fills never fall below 1.5:1 against
  the canvas (a pressed warning solid reached 1.00:1) and never flip the label colour, so zinc dark's pressed primary stays
  light with dark ink instead of dropping to `#52525b`. `ScrollToTop`, `FilePreview`, `ProfilePhotoViewer`, the Table
  clear-filters badge, Chakra `Badge`/`Radio` and the Table status pills now pass the canvas to `solidFillRung`: zinc dark
  primary and every brand's dark gray solid move from `#52525b`/`#525252` (2.4:1) to `#e4e4e7`/`#a3a3a3` with dark ink.
  Light mode and every other dark colour keep their fill; error/warning/info pressed fills in dark are lighter than before
  and hover and press now share one rung.
- **Elevation follows Pebble, per mode.** `shadows.sm`/`base`/`md`/`lg` are the Pebble card-press/
  card/float/dialog stacks, with new `card`, `float`, `dialog` and `sheet` role keys. Light shadows are
  softer and wider; dark mode gets its own heavier black ramp, so overlays cast a visible shadow on a
  dark canvas. `xs`, `xl`, `2xl`, `inner`, `none`, `darkLg` and the focus `outline` are unchanged.
- **`Menu` and `Popover` join the owned popup family:** radius `md` → `xl` (`RADIUS_ROLE.float`)
  and shadow `sm` → `md` (float), like every other anchored popup. Their border stays.
- **`fonts.mono` is a real monospace stack** (`ui-monospace, SFMono-Regular, Menlo, Consolas,
  monospace`). It was Inter, so code and keyboard shortcuts never aligned.
- **`Button` gray `ghost`/`outline` hover is visible.** Gray has no opacity ramp, so hover/active
  take the list-row `active`/`selectedActive` steps (at least 1.15:1 on a card, all 9 brands x 2 modes)
  instead of `gray.50` (1.04-1.08:1). `solid` and `gradient` hover no longer add a box-shadow; the
  colour step carries hover.
- **`Toaster` status-icon pulse** animates a pseudo-element's transform and opacity (scale 1 to 1.5,
  faded out by 70%, the same 900 ms one-shot) instead of `box-shadow`, so it no longer repaints every
  frame. The icon chip background is the same tint composited opaque over the toast surface.
- **`Table` filter sidebar** opens on the 200 ms motion token (was 500 ms), does not animate on
  mount, and is instant under reduced motion.
- **`FileUpload` image thumbnails are a keyboard-operable "Preview <file name>" button**: a new tab
  stop with the library focus ring; Enter or Space opens the preview. Each image row now has two
  buttons (Preview and remove), so a test that queries an unnamed `getByRole("button")` inside a row
  must name the one it means.
- **`Menu` item highlight sits inside the menu.** A themed `MenuItem`/`MenuItemOption` is
  `calc(100% - 2 * mx)` wide, so its hover/focus fill keeps the same 6px inset on both sides instead of
  overhanging the menu's right edge by 6px. Menu size and row height are unchanged; a `MenuItem` given
  its own `w`/`width` keeps that width.
- **`Skeleton` shimmer is one depth on every brand and mode**: 1.35:1 between its two colours (the
  mobile library's `SKELETON_SHIMMER_CONTRAST`), was 1.34-1.58:1 in light and 1.93-1.99:1 in dark. The
  end colour is `gray[300]`'s tint settled against `gray[100]`; dark skeletons pulse much more gently.
  An explicit `startColor`/`endColor` still wins.

- **Motion comes from the tokens.** `SideBar`, `Notification`, `Toaster`, the Table settings sheet,
  `ProfilePhotoViewer`, `TableToggle` and `StageProgress` take every duration and curve from the motion tokens
  (colour fades 150ms, movement 200ms, enters on `MOTION_EASE_OUT`, exits on `MOTION_EASE_IN`). Most timings
  move by under 60ms; the Table settings sheet goes from 400ms to 200ms. `Notification` cards no longer lift
  2px on hover; the shadow still deepens.
- **`Button` (solid, outline, ghost) and `IconButton` scale to 0.98 while pressed** (`MOTION_PRESS_SCALE`).
  Never when disabled or loading, nor under `prefers-reduced-motion`.
- **Dark planes separate by lift.** Cards (`background[50]`) sit one step (1.16:1) above the canvas, with
  overlays (`surfaceRaised`) one step (1.15:1) above the card; every filled mark keeps 3:1 on all three. Light
  mode is unchanged. A brand opts out of the lift with `buildDarkPalette(light, { planes: "flat" })`. `meadow`
  and `rosewood` dark `accentText` move one rung lighter so they clear AA on selected list rows.
- **`zinc`:** muted text in light mode is `#515157` (was `#27272a`, 14.9:1), settled under a new 8:1 muted
  ceiling; zinc's dark card is no longer darker than its canvas.
- **A brand's primary never reads as danger.** `radiant`'s danger colour (`red` and `semantic.error`) is
  crimson and `rosewood`'s primary rotates toward raspberry; a delta-E >= 10 gate in `brandIdentity.test.ts`
  covers all 9 brands in both modes.
- **`Button` solid primary paints `resolveFilledSurface`.** Only `zinc` dark moves: the primary fill goes from `#52525b` with
  white ink (2.5:1 on the canvas) to `#e4e4e7` with black ink, matching `resolveFilledSurface`, the fill every other filled surface already paints.
- **Dark mode: header strips, calendar off-days and in-card rules step visibly off the lifted planes.** Table header
  strips (`CustomModulesTable`, `SignInActivityTable`), `DatePicker` weekly-off cells, and the rules inside cards and
  dialogs (`Table`, `SettingRow`, `SettingsSection`, `StatCard`, `Drawer`, `Modal`, `ConfirmDialog` and the account
  modals) now clear 1.15:1 (bands) and 1.31:1 (rules) off their plane. Light mode is unchanged.
- **`Card` defaults to `variant="elevated"`** (borderless, `background[50]` lifted by `shadows.card`). Pass
  `variant="outline"` for a Card that frames a table or a list of rows. Measured blast radius: 22 library `<Card>`
  sites with no variant flip (hrms 18, account 1, tickets 3); 305 with an explicit variant are unchanged. The
  `SettingsSection` danger zone keeps its red frame by taking `variant="outline"`.
- **Light page canvas (`backgroundColor.base`) sits 1.09:1 below the card on all 9 brands**, so a borderless card keeps
  its top edge (zinc and slate `#fafafa` to `#f5f5f5`, lavender `#fbfafd` to `#f5f4f8`). `main` and every other token
  are unchanged.
- **`NavLauncher` outlines stay visible on the dark overlay.** Scope chips and arrows, the Top hit and answer cards,
  suggestion chips, pinned tiles, the Pin buttons, the tab bar, section rules and the rail and preview-pane edges were
  `boxborder[200]`, 1.00-1.07:1 against `surfaceRaised` in dark (zinc 1.07, the other eight under 1.03). They now use
  `raisedRule`: 1.32-1.39:1 against the overlay and 1.64-1.73:1 where a recessed pane meets it. Section rules inside the
  recessed rail use `ruleOn` against `backgroundColor.subtle` (1.31-1.36:1, was 1.17-1.26:1). Measured on all 9 brands;
  light mode is pixel-identical, since both helpers return `boxborder[200]` there.
- **`NavLauncher` confirms a copy on screen.** Enter or a click on a copy-only answer swaps the hint on its card and in
  the preview pane (`ENTER copy`) to `labels.copied` in `accentText` for 1.5 s, then restores it; only a visually hidden
  live region announced a copy before. The swap reserves the longer label's width, so nothing shifts, and its fade is
  off under `prefers-reduced-motion`. The live region still announces each copy once, focus stays in the field and a
  blocked clipboard never shows "Copied". On touch (`hover: none`), where the hint is hidden, the label alone pins over
  the card's right edge for the same 1.5 s.
- **`NavLauncher` drops the keyboard-hint row below `md`.** At 390px wide the legend wrapped to three rows (63.8px) and
  made the footer 100px tall, over a footer note that needs 32px; the footer is now 32px there, so the scrolling area
  (results, then the Pinned block) gains 68px. Touch devices already hid the legend at any width, and from `md` up it
  is unchanged.
- **`Button` `outline` / `ghost` / `link` ink clears AA on the light page canvas too.** The ink was lifted against the
  canvas, a card and an overlay but not `backgroundColor.base` (the canvas the example pages and any borderless-card layout
  sit on, 1.09:1 under the card), where it measured 4.21-4.49:1. It now steps one rung darker in 21 brand/scheme pairs, light
  mode only: zinc `gray` and `secondary` `#71717a` to `#52525b`, slate `primary` `#6b7280` to `#4b5563`, emerald and
  storefront `gray` `#617474` to `#5e7070`, `purple` `#805ad5` to `#6b46c1` on 8 brands, `green` `#198341` to `#136533` on 7,
  zinc `yellow` `#887425` to `#68591c`. Dark mode and every other `Button` pair are byte-identical.
- **`Tabs` selected ink clears AA on the light page canvas.** The selected `outline`, `ghost` and `line` ink was lifted
  against the tab canvas only, not `backgroundColor.base`, where slate measured 4.43:1 (record detail). On slate, light
  mode only, that ink, the `outline` border and the `line` indicator step from `#6b7280` to `#4b5563` (6.93:1). Every
  other brand and mode is byte-identical.
- **`Tabs variant="ghost"` selected ink clears AA on its own tint.** The ink was lifted against the canvas, but a
  selected ghost tab paints its own gray tint and the ink sits on that, where it measured 4.21-4.31:1 on radiant (light),
  emerald (dark) and rosewood (dark). It now steps one rung: radiant light `#b84533` to `#983628`, emerald dark
  `#0f9d8a` to `#14e3c7`, rosewood dark `#cb6b94` to `#ce91ab`. The other 15 brand and mode pairs are byte-identical.
- **`FilterSidebar`'s Clear control, range triggers and value rows, and rating rows draw the focus ring on keyboard focus
  only.** The shared halo and ring (`focusVisibleRing`), drawn inset on the value and rating rows because their panel
  clips overflow. Rest and mouse click look exactly as before.
- **`FilePreviewTrigger` shows its preview scrim and a focus ring on keyboard focus.** The eye-icon scrim that hover
  reveals now also appears when the thumbnail takes keyboard focus, with the shared halo and ring (`focusVisibleRing`).
  Rest, hover and mouse click look exactly as before.
- **`SelectSearch`'s `isBottomIcon` footer draws the focus ring on keyboard focus only.** The shared halo and ring
  (`insetFocusRingShadow`), drawn inset because the popup panel clips overflow. Rest, hover and mouse click look
  exactly as before.
- **`Editor`'s editing area joins the Tab order and its frame shows focus** (axe `scrollable-region-focusable`, WCAG
  2.1.1 and 2.4.7). Jodit ships the surface at `tabindex="-1"`, so a keyboard could not reach the text and a
  scrollable read-only editor could not be scrolled. `Editor` now sets `tabIndex: 0` (a `config.tabIndex` you pass
  wins), and while focus is inside it the Jodit frame takes the field focus look: a `primary[500]` border and the soft
  bloom, light and dark. At rest nothing changes. Each editor adds one Tab stop between its toolbar and its status bar.
- **`OrganizationDetails`' header avatar is the library's own `Avatar`** (it rendered a raw Chakra one). Chakra picked
  the initials ink by a brightness threshold: dark mode measured 1.98:1 (zinc 2.17:1) on all 9 brands and emerald and
  storefront 4.26:1 in light, at 9.6px. The fill now follows the brand and mode like every other avatar and the ink
  clears AA on all 9 brands in both modes. The avatar renders only with `userName`, since the owned `name` is required:
  without it the grey placeholder icon is gone.
- **`Header`'s bar row is `min-height` 4.3rem instead of a fixed `height`**, so a nav that wraps (many items, or
  `HeaderActions` labels in a narrow bar) grows the bar around it. The fixed height let the wrapped row hang off the
  bar onto the page canvas, where its count badge measured 1.55-1.91:1 on all 9 brands in light (axe `color-contrast`,
  `/header`). A bar whose nav fits on one row is pixel-identical, and 4.3rem is still its height.
- **`ScrollbarBox` is a keyboard stop while it scrolls.** A box whose content overflows a scrollable axis (more than
  1px) and holds nothing focusable now takes `tabIndex={0}`, so a keyboard user can scroll it, and draws the library
  focus ring on `:focus-visible` only. A box that fits, scrolls `hidden`, or holds a button or link is unchanged, as
  is a mouse click. The new optional `aria-label` names the stop as a `region`; without it the stop has no name.
  Closes axe `scrollable-region-focusable` on `/scrollbarBox` (6 regions).
- **`StageProgress`'s strip is a keyboard stop while it scrolls with nothing focusable in it.** At phone width a
  read-only strip (or one with every stage disabled) overflows and a keyboard user could not scroll it; it now takes
  `tabIndex={0}` and draws the library focus ring on `:focus-visible` only. A strip with a stage button, or whose
  stages fit, is unchanged, as is a mouse click. The new optional `aria-label` names the stop as a `region`, or names
  the list when `variant="stepper"`. Closes axe `scrollable-region-focusable` on `/stageProgress` (`[data-stage-strip]`).
- **`SignInActivityTable`'s scroll area is a keyboard stop while the table scrolls.** At phone width the three columns
  overflow the card and a keyboard user could not scroll them; the wrapper now takes `tabIndex={0}`, is a `region`
  named by `labels.title`, and draws a 2px ring on `:focus-visible` only. The ring is an outline in `focusRing.ring`
  (lifted until it clears 3:1 on the header band, on all 9 brands in both modes), because the library's inset shadow
  hides under the opaque header. A table that fits, a mouse click and the rest state are unchanged. Closes axe
  `scrollable-region-focusable` on `/signInActivityTable`.
- **`Menu` dividers keep their full weight in dark.** Chakra's stock divider carries `opacity: 0.6`, which pulled the
  `NavigationBar` avatar menu and `ThemeSwitcher` rules down to 1.17-1.21:1 on the overlay; the `divider` part now
  draws `raisedRule(colors)` at full opacity in dark (1.32-1.39:1) and stays at 0.6 in light, which is unchanged.
- **`Timeline` attachment-chip hover steps off the chip in dark.** The hover fill (`primary[100]`) sat 1.01-1.07:1 off
  the resting chip on meadow, emerald and storefront and kept the resting ink, 3.82-4.29:1 on it; it now uses `bandOn`
  (1.17-1.20:1 on those three) with an `accentTextOnCanvas` ink above 4.5:1 on every brand. Light is unchanged.
- **`Timeline` note box and "from" change pill step off the card in dark.** Both painted `gray[50]` on the event card
  at 1.11:1; they now use `bandOn(gray[50], background[50])` (1.19:1), and the note's attachment chip, edit hover and
  icon ink re-derive against the lifted box so they keep their step and AA ink. Light is unchanged.
- **`Timeline` rules inside the event card use the card rule in dark.** The note box outline and the divider above the
  notes drew `boxborder[200]` at 1.13-1.16:1 on the card (1.08:1 on zinc); both now read `cardRule(colors)` (1.31-1.34:1).
  The card's own outline and the date chips on the page canvas are unchanged. Light is unchanged.
- **`OrgSwitcher` search-header rule is visible in dark.** The rule under the search field was `gray[100]`, 1.11:1 on
  the raised popover; it now reads `raisedRule(colors)` (1.32-1.39:1). Light keeps `gray[100]`.
- **`ProfilePhotoViewer` view-button hover steps off the button in dark.** The hover fill (`gray[50]`) sat 1.11:1 off
  the resting `background[50]` button; it now uses `bandOn` (1.19:1) with the accent ink re-derived against it. The PDF
  fallback tile (`gray[100]`, 1.27:1) already steps and is unchanged. Light is unchanged.
- **`RolesPermission` header band and in-card rules are visible in dark.** The table header band (`background[100]`) sat
  1.06:1 off the card and the rules inside the card (card header, table rows, footer divider) drew `boxborder[200]` at
  1.08-1.16:1; the band now uses `bandOn` (1.19:1) and the rules `cardRule(colors)` (1.31-1.34:1). The card's own
  outline is unchanged. Light is unchanged.
- **`SearchSelect` `insideSelect` divider is visible in dark.** The rule between the nested picker and the input drew
  `boxborder[200]` at 1.22-1.25:1 on the trigger (eight of nine brands); it now reads `ruleOn` that plane (1.31:1+).
  The popup outlines are unchanged. Light is unchanged.

### Fixed

- **`SearchSelect` Select All row now highlights under the mouse.** Only the keyboard reached it before; hover filled every other row.
- **Native `Select` without a `value` snapped back to the placeholder, and `defaultValue` did nothing.** A native `Select` that never
  received `value` was pinned to `""`, so picking an option reverted at once unless `onChange` stored it. It now keeps its own
  selection, seeded by `defaultValue`, until a defined `value` is passed; after that `value` controls it as before.

- **`OrgSwitcher`'s open popup now has an accessible name.** The dialog carried no name (axe `aria-dialog-name`, serious); it
  now reads the existing `ariaLabel` prop ("Switch organization" by default). No new prop, no DOM id changes.
- **`FileUploader` revokes its `blob:` preview URLs on unmount.** Images still listed when the component unmounted kept their
  object URLs alive for the life of the page; removal, `maxFiles` overflow and `maxTotalSize` rejection already revoked.
- **`SearchSelect`'s "Clear all" button no longer opens the menu.** Its click bubbled to the control, so Enter, Space or a
  click on it cleared the value and then opened the list; the click is now stopped at the button, as the chip remove
  button already did.
- **`DescriptionList` stacked values wrap long unbroken strings.** A token or URL with no break opportunity overflowed
  its cell; stacked values now carry `overflow-wrap: anywhere`. Only strings that overflowed before change; the inline
  layout keeps its single-line truncation.
- **`FilterSidebar`'s section Clear control is no longer nested inside the accordion header button** (axe
  `nested-interactive`). `Accordion` items gain `headerAction` (a control beside the header button, which keeps the header
  hover lit) and `customStyles.headerActionStyle`; rest, hover and focus render the same pixels. The header button's
  accessible name is now the section label alone, and a click on Clear no longer reaches the header.
- **`FilterSidebar`'s maximum range picker is portalled like the minimum one.** Only the minimum picker was, so a host
  that clips or stacks over the sidebar (a positioned `overflow` ancestor) could cut the maximum list off. Both now share one picker.
  A range drag or an option toggle also re-renders only its own section instead of the whole sidebar.
- **`SelectSearch` popup keyboard and axe defects.** The sticky `isBottomIcon` footer no longer covers the row Arrow keys
  focus last (the panel reserves its height as scroll padding). An empty or loading panel is no longer a `listbox` with
  no options (axe `aria-required-children`); it keeps its text and drops the role. Tab out of the popup now closes it.
  Picking an option with the keyboard returns focus to the input instead of dropping it to `body`, and Escape in the
  input no longer swallows the next Tab-in open.
- **`NoteTextArea` Save stays disabled while either field is over its word cap.** Two effects each wrote the disabled
  state, so whichever field was edited last decided it: a long title could be saved after editing the note, and a long
  note after editing the title. Save is now derived from both counts.
- **`FormWrapper` forwards `id`, `data-*`, `aria-*`, `className` and handlers to its outer wrapper** instead of dropping
  them, and `icon` accepts a rendered element (`<FiUser />`) as well as a component reference; an element used to throw
  "Element type is invalid".
- **`VerifyEmailOtp` cells now offer one-time-code autofill.** `otp` is passed to the pin input, so the cells carry
  `autocomplete="one-time-code"` instead of `"off"` and the browser or OS can suggest the emailed code. The resend link
  is the owned `Button`.
- **`WorkspaceTrayPreview` no longer closes while the pointer sits on its trigger.** Moving from the card back onto the
  trigger ran the card's own leave handler and closed the preview after the grace delay; the wrapper alone now owns
  open and close.
- **`Select` `virtualizeAfter` now windows grouped lists.** It used to switch itself off whenever any option had a
  `group`, rendering every row; headers now take part in the window.
- **`Select`: Enter and Space on the clear (x) or a chip's remove button did nothing from the keyboard.** The trigger
  swallowed them to open the popup; keys from a nested button now reach the button.
- **The date, range and time picker popovers and `FilePreview` honour `prefers-reduced-motion`**: the popover slide/scale and the preview
  scale-in are skipped when the user asks for reduced motion.

- **The required `*` is hidden from screen readers** in `FormField` and every `TextLabel` label (it was read as "star" and
  became part of the field's accessible name). The control's own `aria-required` still announces it; sighted users see no change.

- **Legacy `DatePicker` (Table filter pane) no longer puts `aria-expanded` on a non-focusable wrapper `div`.** It anchors the
  popover like `SingleDatePicker` and the input carries `aria-haspopup="dialog"`; Escape on the focused field now closes the
  calendar. Looks and click behaviour are unchanged.

- **`Table` filter pane search box honours reduced motion.** The search field no longer slides in over 200 ms when the user prefers reduced motion; it appears in place.
- **`Table` notices when replaced rows gain or lose `content`, `onLink`, `onEdit` or `onDelete`.** The row-action and expand columns were decided from the row count and first id only, so a same-sized reload with the same first row kept a stale layout.
- **`ThemeSwitcher variant="segmented"`** is one tab stop, on the selected mode, like the radiogroup it declares.
  All three modes were separate tab stops. Arrow keys still move focus and selection together; a Tab from outside
  lands on the selected mode.
- **`LazyWrapper`** `fallback={null}` now renders nothing while loading, as written. `??` swapped the built-in
  spinner in for an explicit `null`; only an omitted `fallback` (`undefined`) selects the spinner now.
- **`WorkspaceTrayPreview`** reports `onOpenChange(false)` only after the card actually opened (it used to fire when
  the pointer left before the open delay).
- **`FileUploader`** revokes the blob URL of an image the moment it is removed, and of images it rejects (over the total
  size or past `maxFiles`); those URLs were created and never freed. Surviving URLs stay yours to revoke.
- **`Table` settings sheet** fades in place instead of dropping in when the user prefers reduced motion.
- **`SearchSelect`**: the Select All and Add New rows are reachable from the keyboard. Arrow keys, Home and End walk
  Select All, then the options, then Add New, and Enter runs them. Both are now `role="option"` rows, so a test that
  counts `getAllByRole("option")` with either row shown sees one or two more.
- **Field helper text no longer leaves a dangling `<id>-helptext` in `aria-describedby`** when a field with an
  explicit `id` sits inside a consumer `FormControl` (`TextInput` and every field rendering
  `FieldMessage`/`HelperText`).
- **`SearchSelect` and `Select` (`native={false}`) multi rows**: the decorative checkbox is no longer a focusable,
  unnamed control nested in the option; the row's `role="option"` with `aria-selected` is the single accessible
  control. No visual change.
- **`SearchSelect`'s open listbox and `Select`'s popup dialog have an accessible name**, from `label`, then
  `aria-label`, then `placeholder`.
- **Unnamed controls now have names, with no visual change.** `NoteTextArea`'s remove-attachment button is
  named `Remove <file name>`; the legacy `DatePicker` that `Table`'s filter pane renders names its month buttons
  (`Previous month`, `Next month`) and its two selects (`Month`, `Year`), as `CalendarPanel` does.
- **`FilterSidebar`'s Clear control, range triggers and value rows, and rating rows work from the keyboard.** They were
  mouse-only. Each is now a named control that Enter and Space operate (`Clear <section>`, `<section> minimum: <value>`,
  `<section>: N stars & up`), the open range picker is a named dialog, and the current value or rating reports
  `aria-pressed`. A test that counts `getAllByRole("button")` inside a `FilterSidebar` sees more of them. Rest and
  click look as before, except that opening a range picker with the mouse no longer paints a focus ring around the
  popover: the trigger now takes focus first, so the popover's own focus move is no longer keyboard-visible.
- **`FilePreviewTrigger` is a real button, named `Preview <file name>`** (`Preview file` without one). It was a click-only
  `role="group"` div the keyboard could not reach. Tab now lands on it, Enter and Space open the preview, and closing the
  preview returns focus to it. A test that found it by `role="group"` must query the button. Rest, hover and a mouse
  close look as before; opening the preview with the mouse no longer paints a stray focus ring on its first toolbar
  button, because the thumbnail now takes focus first.
- **`SelectSearch`'s `isBottomIcon` footer works from the keyboard.** It was a click-only div. It is now the last
  `option` of the listbox, named by its text: ArrowDown, ArrowUp, Home and End reach it, Enter and Space run
  `handleBottomClick` with the popup left open, Escape closes the list, and it is the only stop while nothing matches or
  the options load. `getByRole("listbox")` now contains the footer, and a test that counts `getAllByRole("option")`
  with `isBottomIcon` sees one more. Rest and click look as before.
- **`WorkspaceWindow`'s dialog is named by its visible title**; while a window is open the page behind it is
  `aria-hidden`, as for any modal.
- **`UpgradeButton`, `PlanPill` and `PlanBadge` are named by what they show** (WCAG 2.5.3, axe
  `label-content-name-mismatch`). The fixed `aria-label` left out the subtitle, the status chip and, on phones, the
  `mobileLabel`. Without an `ariaLabel` the name now comes from the content: `Pro 5d left`, where it was
  `Pro — 5d left`. A passed `ariaLabel` still wins and should contain the visible text. A test that looks the
  button up by the old dashed name needs the new one. Nothing changes visually.
- **`OrgSwitcher` names its trigger by what it shows** (WCAG 2.5.3, axe `label-content-name-mismatch`). The full
  layout was named by `ariaLabel` alone (`Switch organization`) and the avatar layout by the name without its
  initials, so neither held the visible text. Both now read `${ariaLabel}, ${shown organization}` (the placeholder
  when none is active). The avatar initials are `aria-hidden` generated content (`data-initials`), no longer text,
  which also takes them out of each dropdown row's name. A test that finds the initials with `getByText` must query
  `[data-initials]`; one that looks the trigger up by the exact name `Switch organization` needs the longer name.
  Nothing changes visually.
- **`CalendarGrid` day cells are named by what they show** (WCAG 2.5.3, axe `label-content-name-mismatch`). A
  cell's `aria-label` (`Monday, October 5, 2026, 1 event`) left out the event chips and `renderDayCell` content it
  displays. The cell now has no `aria-label`: a visually hidden date and count (`labels.eventCount`) come first and
  the cell's own content follows, so the name reads `Monday, October 5, 2026, 1 event Payroll cut-off`. A test that
  finds a cell by its old exact name needs a pattern or `data-date`. No layout change.
- **`FileUpload`'s progress bars are named** (axe `aria-progressbar-name`). Each `progress` entry rendered a
  `progressbar` with no name; it is now `Upload progress for <file name>`.
- **`FileUpload`'s drop zone is named by what it shows** (WCAG 2.5.3, axe `label-content-name-mismatch`).
  `label`, or `inputAriaLabel` (default `Upload files`), was the whole name, so it left out the visible `Drag and
  drop files here` and the type and size limits. The name is now `${label ?? inputAriaLabel}, Drag and drop files
  here .jpg, .png up to 5.00 MB`: a passed `inputAriaLabel` still leads. A test that looks the zone up by the old
  exact name needs a pattern. No visual change.
- **`Editor`'s editing area is named** (axe `aria-input-field-name`). Without a visible `label` the
  `role="textbox"` surface had no name at all; it is now `labels.editor` (default `Rich text editor`), and still
  `label` when one is given. A read-only editor also exposes `aria-readonly`. Give each unlabelled editor on a page
  its own `labels.editor`. No visual change.
- **`CustomModulesTable`'s edit-module field is named** by `labels.moduleNamePlaceholder`.
- **`StageProgress` no longer scrolls the page when it mounts**: the active stage was revealed with
  `scrollIntoView`, which also scrolled every ancestor, so a tracker below the fold pulled the page down
  to it. The active stage is now centred inside the stage strip only (`scrollActiveIntoView` still
  turns that off).
- **`Tabs` no longer scrolls the page when it mounts**: the selected tab was revealed with
  `scrollIntoView`, which also scrolled every scrolling ancestor, so a page with tabs below the fold
  jumped to them on load. The first reveal now scrolls only the tab list; later focus and selection
  moves behave as before.
- **The legacy `DatePicker` behind Table's filter pane opens from the keyboard**: Enter or ArrowDown on the
  focused field opens the calendar (it was mouse-only).
- **Keyboard access for click-only elements** (WCAG 2.1.1): `NoteTextArea` (the collapsed placeholder and
  the attach control, now a named button "Attach files"), `Notification` (the title when `onClick` is
  passed; the dismiss control is announced as "Dismiss notification"), `OrganizationDetails` ("Set as
  Default"), `UserDetails` (member rows, the selected one marked `aria-current`), `FileUploader` (the drop
  target opens the picker from the keyboard; it was the only way to add files), `KanbanBoard` (both
  add-column affordances) and the `SearchSelect` `insideSelect` prefix and menu (Enter/Space, arrow
  keys, Home/End, Escape). Each takes Tab, activates on Enter and Space, and shows the library focus
  ring on keyboard focus only; resting looks are unchanged.
- **`ProductCard` no longer throws "Rendered more hooks than during the previous render"** when `isLoading`
  goes from `true` to `false` (a hook sat below the loading early return).
- **`KanbanBoard` no longer logs React's "Cannot update during an existing state transition"** when a
  card is expanded in a virtualized column.
- **`PhoneNumberInput` follows a changed `value`.** It used to read `value` once on mount; a form reset
  or a loaded record now updates the field, and a leading dial code (the `onChange` shape) is stripped,
  so echoing `onChange` back into `value` is safe.
- **`CopyButton variant="menu-item"` is keyboard-operable** (focusable, Enter and Space copy, focus
  ring), and every variant announces the copied label through a polite live region.
- **`Slider` no longer auto-advances while hovered or focused, nor under reduced motion** (WCAG 2.2.2);
  the slide transition is instant under reduced motion. The default interval is unchanged.
- **`FileUpload` no longer leaks a blob URL per image thumbnail per render.** Each file gets one URL,
  revoked when the file is removed or the component unmounts.
- **`Table` column search shows the current search when reopened**, instead of stale text.
- **`NavLauncher` search rows shorten the `· keyword` hint before the label**, so a long keyword list
  no longer cuts "Apply for leave" down to "Apply…".
- **`NavLauncher`'s preview pane says what Enter does on a copy-only answer**: for an answer with no `run` or `href` it
  showed `labels.hintOpen` ("open here") while Enter copied the value; it now shows `labels.hintCopy`, as the answer card does.
- **`NavLauncher`'s footer legend says what Enter does**: it read `labels.hintOpen` ("open here") on every result, also while
  Enter copied a copy-only answer. It now follows the active result and shows `labels.hintCopy` while Enter copies. The
  other hints do not shift when it flips.
- **`NavigationBar` right cluster no longer overflows onto the avatar at phone widths.** The title
  truncates first (overflow-only tooltip when it is a string); the `moreIcon` cluster can shrink, while
  the sidebar toggle, back button and avatar menu never do. (HRMS, beta.60 launcher trigger overflow)
- **`OrgSwitcher` trigger shrinks and its menu fits the screen.** The full trigger truncates its label
  (avatar and chevron are kept) when its container is short, and the menu panel is capped at
  `100vw - 32px`, so a long org name no longer causes horizontal page scroll on phones.
- **`NavLauncher` keyboard and link fixes.** Arrow keys, typing and Backspace were dead on rail and
  apps rows while a search was active; Enter could throw when results shrank; Enter during IME
  composition opened a result; removing the focused row (e.g. an app dropped from `apps`) dropped
  focus to the page body (it now moves to the neighbouring row, or the field).
- **`Search` / `TextInput` / `Select` / `Textarea` / `NumberInput` / `FileUpload` / `PincodeChecker`
  focus glow was transitioned in**, so a screenshot or accessibility check taken the instant a field
  gained focus saw no ring. `FIELD_TRANSITION` no longer animates `box-shadow`; border-colour and
  background transitions are unchanged and still honour reduced motion.

- **`SideBar` rail controls now show a `:focus-visible` ring** (rows, submenu and flyout rows,
  `SecondaryBar`, collapse toggle, Add New, user footer, Collapse/Hide/Show, switch-app cells,
  overlay close) — WCAG 2.4.7 / 2.4.11.

- **Commerce performance (consumer report on 2.4.2-beta.57).** `ProductCard` no longer loads the
  hover image on devices that cannot hover; `QuickAddSheet` mounts on first open, not on first
  paint; `formatPrice` caches its `Intl.NumberFormat`; every `matchMedia` consumer
  (`usePrefersReducedMotion` and the layout hooks) shares one listener per query through a single
  `useMatchMedia`, replacing two duplicate hooks. `ProductDetails` desktop wishlist no longer sits a
  size step above the buy buttons.

- **`SingleDatePicker` / `RangeDatePicker` / `TimeOnlyPicker` popovers clipped inside a scrolling or
  `overflow: hidden` modal.** They now portal to `document.body` (via Chakra's own `Portal`,
  matching `Select`), so the calendar is no longer cut off by an ancestor's clipping or scroll
  container. Also fixes Escape not closing the calendar while focus was inside it (it only
  committed the value before).
- **`SideBar`'s `locked` flag didn't block clicks on a top-level item's `secondaryItems` row.** The
  rail/submenu/flyout rows already no-op'd a locked click; `SecondaryBar`'s own row renderer never
  checked `locked` at all, so a locked secondary item stayed fully clickable with no lock icon. See
  [COMPONENT-GOTCHAS.md](COMPONENT-GOTCHAS.md).
- **Control-size regression: field and button heights are two scales again, not one.** A
  form-field-only bump had leaked into `Button`, `SegmentedControl`, `Tabs`, `TableToggle` and
  `Dropdown`'s trigger through one shared token. `CONTROL_SIZES` (fields/selects: xs 24 · sm 38 ·
  md 40 · lg 48) and `BUTTON_SIZES` (buttons and every button-like trigger: xs 24 · sm 32 · md 40 ·
  lg 48, back to pre-regression) are now separate exports, gated by
  `Common/sizeScaleOwnership.test.ts` so they can't silently merge again. `OPTION_ROW_SIZES`
  (dropdown/select/menu row heights) is reverted to xs 24 · sm 30 · md 34 · lg 40; `OrgSwitcher`/
  `AppSwitcher` are back on their own hardcoded literal sizes, not a shared token.
- **`CONTROL_SIZES.xs` (fields only) raised 24px → 32px.** `TextInput`, `Select`, `MultiSelect`,
  `SearchSelect`, `SelectSearch`, `SelectV2`, `NumberInput`, `PinInput`, `Search` and the
  `DatePicker` family at `size="xs"` are now 32px tall. `BUTTON_SIZES.xs` (`Button`,
  `SegmentedControl`, `Tabs`, `TableToggle`, `Dropdown`'s trigger) is untouched, still 24px.
- **`SideBar`'s nav scrollbar thumb was the brand `primary` color.** Every other scrollbar in the
  library reads a neutral chrome token; the sidebar's hand-rolled CSS (`Constants/Sidebar.ts`) was
  the one place still reaching for `primary[500]`/`primary[700]`. It now reads
  `sidebar.mutedIcon`/`mutedLabel` — contrast-resolved against the rail, not brand-colored.
- **`SideBar` follow-up UI audit — six more fixes, no behavior/prop changes.** `SecondaryBar`'s own
  scroll panel had no scrollbar styling (fell through to the page's default) and a hardcoded,
  collision-prone id — now matches `SideBar`'s own generated-id + `cssForScrollBar` pattern. The
  collapsed-rail badge dot (`red[400]`) is now `sidebar.semanticText.error`, so it stays legible in
  both modes instead of drifting. Six `whiteAlpha.100` hover/rest fills (header collapse button,
  Add New, user footer, inline submenu row, app switcher cell, `SecondaryBar`'s collapse toggle)
  were below the sidebar's own perceptible-step floor — bumped to `.200`, matching every other row.
  The collapsed-flyout submenu showed Chakra's built-in "?" glyph for a sub-item with no `icon`; it
  now draws the same dot the always-visible inline submenu already used. Hovering an *active* flyout
  row got lighter-then-darker instead of lighter-then-lighter — fixed to the same `.300`→`.400` step
  every other active row uses. The default (no `logo`/`avatarUrl`) header logo tile and footer
  avatar initials painted a raw `primary` fill that measured ~1:1 against the rail — invisible — on
  zinc, skyline and storefront; both now use the same rail-safe surface/ink pairing the active-row
  accent already resolves against.
- **`SideBar` audit round 2 — muted-chrome contrast, inline-submenu consistency, demo previews.**
  `sidebar.mutedIcon`/`mutedLabel` cleared AA against the bare rail and `elevated` but not against
  a whiteAlpha hover/active overlay on top of either — now resolved with `settleAcross` against
  both surfaces at once, gated by new `paletteContrast.test.ts` cases across 9 brands x 2 modes.
  The expanded rail's always-visible inline submenu (`MenuItemBox.tsx`) now matches the
  fill+accent-bar+`activeAccent.label` pattern the top-level row and the collapsed-flyout submenu
  already use for an active item, instead of only swapping label color. Demo-only: the playground's
  docked-sidebar previews (`src/Pages/demo.sidebar.tsx`) rendered blank below the `lg` breakpoint
  because `SideBar` reads the real `window` width, not the preview box's — now shows a
  "widen the window" fallback there instead; the secondary-bar demo's panel was also widened so its
  drill-down chevron isn't clipped.
- **`Table` clipped its last row(s) whenever a cell wrapped to more than one line.** The container
  height was computed as `rows × density height + header`, assuming every row is exactly one line —
  wrapped text (long values, multi-column data) rendered taller than that, and the box clipped it
  behind a scrollbar even with empty space left on the page. Tables at or under the row-count cap
  (`maxVisibleRows`, default 10) now grow to fit their real content instead; larger lists keep the
  exact capped/scrolling height and virtualize as before, so nothing changes for big record counts.
- **Icons beside a field are centred at every size and height.** A lone right-hand icon in
  `TextInput` (an `inputRightIcon`, the clear button or the password toggle) sat flush against the
  border while the left one was inset; both now mirror at 6/9/12/15px. `SelectSearch`'s `rightIcon`
  was centred on a 40px box whatever the field size (up to 8px high at `lg`). `Tabs` drew a
  caller's icon at the caller's size inside a token-sized box, leaving it 3–6px off; it is now
  pinned to the size token. `TableSearch`'s clear icon and `TableToggle` had a literal `2rem`
  height and now follow the `sm` control height. `TextInput`'s `height` prop now wins over the
  size token's `minH`, so an explicit height below the token no longer leaves a taller input in a
  shorter group.
- **`*Props` JSDoc quoted the old control scale.** Eleven components documented "xs 24 · sm 32";
  they now say 28 / 35, and `Common/controlSizeDocs.test.ts` fails if they drift again.
- **`Table` — a column search only ever matched the page you were looking at.** The per-column
  search ran on `filteredData`, which `useTable` had already sliced to the current page, so a match
  on page 3 was invisible from page 1 and the pager still counted every row. The search now runs
  before the slice: page count, the `1 - 20 of N` readout and the rendered rows all come from the
  matched set, and typing a new search returns to page 1. Grouped mode already searched the whole
  source and is unchanged; server pagination still searches only the loaded page, since the client
  cannot see the rest.
- **`Table` — an empty page was a dead end, and a page-size change left the body scrolled.** The
  pager's step controls were hidden whenever the loaded rows were empty, so a server page that came
  back with nothing stranded the user with no way back; stepping back now survives an empty result
  and only stepping forward is disabled. A narrowing client-side result also clamps the current page
  instead of leaving it past the end. Changing the page size at page 0 swaps the rows without moving
  `startRow`, which used to leave the virtualized body scrolled into the old page — the scroll reset
  now keys on the page size too.
- **`Table` — a full-bleed status cell ignored `columns[].align`.** `align` is documented as aligning
  the header label and the cells together, but a bleed status cell hardcoded centre. It now follows
  `align` when the column sets one, and still centres when it does not, so existing tables look the
  same.
- **`Select` — the popup's filter field did not line up with the options under it.** Its wrapper
  used a hardcoded `p={2}` while option rows inset by the size spec's `mx` (6px at `sm`, 4px at
  `xs`), so the field's edges sat 2-4px inside the list's, and its text origin 2px further in. The
  field was also fixed at `size="sm"` whatever the `Select`'s own size, so a `lg` picker got a small
  search box. Both now follow the active size spec; measured edge- and text-aligned at every size.
- **`Select` — a multi-select with chips rendered 2px taller than every other field.** The chip row's
  vertical padding was computed against the control height without allowing for the trigger's own
  1px rule top and bottom, which a border-box trigger adds on top. The sizing now lives in one
  shared helper (`Common/chipTriggerSize`) that `Select` and the deprecated `SelectV2` both read.
- **`SearchSelect` — infinite-scroll paging could duplicate a page, loop forever on an
  exact-multiple total, leak a pending fetch into a closed/unmounted menu, and never scrolled the
  keyboard-highlighted row into view.** `handleScroll` now reads the live option count at fire
  time instead of the value captured when its 200ms debounce was scheduled, and won't re-request a
  `startIndex` it already asked for. `hasMore` takes new optional `pagination.hasMore` /
  `pagination.totalRecords` overrides, defaults `limit` to 10 (it used to never update when `limit`
  was omitted), and stops after one page that doesn't grow `options`. The pending page timer is
  cleared on close/unmount, and the active row now scrolls into view with `block: "nearest"`.
- **`Table` — frozen columns sat at the wrong offset, and the pagination bar was misaligned.** The
  sticky `left` was passed as a bare number, which Chakra reads off the space scale: a 48px offset
  rendered as `12rem` (192px), and any cumulative frozen-column width landing on the scale jumped
  4x. It stayed hidden because the old `CHECKBOX_SLOT_OFFSET = 50` is not a scale value — but 50
  also missed the real 48px select-all cell by 2px, leaving a slit that scrolled content bled
  through, and was wrong outright for `variant="studio"`'s 56px slot. The slot is now measured with
  a `ResizeObserver`, and both offsets interpolate explicit pixels. The pager is one 24px row: the
  `mt={3}` that faked centring is gone, the steps are `size="xs"` with 16px icons instead of
  Chakra's 40px default, and the page-size select no longer fights `justify="flex-end"` with
  `w="100%"`.
- **`Table` — virtualized body jumped to the top on an appended page, and never scrolled on a real
  page change below the virtualize threshold.** The scroll-reset effect now keys on `startRow`
  actually changing instead of on the 30-row virtualize threshold flipping, so infinite-scroll and
  grouped `groupLoadMore` appends no longer yank the user to the top, while a real page change
  resets correctly at any size. A shrinking `data` set no longer strands the window past the end,
  the offset-lookup binary-searches instead of scanning from 0, and measured row heights are
  pruned per page instead of growing forever.

- **`SegmentedControl` / `Switch` guessed the backdrop it was drawn on.** `brandSlide` painted an
  8% brand tint composited over `backgroundColor.main` and lifted its unselected-label ink against
  that same assumed canvas, so on a brand-filled bar the tint sank into the bar and the label
  measured **1.03–2.03:1 across all 9 brands x 2 modes** — crm's `Overview / Timeline` view switcher
  on a `primary[700]` bar was unreadable. The track is now an opaque plate on every backdrop: rest
  ink 7.58–18.39:1, plate-vs-bar 6.31–19.0:1, with no change required at the call site. `surface`
  is now only a refinement for the plate's hairline edge, and a test gates the invariant.
- **A selected `Select`/`SearchSelect`/`SelectSearch`/`CompactSelect` option stacked brand ink on a
  brand-tinted row** — `CompactSelect` had no selected styling at all. The label is now `text[900]`
  at weight 500 with the brand carried by a trailing check, matching `OrgSwitcher`. The row tint
  itself is also settled to one perceptible step off the panel instead of taking the raw token
  (`selected` 1.16–1.29:1 → 1.151–1.191:1; the pressed-and-selected state above it 1.34–1.52:1 →
  1.259–1.340:1), so a too-saturated tint is pulled down as reliably as a too-faint one is lifted.
- **Every dropdown row is now one size.** `Select`, `SearchSelect`, `SelectSearch`,
  `CompactSelect`'s filter, `DropDown`'s menu items and every themed `Menu` item shared six
  different paddings for the same row; `sm` is now 30px tall everywhere (`padding: 4px 10px`,
  `font-size: 13px`) instead of `SearchSelect` alone rendering ~34px with a 16px font. Row
  content — including a `SearchSelect` `option.view` or `Select` `renderOption` node — now inherits
  its font size from the row, so consumer-rendered row content is no longer stuck at the page's
  16px body font.
- **A dropdown row's description sub-line fell under AA in dark mode.** Settling the selected rung
  left `textMuted` at **4.00–4.22:1** on the hovered-selected row across 6 of 9 brands (dark
  `surfaceRaised` carries only ~0.86 of headroom, so any lighter rung eats it). The sub-line ink is
  now derived once per palette against that busiest rung, clearing 4.5:1 everywhere; light mode is
  unchanged because it already cleared.
- **`Switch` no longer forces a 200px-wide track when it has no labels.** The width floor now only
  applies once a label is set, so an unlabelled instance — the wrong component for a boolean field,
  but one that shipped live in a 120px table cell — shrinks to fit instead of overflowing by 80px.
  Use `ToggleSwitch` for an on/off field; swapping it in trades `role="radiogroup"` for
  `role="switch"`, the same `getByRole` break the `InputSwitch` → `ToggleSwitch` migration caused.

- **`SegmentedControl`'s pill bulged past the plate's corners.** The pill and the segments carried
  the track's own `lg` radius while sitting 2px inside it, so the plate's band stayed even along the
  straight edges but pinched to nothing at the corners. They now round concentrically at
  `calc(radii.lg - TRACK_PADDING)`, and a test gates the relationship.
- **Every field's focus ring was too heavy.** Fields reused the button ring — a 2px surface halo
  plus a 2px brand band — outside a 1px border that also turns brand on focus, so a focused field
  was ringed by 5px of chrome reading as one fat stroke. Narrowing and fading that band did not fix
  it: a `0 0 0 Npx` shadow has a hard outer edge at every opacity, and beside a same-hue border it
  always merges into one thick stroke. Fields now draw `focusGlowShadow` as a **blurred bloom** —
  `0 0 6px -1px primary.opacity[32]` — with the border still turning `primary[500]` as the
  accessible 3:1 indicator. Buttons, chips, checkboxes and every borderless control keep the
  two-tone ring unchanged.
- **The Switch demo's "brand-filled bar" was pale in dark mode.** It hardcoded `primary[700]`,
  which is deep in light and pale in dark, so the bar inverted and the whole comparison was
  unreadable. It now derives the bar from `resolveFilledSurface(...).activeFill` and **measures**
  both contrast ratios live against the active brand and mode, instead of printing two numbers that
  were only ever true in light mode. `segmentedTrackSurface` and `segmentedRestInk`
  are now one shared module, so the demo, the component and the contrast
  gate cannot drift apart.
- **`SegmentedControl` on a brand-filled bar was unreadable mud.** `brandSlide`'s track is an 8%
  brand tint, which on a brand bar composites straight back into the bar — the control lost its
  own surface and the labels had nothing to sit on. With `surface` the track is now an opaque
  plate (`surfaceRaised`) carrying a 1px inset edge lifted to 3:1 against the bar, because in dark mode
  the plate and the bar can sit at nearly the same lightness (1.56:1 on radiant) and hue alone
  would not separate them. The `/switch` demo shows the control instead of a pass/fail ratio pair.
- **`Tabs` drew a focus ring that was clipped into two arcs.** `TabList` scrolls, so the outer
  two-tone ring was cut off on all four sides and rendered as two disconnected side edges rather
  than a ring. The tab now draws it inside itself (`outline-offset: -2px`), clear of the `outline`
  variant's own border, in an ink lifted to 3:1 against that tab's own surface.
- **`SegmentedControl` / `Switch` drew the button's focus ring.** A segment sits inside a track, so
  the ring's surface-toned halo painted a white band and both bands landed outside the pill — a
  thick double border that overflowed the track. A radiogroup is also a single tab stop, so ringing
  one segment says nothing the selection does not already say. The **group** now carries the
  indicator: Button's own two-tone ring (halo plus brand band, at least 3:1 on any fill, light and
  dark) on the track while a segment has keyboard focus (never after a mouse click).
  `underlineSlide` has no track chrome, so there the
  segment wears `focusVisibleStyles`. `ThemeSwitcher`'s mode segments follow the same rule through
  their own border.
- **`Switch` clipped long labels.** Its 12.5rem width was fixed, so labels wider than the track
  overflowed the background; it is now a minimum. `SegmentedControl`'s track carries
  `minW="max-content"` for the same reason.
- **`ThemeSwitcher` had no visible focus indicator.** All four controls used Chakra's `boxShadow:
  "outline"` token, which resolves to a fully transparent shadow under these brand themes — keyboard
  focus was invisible. They now use the library's own rings.
- **Four fields never used the shared focus style.** `PinInput`, `VerifyEmailOtp`, the
  `CalendarPanel` month/year selects and the `FilterSidebar` search box each hand-rolled a focus
  shadow (the last one suppressed it entirely with `boxShadow: "none"`). All four now read
  `fieldFocusVisible`, so the library has one field focus treatment rather than five.
- `SearchSelect` drew its placeholder over the selected chips. The search input is `flex: 1`
  with `minW: 0`, so a field holding chips shrinks it to near-zero and the placeholder text
  overflowed across them. The placeholder is now the empty-state hint only — it is dropped as
  soon as anything is selected, open or closed.
- `Select`'s popup panel truncated long option labels. It no longer inherits the trigger width
  (`matchWidth`); it grows to `max-content` above a measured trigger-width floor, capped at
  `min(24rem, 100vw - 2rem)`.
- `usePrefersReducedMotion` crashed during SSR — it now guards `window.matchMedia`. Affected
  `Tabs`, `Toaster` and `SideBar`.
- `MediaViewer` coalesced several navigation calls landing in the same tick, so six rapid arrow
  presses advanced one item. Relative moves now compose.
- `SideBar` menu labels clipped descenders (g/y/p/q/j), and its nav list showed a bare, unthemed
  scrollbar when items overflowed.
- `SideBar`'s `locked` menu-item flag now actually blocks interaction.
- `sidebar.hoverBg` no longer shares `sidebar.divider`'s value.
- `InputSwitch`'s `colorScheme` was destructured and discarded — every switch rendered `primary`
  regardless of the prop. Now forwarded to `ToggleSwitch` and actually applied.
- **The `NavLauncher` search engine no longer puts Indic characters in a consumer's minified bundle.** Tamil final-ma and
  the Devanagari anusvara are built from code points, so hrms's first-load budget gate (it counts Tamil and Devanagari
  characters) stops reading the engine as locale data. `src/index.indicLiterals.test.ts` bundles the package root with
  esbuild and fails on any code point in U+0900 to U+0DFF.
- **`Table` / `KanbanBoard` saved preferences refetch when `orgId`, `authToken`, `page`, `key` or `name` change after mount.**
  The fetch depended on `baseUrl` alone, so a switched organisation kept the previous one's saved columns. A slow
  earlier response can no longer overwrite a newer one, the render a re-key lands is already `loading: true` with no
  stale data, and `useSavePreferences` clears `error` after a later success.
- **`KanbanBoard` re-hydrates its collapsed columns when `orgId` or `token` changes.** The reset effect listed `baseurl`,
  `page`, `key` and `name` only, so after an organisation switch the previous one's collapsed columns stayed.
- **`resolveFilledSurface` / `darkerFillRung`** no longer throw for a scale whose only white-carrying rung is `[50]`. The
  hover and active steps walked to rungs `150` and `250`, which do not exist; a step now moves along the rung table and
  clamps at both ends. Every scale that already worked resolves to the same rungs.
- **Playground demos stop touching the network and clear their axe findings** (demo-only, nothing here ships in the
  package). `/charts`, `/avatar`, `/editor` and `/examples/template-editor` no longer request imgur, a dead host or cdnjs;
  `/contactForm` no longer POSTs to the dev API. `/switch` names its narrow-cell options, the `/chartTheme` legend and the
  `/wishlistToggle` glass caption clear AA, and `/notification`'s colour override follows dark mode. `visuallyHidden`,
  `skipLink` and `productDetails` gain a Playground.

### Removed

Nothing exported from the package root. These files were never exported and nothing in the repo imported them; they are
gone from `dist/`, so a deep import of one no longer resolves. Measured 2026-10-14: no file in hrms, crm-frontend,
account-frontend, tickets, lfour, social-frontend or commerce deep-imports any of them.

- `services/feedback.ts` (use `FeedbackForm`), the legacy `Input/Input` field (`Input.tsx`, `InputProps.tsx`; use
  `TextInput`), `Common/ErrorComponent` (use `ErrorMessage` or `FieldMessage`), `Common/FormattedDescription`,
  `Common/Label` (use `TextLabel`) and `Buttons` (`Buttons.tsx`, `ButtonsProps.ts`; use `Button`).

### Deprecated

Each of these still ships its full implementation.

- **`OrganizationDetails` `uiInvite` is now optional and ignored.** The component never read it (active vs. invited is
  decided by which array an organization is in); callers that still pass it compile unchanged, and new ones can drop it.
- **`TextInput` `onRightIconclick`** (lower-case `c`): use `onRightIconClick`. Behaviour is unchanged; tagged `@deprecated` in JSDoc.
- **`NoteTextArea` callback arguments that are never passed**: `handleCancel`'s `isActive` (always `false`) and
  `handleSubmit`'s fourth `resetState`. Behaviour is unchanged; both are tagged `@deprecated` in JSDoc.
- **`Skeletons` `alignSelf` and `flexShrink`** are marked `@deprecated` in the type: they have never been applied, and
  applying them now would move layout for anyone passing them. Wrap the skeleton in an element that sets them.
- `UploadedFile.type` (FileUploader) — never set; read the extension from `name`.
- `ProfileCard` `color` — no visual effect; Card styles never read a colour scheme.
- `NotificationData.status` — never read; the status dot colour comes from `dueDate`/`endDate`.
- `CustomModulesTableLabels.loading` — never rendered; the loading state shows skeleton bars only.
- `WorkspaceWindow` `onMedium` and `onMaximize` — never called; the window has no such controls.
- `NavLauncher` `openShortcutHint` and `LauncherLabels.hintOpenMenu` — no longer rendered (the launcher
  is already open when its footer shows); bind the open chord in the app only. `hintOpenMenu` is now
  optional, so a full `LauncherLabels` object type-checks without it.
- `searchLauncher` — legacy flat search (one Screens group); `NavLauncher` never calls it. Classic mode
  runs `searchLauncherSections`, which matches a screen on its own label and keywords only ("leave
  balance" finds nothing unless one screen's keywords carry both words); `searchMode="spotlight"`
  ranks and also matches a screen on its module and section.
- `SelectV2` — use `Select` with `native={false}`; it is already a thin wrapper over it.
- `SearchSelect`, `SelectSearch` are **no longer `@deprecated`.** Both now say "prefer `Select`
  for new work" instead: `@deprecated` claimed a migration that loses real capabilities
  (`insideSelect`; and `SelectSearch`'s combobox is the text input itself, a different ARIA
  pattern). The marker returns once `Select` absorbs them.
- `Dropdown` is **no longer deprecated.** The marker was an artifact of the Select-consolidation
  pass; `Dropdown` is a `role="menu"` action menu, never a value picker, and nothing replaces it.
- `Notification` is **no longer deprecated.** `NotificationItem` cannot replace it —
  `formatRelativeTime` clamps every future date to "Just now", so a countdown card silently
  renders wrong text through it.
- `FilterSidebar` `onApply`, `headerPadding`, `sliderMode` and `FilterSection.checkboxes` — dead, never read.
  Every change is already live through `onChange`, the range slider is always native, and radio mode comes
  from `type: "radio"` or `radio`.
- Dead props now marked rather than silently ignored: `Checkbox.variant`, `ProgressBar.variant`,
  `Table.isVisiblity` and its export props, `SideBar.selectedSubMenu`, `InputSwitch.variant`/
  `.spacing`/`.isFocusable`, and nine native-only `Select` props that are inert under
  `native={false}` (these now warn in dev).
- `disabled` → `isDisabled` on `SideBar`, `StageProgress`, `DatePicker` and `TimePicker`. Both
  are accepted; `isDisabled` wins.
- `MultiSelctOPtions` → `MultiSelectOption` (two typos in a shipped public type name).

Also tagged `@deprecated` in the JSDoc, by component; each tag names its replacement. None is removed in this release.

- `AlertDialog` `addRole`, `colorScheme`, `size`, `status`, `variant` and `key` — never read. The dialog is always
  `role="alertdialog"` at Chakra's default size; use `tone` for the colour props, and React strips `key`.
- `ApexLineChart` `variant="realtime"` — does not animate the data it is given: it appends `Math.random()` values every
  second. No replacement ships; `"basic"`, the default, plots your `data`.
- `Breadcrumbs` `isLastChild` — never read, no effect.
- `ButtonGoupIconProps` (misspelled file path, a one-line re-export) — import `ButtonGroupIconProps`.
- `Card` `colorScheme` and `color` — no visual effect, Card styles read no colour scheme. `dividercolor`,
  `dividersize` and `dividervariant` — never rendered, Card draws no divider.
- `DatePicker` `placeholderText` — use `placeholder`; `placeholder` wins when both are set.
- `Drawer` `vaiant` — typo of `variant`, kept as an alias; `variant` wins.
- `Dropdown` `optionsSize` — use `size`.
- `InputSwitch` and `ThemedSwitchProps` — use `ToggleSwitch`; `InputSwitch` is a thin wrapper around it.
- `MediaViewerLabels` `photoCount`, `videoCount`, `documentCount`, `youtubeCount` and `shortcuts`, and the `loading`
  label of `MediaAudioPlayerLabels` and `MediaVideoPlayerLabels` — defined but never read.
- `OrganizationDetails` `OrganizationItem.uiInvite` — never read (active vs. invited comes from which array an item is in)
  but still required, so keep passing it. `CreateOrgFormValues` `default` and `defaultOrg` — never populated by the
  built-in create modal.
- `STATUS_INK` (`PlanPill`) — use `colors.white` from `useCustomTheme()`.
- `ProfileCardVariantLegacy` (`variant="outlein"`) — misspelling kept for existing callers; use `"outline"`.
- `ProgressBar` `color` — use `colorScheme`; `color` is still honoured while `colorScheme` is unset.
- `Search` `onclose` — use `onClear`; same behaviour, consistent casing.
- `SearchSelect` `onSelect` — use `onChange`, same payload. `virtualization` — not implemented, the list never
  virtualizes; page large datasets with `pagination`. `SelectSearch` `isSearchEnabled` and `BottomIconStyle` — never read.
- `chakraSelectProps` — the deep-import name of `SelectProps`; import `SelectProps` from the package root.
- `SelectV2SingleProps`, `SelectV2MultiProps` and `SelectV2Props` — use `SelectSingleProps`, `SelectMultiProps` and
  `SelectProps` with `native={false}`.
- `Switch` and `SwitchProps` — two labelled choices; use `SegmentedControl`, or `ToggleSwitch` for a single on/off field.
- `Table` `handleExportChange`, `exportOptions`, `exportLabel`, `headerBgColor`, `tableBorderColor`, `onColumnFilter`,
  `tableSettings` and the column-level `isSearch` — dead, never read.
- `Timeline` `events` and its `TimelineEvent` type — use `timelineEvents`. `TimelineNote.logs_id` is never read;
  `TimelineEvents.primary_id` and `is_from` are never read but still required, so keep passing them.
- `Toaster` `showToast({ children })` — never rendered; pass `description`.
- `avatarFillFor` — use `resolveAvatarColorway(seed, colors)`; different arity, returns `{ bg, color }`.

### Breaking in 3.0.0

**`ProductCard` / `ProductDetails` props are replaced, and `ProductImageSlider` is gone.** Zero
known consumers in the Pixelize repos. `ProductCard` drops `id`, `label`, `description`,
`productImage`, `tags`, `reviews`, `options`, `button`, `onClick`, `onAddToCart`, `onNotifyMe`,
`onOptionChange`, `addToCart`, `goToCart`, `notifyMe`, `OnGoToCart`, `batch`, `size`,
`avalabilitys`, `isQtyEditable`, `displayRating`; `ProductDetails` drops its old image/video,
option and cart-callback props (it takes `product`, `selection`, `onAddToBag` and slots). Video
and YouTube/Vimeo frames are now `ProductMedia` items in `MediaGallery`.

**`Card` `padding` narrows from Chakra's style prop to `CardPadding`.** `padding={4}` stops
compiling; use `p` / `sx` for arbitrary values. No use inside this repo; consumers not measured, so
run `tsc --noEmit` in each before upgrading.

**`star` is a required `PaletteProps` token.** A hand-written palette without it throws in
`assertPixelizePalette` naming `star`; palettes built from the library's builders get it for free.

**34 Chakra value passthroughs (and 12 type passthroughs) are removed from the Primitives barrel**,
because each duplicated a component this library already owns. The point of the library is one
place to change the UI; a Chakra twin beside an owned component means half the call sites bypass
the theme.

Every removal is a **compile error, never a silent behaviour change** — the owned components' props
differ, so `tsc` names each site and nothing must be found by hand.

- `Tabs` takes `value: string` where Chakra's took `index: number`.
- `RangeSlider`'s old sub-parts (`SliderTrack`, `SliderFilledTrack`, `SliderThumb`, `SliderMark`)
  are gone; the owned component renders its own internals.
- `Avatar`/`AvatarGroup` keep their names but narrow their props: `name` is required, Chakra style
  props are rejected, and `AvatarGroup` takes a `people` array instead of `Avatar` children.

**Blast radius is measured per consumer and stated once**, in the repository's `CLAUDE.md` section 7, rather
than copied here where it can drift.

Get the current number from `scripts/verify-consumers.sh check`, which typechecks every consumer
against a real build — **do not estimate it by grep.** A grep conflates names that were removed
outright (`ChakraModal*`, `CardBody`, `ModalContent`, the `Slider*` sub-parts)
with names that still resolve but narrowed (`Avatar`, `AvatarGroup`, `Tabs`, `Tab`, `TabList`, `TabPanel`, `TabPanels`, `RangeSlider`), and
overcounts badly — that mistake has already been made twice in this repo's own planning docs.

Layout and composition primitives with no owned equivalent stay exported: `Box`, `Flex`, `Stack`,
`Grid`, `Text`, `Heading`, `Divider`, `Badge`, `Alert*`, `Menu*`, `Popover*`, `FormControl*`,
`List*`, `Portal`, `TableRoot` and the Chakra hooks.

Consumers pin published versions, so none of this bites until a repo upgrades.

### Known gaps

Tracked in `.cursor/plans/deferred-breaking-changes.md` (repo-only). The ones a consumer can hit:

- `Select`'s `onLoadMore` is zero-arg with no `hasMore` gate, unlike `SearchSelect.pagination`.
- Migrating `SearchSelect` → `Select` by hand: `SearchSelect.onSearch` debounces **1000 ms**;
  `Select.searchDebounceMs` defaults to **0**. A mechanical rename starts firing the consumer's
  API on every keystroke.
- React 19 is not supported — `peerDependencies` pins React `^18.3.1`, in step with Chakra 2.8.
