# Design authority — which external standard owns which decision

**Status:** accepted · 2026-09-07

## Why this document exists

Every design question that has no external answer gets answered by us, in a meeting, again.
That is the expensive part — not the code. This file names, per layer, the outside system we
defer to, so the next question is a lookup instead of an invention.

It changes no code by itself. It is the tie-breaker a reviewer points at.

## The layers, and who owns each

| Layer                                                              | Authority                                       | Status in this repo                                                                                                                                                                                        |
| ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Interaction semantics, keyboard, ARIA                              | **WAI-ARIA APG**                                | already followed — 33 references in `src/`                                                                                                                                                                 |
| Behaviour primitives                                               | **Radix**                                       | already the implementation — 193 references                                                                                                                                                                |
| Component composition shape                                        | **shadcn**                                      | already the structural convention — 23 references                                                                                                                                                          |
| Component taxonomy / grouping                                      | **Ant Design** groups                           | already the catalog shape: `data-entry`, `data-display`, `layout`, `feedback`, `navigation`, `general` — a naming precedent, nothing is installed                                                          |
| Colour foundation                                                  | **SmartHR**, brand roles aside                  | the NEUTRAL spine is SmartHR's — `--foreground` = TEXT_BLACK, `--border` = BORDER. `--primary` was SmartHR MAIN `#0071bd` until identity v2.3 took the brand roles back: it is GoDX violet `#7A00FF` today |
| **Derived colour — the interaction states hanging off each seed**  | **Measured contrast (WCAG 2.2 / JIS X 8341-3)** | Authored in `src/tokens/derived.css`; no algorithm derives them. Four contrast suites read that file and hold every value to a threshold, and a fifth holds it to the seed's HUE (gh#648) — see below      |
| **Japanese UI convention — density, JP typography, form patterns** | **SmartHR**                                     | **NEW — this decision.** Extends SmartHR from "where the colours came from" to the authority for how a JP business screen behaves                                                                          |
| **Japanese accessibility / public-sector convention**              | **デジタル庁 Design System** (Digital Agency)   | **NEW — this decision.** The reference when a JP customer asks which standard a screen meets (JIS X 8341-3)                                                                                                |
| **Spacing, density, type scale, information architecture**         | **IBM Carbon**                                  | **NEW — this decision**                                                                                                                                                                                    |

The first five were already true and merely unwritten. The last three are the choices being made
here. Carbon fills the one layer that had no outside answer at all: page rhythm, table density, form layout,
when to reach for which container. That gap is why `PageContainer` presets, `MasterDetail`,
`CenteredShell` and the `AuthShell` variants were each designed from scratch.

## Why Carbon for that layer

**It is the closest fit to what this library is for.** Internal SaaS, dense, neutral, legible —
that is Carbon's design brief almost verbatim. Its visual language is deliberately plain, so it
does not fight the SmartHR colour foundation already in `foundation.css`.

**It is the cheapest to adopt, measurably.** The spacing scale here already matches Carbon's on
8 of 9 steps:

| `@godxjp/ui` | px     | Carbon                                   |
| ------------ | ------ | ---------------------------------------- |
| `--space-1`  | 4      | `spacing-02`                             |
| `--space-2`  | 8      | `spacing-03`                             |
| `--space-3`  | 12     | `spacing-04`                             |
| `--space-4`  | 16     | `spacing-05`                             |
| `--space-5`  | **20** | **no equivalent — Carbon steps 16 → 24** |
| `--space-6`  | 24     | `spacing-06`                             |
| `--space-8`  | 32     | `spacing-07`                             |
| `--space-10` | 40     | `spacing-08`                             |
| `--space-12` | 48     | `spacing-09`                             |

Body size agrees too: Carbon's `body-compact-01` is 14px, and `--font-size-base` is 14px.

**It is the most actively maintained of the candidates**, which matters when the point is to follow
someone rather than lead. Measured 2026-09-07 from the GitHub API:

| System                | Last commit | Commits, 90 days |
| --------------------- | ----------- | ---------------- |
| Carbon (IBM)          | 2026-09-04  | **418**          |
| Fluent UI (Microsoft) | 2026-09-06  | 220              |
| Polaris (Shopify)     | 2026-08-05  | —                |
| SLDS (Salesforce)     | 2026-06-02  | **0**            |
| Primer (GitHub)       | 2025-07-01  | 0                |

## Why not the others

**Salesforce Lightning** was the first instinct and is a reasonable one — it is the archetypal
enterprise system. Two things rule it out. Its public repo has not moved since June 2026 (SLDS 2
lives in separate `salesforce-ux/design-system-2` repos on a much slower cadence), and the part of
SLDS with the most value is bound to Salesforce's own object model — record home, related lists,
object pages — which does not transfer to a product that is not Salesforce.

**Fluent UI** is complete (54 component packages) and extremely well maintained. It loses on fit,
not quality: its guidance is written for Microsoft product surfaces and its visual language carries
more Windows/Office flavour than a white-label framework wants.

**Apple HIG** is platform guidance for macOS/iOS. It has close to nothing to say about a dense web
admin table.

**Polaris** has the best content-design guidance of the group, worth borrowing from for microcopy,
but its patterns assume the Shopify admin.

**Atlassian** is a genuine alternative to Carbon and would also work. Carbon wins on being more
visually neutral and on the spacing scale already matching.

## The Japanese market comes first, and it outranks Carbon

The customers are Japanese businesses. Where a Western system and a Japanese one disagree, **the
Japanese one wins for anything the user reads or touches** — typography, line rhythm, density,
form conventions, wording. Carbon keeps geometry (the spacing steps, the 4px grid) because that
layer is culture-neutral.

**SmartHR is the JP authority, not just the palette.** It is a Japanese HR SaaS — the same product
shape as ours, aimed at the same buyers — its library is open source, and it is the most actively
maintained system of any measured here: **561 commits in 90 days**, last commit 2026-09-07 (Carbon:
418). It was already the colour source; this decision extends it to how a JP business screen
behaves.

**デジタル庁 (Digital Agency) is the standards reference.** Its design system is the closest thing
Japan has to a public, government-backed baseline, and it is what a JP enterprise customer or
procurement reviewer will cite. Use it to answer "which standard does this meet" (JIS X 8341-3),
not for visual style.

### Japanese conventions this library already follows — now written down

These were correct decisions with no recorded reason. They are load-bearing; do not "simplify"
them toward a Latin default:

- **`--line-height-body: 1.7`.** Japanese body text needs 1.7–2.0, not the Latin 1.5. Full-width
  kana and kanji fill the em box, so the same 1.5 that reads as airy in English reads as cramped in
  Japanese. `--line-height-normal: 1.5` remains for Latin-only runs.
- **14px body.** The JP business-software norm, and it happens to agree with Carbon's
  `body-compact-01`. Do not raise it to a Western 16px default.
- **A per-language font slot, not one merged stack.** `--font-sans-base` is deliberately a pure
  system stack so the library renders with zero font setup; Japanese arrives through
  `--font-sans-ja`, which `styles/base.css` wires to `[lang="ja"]`. Keep the indirection — merging
  a JP face into the base stack looks like a simplification and quietly removes the hook a consumer
  themes through.
- **Density never touches type size.** `density.css` sets `--scaling` only; `font-size` appears
  **zero** times in it. This is the single most important rule for dense JP screens: Japanese loses
  legibility far faster than Latin when shrunk, because the strokes-per-em of a kanji do not
  survive it. Compress spacing to fit more on screen — never the characters.

## Compact without becoming ugly — the rules that hold both

"Fit more on one screen" and "still looks composed" are not in tension if the compression is
applied to the right axis:

1. **Compress spacing, never type.** See above. `--scaling` is the only density knob.
2. **Keep the ratio, shrink the unit.** The φ macro rhythm (`docs/SPACING.md`) stays intact under
   density because `--phi-unit` is itself a spacing token — the proportions between sections are
   preserved while the absolute gaps shrink. That is why density does not make the page look
   broken, only tighter.
3. **Density is a token axis, not a per-screen decision.** `data-density` on the root, three values.
   A screen that wants to be denser than `compact` is a screen with too much on it.
4. **The information ceiling is the row, not the page.** Dense JP admin screens get their density
   from row height and column count (`--table-cell-padding-y`, `--band-height-*`), not from
   squeezing the page gutter. Take row heights from SmartHR's tables.
5. **Touch targets do not participate in density.** See divergence 5 — this is currently violated.

## What this does NOT mean

**We do not adopt Carbon's components.** There are 127 catalogued components here with 3,480 tests
and a token contract that took #316/#319 to land. Swapping libraries would throw that away to buy
guidance we can simply read. Carbon is the **specification authority**, not a dependency.

**We do not adopt Carbon's colours or brand.** Colour stays SmartHR-anchored. Carbon owns
geometry and rhythm; SmartHR owns hue.

## The open divergences

Recorded rather than silently fixed, because each is a real decision:

1. **`--space-5` (20px) has no Carbon equivalent.** Carbon steps 16 → 24 on purpose: a scale with
   both 20 and 24 lets two authors space the same relationship differently. Either retire it or
   write down what it is for. Pinned by `src/tokens/__tests__/carbon-scale-alignment.test.ts`, which
   fails if a NEW off-scale step appears.
2. **The type scale diverges by construction.** Carbon uses integers — 12, 14, 16, 18, 20, 24, 28,
   32, 36 — while `--font-size-ratio: 1.1227` (φ^¼) produces 11.1, 12.5, 17.6, 19.8, 22.2. Only the
   14px body agrees. Fractional sizes do cost something measurable — they snap a line box half a
   pixel either way, which is ±0.5px of the residual in gh#370 — but they are not what that issue
   is. gh#370 is that `place-items: center` centres a LINE BOX and a line box is not a letterform;
   re-measured from the painted ink, the glyph classes' optical centres lie 0.21em apart (`g` 3.69px
   low in the 28px `md` box where `神`, `G` and `GX` are within 0.94px), so no glyph-blind rule
   centres them all — the best glyph-blind rule, `text-box: trim-both ex alphabetic`, still leaves
   2.25px. So the correction moved up a layer: `Logo` classifies the glyph string it is handed into
   the ink band it occupies and CSS applies that band's offset, which measures 1.38px worst case and
   needs no `text-box` at all (`src/styles/logo-layout.css` carries the full matrix). Aligning the
   scale is a visual change to every screen, so it is a separate decision, not a side effect of this
   one.
3. **The large end is missing.** Carbon has 64/80/96/160px; this scale stops at 48px. Nothing needs
   them yet — add from Carbon's steps when something does, rather than inventing a number.
4. **2px (`spacing-01`) is missing.** Same rule: take Carbon's value if a hairline gap is ever
   needed.
5. **`--scaling` takes the whole scale off the 4px grid, and undercuts the 44px touch floor.**
   `density="compact"` sets `--scaling: 0.92`, so every step becomes fractional — 16px → 14.72px,
   24px → 22.08px — and the grid Carbon's scale is built on only holds at default density. Worse,
   the coarse-pointer override in `tokens/components/control.css` raises controls to
   `--band-height-xl` (44px, "rule #24"), but `--control-height` multiplies it by `--scaling`
   anyway: **44 × 0.92 = 40.48px on a touch device at compact density.** That still clears WCAG 2.2
   AA (24px), but it breaks this library's own rule #24. Carbon's answer to density is discrete
   size variants that stay on the grid, not a continuous multiplier — worth reconsidering. Verify
   on a real touch device before changing anything.
6. **Type scale vs Japanese rendering.** Divergence 2 is sharper for JP than for Latin: fractional
   sizes (12.5px, 17.6px, 19.8px) put full-width glyphs on half-pixel boundaries, where kanji
   strokes blur far more visibly than Latin letterforms. Another reason to prefer Carbon's integer
   steps if the scale is ever revisited.

## The PROP SURFACE of a component is antd's too, not just its group name

antd was already the authority for which components exist and which group they live in. That
answered "is there a Table" and never answered "what does a Table take". So every prop was decided
here, one at a time, and the answer drifted per component: `DataTable` grew `pin: "end"` where antd
has `fixed`, `sortable: true` where antd has `sorter`, and no answer at all for filters, expandable
rows or a totals row — which is how a consumer ends up hand-rolling a `<tfoot>` and a sticky column
in page CSS.

**The rule: where antd names a capability, this library takes antd's name and antd's semantics.**

> **Restated and widened by the repo owner on 2026-09-12, because the rule above was being read as
> advice rather than as the standard it is: Ant Design IS the standard. A missing capability is
> ported from antd 100% FIRST — its names, its props, its semantics — and improved afterwards.
> Not redesigned first, and not half-ported.**
>
> Two things forced the restatement, both measured rather than felt:
>
> - `Dialog` + `AlertDialog` ship **26** exports with **12 name-for-name pairs and 0 parts unique
>   to `AlertDialog`**, whose entire difference is `role="alertdialog"` plus `isDismissable={false}`
>   — two props the shell already takes. antd has exactly one `Modal`, where danger is `okType`
>   and `Modal.confirm()`. The shape here came from Radix, silently, against this very rule
>   (gh#567). A package-wide sweep over all 272 public exports found this is the ONLY such pair:
>   `Skeleton.Avatar/Button/Input/Image/Node` looks identical in shape but is antd's own naming,
>   so it is compliance, not drift.
> - Whole families arrived half-ported: Ant Design X without `Conversations`, `Attachments`,
>   `ThoughtChain`, `Welcome` or `Actions` (gh#559); `FloatButton` never at all (gh#558);
>   `Typography` reduced to `Text` + `Heading`, with `Paragraph`, `Link`, `copyable`, `editable`,
>   `mark`, `keyboard` and `italic` simply absent.
>
> A deviation from antd is still allowed — the three below are — but it must be WRITTEN DOWN at
> the point of deviation. An undocumented deviation is a bug, and gh#567 is what that bug costs:
> a consumer forced to pick between the right ARIA role and a form it needs, and an accessibility
> decision pushed onto the party least able to make it.
> The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
> `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) — never
> from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
> of `start` inside rc-table itself).

> Đọc sau 20.0.0: bản major ấy đã **gỡ `antd` khỏi devDependencies** cùng máy sinh màu của nó, và
> `check:no-antd-runtime` canh cho nó không quay lại. Câu trên mô tả cách bề mặt prop này ĐƯỢC ĐỌC
> lúc antd còn cài, không phải một lời mời cài lại. Lần sau muốn đối chiếu, đọc type ở một checkout
> riêng rồi ghi số hiệu bản vào đây — đừng thêm dependency.

**Three things override antd's spelling, each for a stated reason:**

- **Logical over physical.** antd's `fixed: 'left' | 'right'` cannot mirror for an RTL locale, so
  only `start` / `end` are published. Same rule that makes `check:rtl` a gate. Table
  `pagination.position` follows it (gh#705): antd `topLeft | topCenter | topRight | bottomLeft |
bottomCenter | bottomRight | none` is `topStart | topCenter | topEnd | bottomStart | bottomCenter |
bottomEnd | none` (`TablePaginationPositionProp`, default `['bottomEnd']`), the camelCase shape
  `DropdownMenuPlacementProp` already uses. antd's `size="small"` Table → small pager is
  `density="compact"` → `Pagination size="sm"`.
- **This library's controlled vocabulary wins on values.** antd's `SortOrder` is
  `'ascend' | 'descend'`; here it stays `SortDirectionProp` (`asc` / `desc`), because that type
  already exists and a second spelling of the same axis is exactly what `check:prop-vocabulary`
  exists to prevent.
- **A capability this library already has keeps its own name.** antd's `size`
  (`small | middle | large`) IS `density` (`compact | default | comfortable`); antd's `locale` IS
  the `t()` layer. Adding the antd spelling as an alias would be duplication, not parity.
- **`SegmentedOption.count`.** antd's `SegmentedItemType` has no count field — consumers embed counts
  in `label`. Nested `Badge` `secondary` is `--muted`, which matches the Segmented track
  (gh#602, measured 1.00:1). This library adds `count` / `overflowCount` / `showZero` /
  `countLabel` on `SegmentedOption` with a DS-owned pill (`--segmented-count-*` tokens), same
  counter vocabulary as `Button` / `Toggle`.
- **`DraggablePanel.labels`.** antd's Draggable Modal demo has no i18n hook for the drag handle.
  Embedded consumers cannot mount `AppProvider` without writing theme attributes on the host
  `document.documentElement` (gh#606). `labels?: { close?: string; move?: string }` overrides `t()`
  per control; a scoped `AppProvider` subtree remains the long-term fix.
- **`Badge.onRemove` instead of antd `Tag`'s `closable` + `onClose`.** Semantics match antd's
  closable tag (a chip that draws its own × and fires one callback). The boolean is folded into
  prop presence (`onRemove` omitted ⇒ no ×), and the callback is named `onRemove` rather than
  `onClose`, because `onClose` already means overlay dismiss across Dialog/Drawer and would read
  as closing a surface, not removing one applied filter chip. Implemented on `Badge` — the DS chip
  primitive — rather than adding a separate `Tag` export beside `Badge`.
- **`ChatComposer.submitType="modEnter"` and `ChatComposer.allowEmptySubmit`.** Ant Design X
  `Sender.submitType` is only `"enter" | "shiftEnter"`, and `Sender` never submits an empty draft.
  A comment composer on a record needs the convention GitHub, Jira, Linear and GitLab share —
  `Enter` breaks the line, `⌘+Enter` (Apple platforms, `metaKey`) / `Ctrl+Enter` (elsewhere,
  `ctrlKey`) sends — and needs to post a status change with no text (gh#693). `"modEnter"` extends
  antd's union rather than renaming it, so both antd values keep their meaning; its hint is
  `dataEntry.chatComposer.hintModEnter` with `{modifier}` from `isApplePlatform()`
  (`@godxjp/ui/lib/utils`). `allowEmptySubmit` (default `false`, antd's behaviour) sends `""`,
  still never while `loading` / `disabled` / `readOnly`.
- **`Table.striped` / `DataTable.striped` (gh#700).** antd names no zebra capability — its docs
  stripe through `rowClassName` — so there is no antd spelling to port. Element Plus calls it
  `stripe`, Bootstrap `.table-striped`, MUI documents `:nth-of-type(odd)`. This library takes
  `striped`: a positive boolean adjective, the shape `bordered` and `hoverable` already have on the
  same component, and the spelling `DataTable` had shipped since 17f4a067. It is TRI-STATE on
  purpose: omitted inherits the service default `--table-row-striped-alpha` (`0%` at `:root`, so
  one theme line — `:root { --table-row-striped-alpha: 100%; }` — stripes every list table),
  `true` / `false` override it per table. `rowClassName` is not the answer here: it counts DOM
  rows, so an expanded detail row shifts every stripe after it, and `ui-audit` blocks the
  utilities it would need.

- **`Masonry.gap` is antd `gutter`, and its breakpoint steps are this library's (gh-masonry
  port).** Three divergences on one component, each for a reason already written down elsewhere in
  this file:

  1. **`gutter` → `gap`.** antd's `gutter` takes a raw pixel number (or a per-breakpoint map of
     them). This library already owns that axis under the name `gap` on `Flex`, `ResponsiveGrid`
     and `AuthStack`, `check:prop-vocabulary` maps a field called `gap` to `GapProp`, and a token
     step is what makes spacing follow `--scaling` (density) and a tenant theme. "This library's
     controlled vocabulary wins on values" is the rule; here it wins on the name too, because the
     name and the value type move together. antd's tuple `[Gap, Gap]` survives as
     `[GapProp, GapProp]` = `[inline, block]`. **`gutter` is not silently absent**: it is declared
     on the prop type as `never`, so a consumer arriving from antd's docs gets a compile error and
     a `@deprecated` hint naming `gap`, plus a development-time `console.warn`.
  2. **`columns` steps are `base sm md lg xl`, not antd's `xs sm md lg xl xxl`.** antd's `xs` IS
     this library's `base` (the mobile-first floor), and a second spelling of one axis is what
     `check:prop-vocabulary` exists to prevent; `xxl` has no step here at all. `Flex direction`
     is the precedent shape and the media queries are literally the `--flex-direction-*` cascade's
     (sm 40rem · md 48rem · lg 64rem · xl 80rem). Both antd spellings FAIL LOUDLY — TypeScript
     rejects them as excess properties and the component names them in a development warning —
     because a silently-ignored `xs` is the worst outcome of the three.
  3. **`MasonryItem.height` is HONOURED, where antd declares it and never reads it.** Verified in
     antd `master`: `Masonry.tsx` builds every `ItemHeightData` from `getBoundingClientRect()`
     alone, and all six demos carry their heights in `data`. Porting the field as inert would ship
     a documented prop that does nothing. Here a finite `height` sizes the tile and replaces its
     measurement, which is also what lets a first paint and an SSR render land in the right place.

  Not ported, each under a rule that already exists: `classNames` / `styles` (a knob only a fork
  could reach — `src/tokens/components/masonry.css` is the answer), `prefixCls` / `rootClassName`
  (no `prefixCls` layer here), and rc-motion's tile-REMOVAL animation (the arrival fade and the
  re-flow slide are ported in CSS and both snap under `prefers-reduced-motion`). `fresh` keeps
  antd's name and antd's `false` default even though `docs/roadmap/list-masonry.md` (2026-09-10)
  proposed renaming it `observeItems` and defaulting it on — that proposal predates the
  2026-09-12 restatement above, and "the antd name says nothing" is exactly the reasoning the
  restatement rejects. The same roadmap's invented `sequential` prop is not ported for the same
  reason: antd has no such knob, and improvements come after parity, not instead of it.

- **`Affix` and `Anchor` take the LOGICAL axis where antd takes the physical one (gh#827/gh#828).**
  Ported from `ant-design@master`, read out of `components/affix/{index.tsx,utils.ts}` and
  `components/anchor/{Anchor,AnchorLink}.tsx` on 2026-09-21, not out of the docs tables. Every
  antd prop is present. Four renames, and nothing else:

  1. **`offsetTop` → `offsetBlockStart`, `offsetBottom` → `offsetBlockEnd`** (both components;
     `Anchor.targetOffset` → `targetOffsetBlockStart` with it). `check:rtl` exists to keep
     `inset-block-start` out of `top`'s hands in every stylesheet here, and a prop that names a
     physical edge re-opens that door at the API. It is also load-bearing rather than cosmetic:
     the offset is a CSS custom property (`--affix-inset-block-start`), read once by the sentinel
     that detects the crossing and once by the pinned bar that paints it, so the name has to be
     the one CSS uses. All three antd spellings are declared `never` — a compile error carrying an
     `@deprecated` hint that names the replacement — plus a development `console.warn`, plus the
     MCP catalog entry. The `Masonry gutter → gap` precedent, applied to a second component.
  2. **`Anchor.onChange` → `onValueChange`,** beside new `value` / `defaultValue`. The active
     `href` is a controlled value and this library has one spelling for that. antd has no
     controlled form of it at all (`getCurrentAnchor` is a transform of the computed answer, not a
     value), so this is an ADDITION with a rename attached rather than a substitution, and
     `getCurrentAnchor` is ported unchanged beside it — a controlled `value` simply outranks it.
     antd's own note is ported too: `onValueChange` reports the link the SCROLL POSITION resolved,
     not the one `getCurrentAnchor` substituted.

  **`Affix.onChange` is NOT renamed**, and the distinction is the point: there is no pinned VALUE
  to control, only an observation of a derived boolean, so no triad applies. `Attachments.onChange`
  and `ActionsFeedback.onChange` already carry antd's name with a non-DOM payload for the same
  reason.

  **Not ported, each under a rule that already exists:** `classNames` / `styles` on `Anchor` (a
  knob only a fork could reach — `src/tokens/components/anchor.css` is the answer),
  `prefixCls` / `rootClassName`, and antd's deprecated `Anchor children` / `Anchor.Link` JSX form
  (antd itself warns on it and says to use `items`).

  **One deliberate behavioural difference, in `Anchor`.** antd resolves from scroll position on
  mount unconditionally, which overwrites a landing hash on any page whose hashed section happens
  to be above the fold. Here the hash is read in the state INITIALISER and the mount resolution is
  skipped when it matched, so `/page#section` is correct on the first render with no scroll event
  ever firing — which is gh#828's acceptance criterion, and the one place antd's behaviour is
  worse rather than merely differently named.

  **Two things antd does not have at all**, both required by the contract rather than invented:
  `Anchor.label` (antd ships an unnamed `<div>`; a `<nav>` landmark on a page that already has a
  breadcrumb and a rail must be named, so `label` defaults through `t()`), and `Affix`'s
  `scroll-padding-block-start` on the scroll box while pinned (WCAG 2.4.11 — without it the pinned
  bar covers whatever the browser scrolls a focused element to).

- **`Command.split` is antd `List.split`, with this library's default.** Same name, same meaning:
  a divider between rows and none after the last (gh#699). antd's `List` defaults it to `true`;
  `Command` defaults it to `false`, because its resting home is a command palette, where inset
  pill rows are right. The option lists the library renders itself as a checklist
  (`Select mode="multiple" | "tags"`) set it to `true`, and a consumer composing a filter facet
  (`Popover` + `Command`) opts in with `split`. It also zeroes the list padding and bleeds rows to
  the panel edge, which antd's `split` does not need to say because a `List` has no inset rows.

- **`ResponsiveGrid.align` is antd `Row align`, with Flex's values (gh#708).** antd spells the
  cross-axis `top | middle | bottom | stretch`; this library already names that axis
  `FlexAlignProp` (`start | center | end | stretch | baseline`), so `ResponsiveGridAlignProp` is
  `Extract<FlexAlignProp, "start" | "stretch">` — antd `top` → `start`, `stretch` unchanged.
  `middle` / `bottom` are not offered until a screen needs them. The default differs by flow on
  purpose: omitted, `flow="columns"` keeps `start` and `flow="rows"` keeps the grid's stretch, so
  no existing grid moves; antd's `Row` default is `top` in every case.

- **`Tree.divided` — a new capability, named from this library, not from antd (gh#732).** antd's
  `Tree` has no horizontal rule between nodes at all (only `showLine`, the VERTICAL parent/child
  rail), so there is no antd spelling to port and nothing to be out of parity with. The name was
  picked from the two the library already uses for the idea, and they are not interchangeable here:
  **`bordered` means a GRID** — `Table` / `DataTable` "outer frame + vertical rules between
  columns", `Descriptions` "bordered table with shaded label cells", `Calendar` "rule the day
  grid", `RangeTimeline` "row rules, header rule, a vertical rule per column" — while the
  horizontal hairline BETWEEN rows of a vertical list is called a **divider** everywhere it exists
  (`--list-row-border` on `ListRow`, `--command-item-divider-color`, `--table-flush-divider-*`,
  `--card-*-divider`). A `Tree` has no columns and no frame, so `bordered` would promise chrome the
  prop does not draw and would collide with a real Tree frame later. `Command.split` is the same
  hairline, but that name is antd `List.split` adopted verbatim (gh#699) and carries a second
  meaning there — zero list padding, rows bled to the panel edge — which `Tree` must not inherit.
  So: `divided`, matching the token `--tree-divider-color`, a positive boolean adjective like
  `striped` (gh#700). It **defaults to `false`**, unlike `RangeTimeline bordered`: a Gantt body is
  unreadable without a grid, whereas `Tree`'s documented chrome default is quiet (cardinal rule
  #44 — `showLine` ships off for the same reason, and the token file states it), and default-on
  would rule every existing tree, including the picker-sized and `size="xs"` outlines where a rule
  every 24px is noise rather than structure. A tree used as page navigation opts in.

- **`RangeTimeline` nested rows take `Tree`'s spelling, not antd's (gh#724).** antd has no Gantt;
  the nearest names are `Tree expandedKeys` / `defaultExpandedKeys` / `onExpand` and `Table
expandable.expandedRowKeys` / `onExpandedRowsChange`. This library already mapped the Tree ones
  onto its controlled vocabulary as `expandedValues` / `defaultExpandedValues` /
  `onExpandedValuesChange`, and a second spelling of the same axis is what
  `check:prop-vocabulary` exists to prevent — so `RangeTimeline` reuses those three names exactly
  (the values are row `id`s). The shape is also `Tree`'s: flat rows plus `depth`, the component
  owns the indent (`--range-timeline-indent-width`) and the disclosure. Two deliberate differences
  from `Tree`: (1) a row is a parent when the NEXT row is deeper — derived from the depth-first list,
  never from a `hasChildren` flag, so a disclosure can never promise children that are not there;
  (2) omitted `defaultExpandedValues` starts every parent EXPANDED (a `Tree` starts closed), so
  adding `depth` to an existing schedule never hides a row. The disclosure is a real `Button`
  (the row is not a focusable tree item, so APG's one-tab-stop rule does not apply), and the rows
  are a `list` of `listitem`s with `aria-level` / `aria-setsize` / `aria-posinset` — the lightest
  role that carries a level without promising the `tree` / `treegrid` arrow-key model. A timeline
  with no `depth > 0` keeps its pre-#724 markup byte for byte.

- **`ToggleGroup.disallowEmptySelection` — React Aria's name, and a deliberate divergence from
  Radix's MARKUP (gh#744).** antd has no ToggleGroup at all (its nearest controls are `Segmented`
  and `Radio.Group`, both of which are always-one-selected by construction and name nothing here),
  and Radix's `ToggleGroup` has no such prop — so there is no antd spelling to port and no Radix
  spelling to keep. The name is taken from the library this component is actually built on:
  `react-stately`'s `useToggleGroupState` and `react-aria`'s `AriaToggleButtonGroupProps` both
  call it `disallowEmptySelection`, and it is already the state hook's own switch, so a second
  spelling would be a rename of a prop we pass straight through. It is a negative boolean, which
  the vocabulary otherwise avoids (`allowClear`, `allowEmpty`, `allowEmptySubmit`) — `allowEmpty`
  was rejected because `RangePicker.allowEmpty` already means `[boolean, boolean]` on the same
  prop surface, and one word must not carry two shapes.

  The divergence is not the name, it is the markup. Radix's single group emits
  `role="radiogroup"` / `role="radio"` + `aria-checked` **and still lets the user clear the
  selection by pressing the checked item again** — React Aria inherited exactly the same shape
  (`useToggleButtonGroupItem` hardcodes the radio role on `selectionMode === "single"` without
  ever reading `disallowEmptySelection`). ARIA has no press-again-to-deselect for a radio: a
  radiogroup that has a selection always has exactly one checked item, so the state a user reaches
  by pressing the checked chip once more — a radiogroup with nothing checked — is not expressible,
  and a screen reader reads an empty radio group immediately after the user activated one of its
  radios. This library's own catalog said as much in two places (`Radio.optionType` and
  `Segmented`, both of which justify themselves by calling ToggleGroup "a row of aria-pressed
  buttons" that "permits 'none chosen'"), so the two entries contradicted the ToggleGroup entry
  about the same case.

  So here the emptiness rule DECIDES the role. Default (`false`, today's behaviour — no existing
  group changes): `role="group"` + `aria-pressed` per item, and no `aria-orientation`, which
  `group` does not take. With the prop: `role="radiogroup"` + `role="radio"` / `aria-checked`,
  the checked item is the single tab stop, and the arrow keys move the SELECTION as APG's
  radio-group pattern requires rather than only the focus. `type="multiple"` is untouched
  (`role="toolbar"`, `aria-pressed`); the prop there only keeps the last item selected.
  Divergence from Radix's markup is the point of the change, not a side effect of it.

**A knob that only a fork could reach is not parity either.** antd's `components`,
`filterDropdown`, `classNames`/`styles` semantic maps and `prefixCls` all exist to let a consumer
replace the rendered markup. This library answers that layer with tokens (cardinal rule #45), so
those are deliberately NOT adopted — adopting them would re-open the hole the token tiers close.

## One `Dialog`, not two families — and the rule a consumer picks by

**Status:** accepted · 2026-09-12 · gh#567

### The measurement that forced it

On `dist/components/feedback/dialog.d.ts` at `23.2.0`, the feedback group published **26** things:
**14** `Dialog*`, **12** `AlertDialog*`, **12 name-pairs**, **0** parts that only `AlertDialog`
had, and 2 that only `Dialog` had (`DialogBody`, `DialogClose`). `AlertDialog` carried no part of
its own; it was a full mirror minus two.

The real difference was two internal props on one shared shell — `role` and `isDismissable` — and
`AlertDialogRoot` is literally that shell with `role="alertdialog"` and `isDismissable={false}`.
Twelve exports were the price of publishing one ARIA attribute.

**A sweep of the whole public surface says this was the only place it happened.** Scanning the 270
callable exports in `component-api-manifest.json` for a prefixed mirror of another export
(`Alert*`, `Compact*`, `Simple*`, `Mini*`, `Basic*`, `Confirm*`, `Destructive*`) returns **11
pairs, all 11 of them this one family**. There is no second drifted pair to find.

### Why antd wins here

antd has **one** `Modal`. Danger is a prop — `okType="danger"`, `okButtonProps`, with
`Modal.confirm()` as the flat preset alongside `info/success/error/warning`. antd has no
`AlertModal`. The split came from Radix, which ships `@radix-ui/react-dialog` and
`@radix-ui/react-alert-dialog` as two packages — and this library no longer runs on Radix.
react-aria-components has a single `Dialog` whose `role` is an ordinary prop, so the shape that
made two families necessary is gone from the implementation as well as from the authority.

### The decision

`Dialog` takes **`variant`** (`"default" | "destructive"`), on the root or on `DialogContent`, and
that one prop decides three things that always travel together in practice:

| `variant`             | ARIA role     | outside click | primary action                                |
| --------------------- | ------------- | ------------- | --------------------------------------------- |
| `"default"` (default) | `dialog`      | dismisses     | default emphasis                              |
| `"destructive"`       | `alertdialog` | ignored       | destructive emphasis (antd `okType="danger"`) |

The 12 `AlertDialog*` exports **stay and keep working unchanged**. Deleting them is a breaking
change and belongs in a major; nothing here retints or re-roles an existing tree, because the new
prop defaults to `"default"` and no existing `AlertDialog*` call site passes it.

### The rule for choosing — answer these in order

1. **Is it dangerous or irreversible?** No → plain `Dialog`, no `variant`. Yes → keep going.
2. **Does the flat preset cover it?** Title, description, confirm/cancel labels, an optional typed
   challenge, an optional step-up re-auth, a pending state, and nothing else in the body → use the
   `AlertDialog` **preset**. It is one element and it already forces the destructive tone.
3. **Does the body need anything else** — a required reason field, a summary table, a diff, a
   nested list? → `Dialog` with `variant="destructive"`. You get `role="alertdialog"` and the
   non-dismissable scrim, plus `DialogBody` and `DialogClose`, which the `AlertDialog*` family
   never had.
4. **Are you writing new code with the `AlertDialog*` compound parts?** Don't. They are the legacy
   spelling of step 3. Existing code stays valid.

That third row is the reported case: a destructive confirmation with a **required free-text reason**
that still has to be announced as an alert dialog. Before this, `Dialog` gave the form and lost the
role, `AlertDialog` gave the role and forced the footer to be rebuilt, and either way it was a
rewrite rather than a prop.

### Four places this deliberately departs from the issue's proposal

1. **The prop is `variant`, not `severity`.** antd has no name for a one-knob-three-effects switch,
   so there is nothing to port verbatim. But the axis already exists here as `ConfirmVariantProp`
   and the `AlertDialog` preset already spells it `variant` — and "this library's controlled
   vocabulary wins on values" is the rule two sections up. `check:prop-vocabulary` maps a field
   named `variant` to a `*VariantProp` type for exactly this reason.
2. **Escape still closes a `destructive` dialog.** The issue wanted the prop to govern Escape too.
   It does not, because Escape-closes-an-alertdialog is the behaviour the 12 existing exports have
   today, with a test measuring it (`dialog-alert-primitives.test.tsx` · "closes on Escape and
   restores focus"). Making the new prop disable Escape would make `variant="destructive"` differ
   from `AlertDialogContent`, which is the one thing it must not do. antd agrees: `keyboard`
   defaults to `true`. The prop governs the outside click; `maskClosable` is antd's name for that
   same switch.
3. **The corner ✕ defaults off under `destructive`.** It is a fourth effect, and it follows from
   the second: a ✕ is an accidental-dismiss affordance like the scrim. It keeps
   `DialogContent variant="destructive"` byte-identical to `AlertDialogContent`, and
   `showCloseButton` still overrides it either way.
4. **The header band tone is NOT forced.** `DialogHeader tone` is a separate published axis with
   seven values; the preset is where a destructive tone is imposed on both the band and the button
   at once. Forcing it from `variant` would put two props on the same pixel.

### And the `Sheet` question in the same issue

Asked, measured, no change. antd keeps `Drawer` and `Modal` as two components and splits them by
**where the surface comes from**, not by danger — which is exactly the `Sheet` / `Dialog` boundary
here. That pair never drifted from the authority, so there is nothing to merge.

## Derived colour is AUTHORED, and MEASUREMENT is what makes it authoritative

**Twenty values, in `src/tokens/derived.css`:** `--primary-hover`, `--primary-active`,
`--primary-border`, `--destructive-hover`, `--destructive-active`, `--control-outline` and its
alpha, `--control-outline-error` and its alpha, and `--ring` — each in both themes. They are the
interaction states that hang off the five authored seeds per theme (SmartHR MAIN plus four 和色,
and the lifted dark ramp), which stay in `src/tokens/foundation.css`.

**These twenty were once generated.** A colour algorithm ran at build time, took the seeds and
emitted the derived map; the authority behind each value was "the algorithm said so". That
generator has been removed and its dependency with it. The values did not change — but the reason
to trust them had to.

**The authority is now the measurement, not the derivation.** Four suites read `derived.css`
directly and hold every value in it to a threshold this repo has already committed to:

| suite                                                    | what it holds                                                                                                                                                          |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/tokens/__tests__/focus-ring-contrast.test.ts`       | the focus mark in both switch positions — ≥3:1 (WCAG 2.2 SC 1.4.11) on every surface a control sits on, and the halo proven to be decoration rather than the indicator |
| `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it                                                                                               |
| `src/tokens/__tests__/destructive-contrast.test.ts`      | `--destructive-hover` / `--destructive-active` against the same bar                                                                                                    |
| `src/lib/__tests__/theme-tokens-css.test.ts`             | the tier is actually loaded, and complete in both themes                                                                                                               |

The first three also pin each value as a literal, so an edit to `derived.css` alone turns CI red
rather than quietly retinting the library. **That is a stronger claim than the generator made, not
a weaker one.** An algorithm guarantees a value is _consistent_; this repo had to override it four
times (below) precisely because consistent is not the same as _accessible_. A threshold guarantees
the property actually being sold.

**To change a derived value:** change it, run the test file that owns the threshold (never the full
suite — that is CI's job on the PR), and if a threshold breaks the value is
wrong. Do not relax the threshold.

**The primary interaction ramp, and the one override left in it.** The light states are TAKEN from
the identity v2.3 kit rather than computed — the kit publishes its own violet ramp — and each is
snapped to the seed's hue (1.008:1 / 1.010:1 from the kit value, i.e. the same colour). Dark takes
the kit's `action.primary.hover` and then reflects the pressed step, because the dark seed sits at
86.9% L and the conventional step runs out of LIGHTNESS rather than out of contrast:

| token                    | conventional step            | shipped                         |
| ------------------------ | ---------------------------- | ------------------------------- |
| light `--primary-hover`  | kit #6400D4                  | #6500d4 · 8.23:1                |
| light `--primary-active` | kit #5200B0                  | #5400b0 · 10.31:1               |
| dark `--primary-hover`   | kit #E8DAFF                  | #ecdaff · 13.51:1               |
| dark `--primary-active`  | #fbf7ff · 1.056:1 from white | #cd9fff · 8.41:1 (sign flipped) |

**The ratio is blind to hue, and that cost a release (gh#648).** Identity v2.3 moved the seed 63
degrees and this tier stayed on the blue it was derived from: 24.0.0 and 24.1.0 shipped a primary
button that was violet at rest and navy on hover, with all four contrast suites green throughout,
because #005596 clears 7.53:1 on the primary label whatever the seed underneath it is. A threshold
is still the right authority — it just answers one axis of two.
`src/tokens/__tests__/derived-hue-lock.test.ts` answers the other, and asserts nothing else.

**What was deliberately never derived, and why — each a measurement, not a preference:**

- **The neutral spine.** Colour foundation stays SmartHR's (see the table above); the neutrals are
  not derived from the brand seed. The one derived role on offer for a control boundary measures
  **1.43:1** on the page, where `--input` is held to 3:1 by SC 1.4.11 and by
  `input-boundary-contrast.test.ts`.
- **The text ramp.** The derived one is alpha-based (`rgba(0,0,0,0.88)`), which cannot enter this
  library's opaque `H S% L%` triple without choosing a surface to composite against — lossy by
  construction.
- **The dark `--primary` itself.** A mechanical dark derivation MOVES the seed. Deriving from this
  library's light seed gives `#0363a4` at **2.81:1** on the dark spine, which
  `primary-text-contrast.test.ts` rejects outright; deriving from the committed dark seed gives
  `#3794d3` at 5.36:1, where the seed itself measures 7.07:1. The dark theme therefore keeps its
  own seed — recorded, with the measurement, in `focus-ring-contrast.test.ts`.

**The focus geometry was already on the named scale.** Border 1, halo width 2, heavy outline 3,
radius 6, control height 32 and font size 14 all match scales this library already ships
(`--stroke-*`, `--radius`, `--band-height-md`, `--font-size-base`), so the focus tokens bind to
those steps instead of restating a second copy of the number.

**antd itself is gone from this repository**, and `pnpm check:no-antd-runtime` is what keeps it
gone: it fails if antd or any `@ant-design/*` package appears in any manifest field, is imported
anywhere in `src/`, or leaves a trace in `dist/`. Deleting that gate re-opens the door it closes.

## Focus appearance — a two-form convention, and the indicator SHIPS OFF

### The two forms

**SmartHR** (`smarthr-ui@99.6.0`) draws focus as an opaque ring held off the control by a white
spacer. This library takes a different, two-form convention instead — widely used in enterprise
component libraries, and surveyed across several before it was adopted here:

- **Field** — the boundary RECOLOURS to the primary at its unchanged hairline width, plus a
  `--control-outline-width` halo in the `--control-outline` colour. A Select emits the same
  declarations as an Input, which is why the two focus identically.
- **Non-field** — an outline outside the box model, `--focus-outline-weight` thick in the
  `--primary-border` colour, `outline-offset: 1px`.

Since 26.0.0 (gh#678) `--control-outline` and `--primary-border` are `initial` knobs whose default
derives from `--primary` at the painting element, so a bare `var(--control-outline)` paints
**nothing**. Read them through their fallback — see `docs/CUSTOMER-THEMING.md` §Multi-tenant.

**Why this shape wins.** The library previously shipped an opaque brand ring drawn immediately
outside an untouched grey border — two outlines of different colours claiming the same edge — and a
Select that could not be told to agree with an Input. Recolouring the existing boundary rather than
adding a second one resolves both. SmartHR still owns the hue: the focus colour is `--ring`, which
the derived tier declares as `var(--primary)`, because a focused field takes the primary rather than
a focus colour of its own.

### The indicator ships OFF. That forfeits WCAG 2.4.7 and a JIS X 8341-3 AA claim.

`--focus-outline: 0` in `foundation.css`. Nothing paints a focus indicator by default.

**This is a product decision, made with the cost stated.** The indicator was reported as intrusive —
a thick blue outline appearing on shift-tab, stacked on an already-shaded selected nav row. The
owner chose to ship it off and let whoever needs it turn it on. What that costs, plainly:

- **WCAG 2.2 SC 2.4.7 Focus Visible is an AA criterion**, and with the switch off it is not met.
- **JIS X 8341-3:2016 tracks WCAG 2.0 AA**, so a Japanese enterprise procurement asking for that
  conformance statement cannot be answered while the switch is off. Japanese public-sector and
  large-enterprise buyers ask for it routinely, and デジタル庁 is this document's standards
  reference for exactly that reason.

**Turning it back on is one attribute, on the root element, with no code change:**

```html
<html data-focus-outline="on"></html>
```

**The switch is a single multiplier, not a scatter of overrides.** `--focus-outline` is one flag,
and every painted focus length multiplies by it:

```css
--focus-ring-width: calc(var(--focus-ring-weight) * var(--focus-outline));
```

Setting it to `0` zeroes every focus length at once, so no component rebind can bring the mark back
while it is off — which is the property a scatter of per-component overrides could never give. The two paints
that are not lengths — the halo and a field's recoloured boundary — are scoped to the same attribute
in `styles/focus-ring.css`. **Every `:focus-visible` selector stays exactly where it is**; only the
painted result disappears.

### The ON state is the LIGHT one

The complaint was weight, not existence, so the on-position is not the old mark restored. It paints
the **field** indicator on every control — one hairline (1px) in the focus hue, plus the
`--control-outline` halo — rather than the heavy 3px outline form. Measured, in Chromium, on
`ql.test` after the transition settles:

| control                    | switch off                                           | switch on                                                       |
| -------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
| Input / Select trigger     | border `1px rgb(144,135,127)`, resting shadow intact | border `1px` of `--ring` + `--control-outline` halo `0 0 0 2px` |
| Button (primary)           | outline `0px`, resting shadow intact                 | `outline: 1px solid` `--ring` @ `0px` + same halo               |
| Sidebar nav row / list row | outline `0px`                                        | `outline: 1px solid` `--ring` @ **`-1px`** (inset into the row) |

The GEOMETRY above is the Chromium measurement; the COLOUR is named by token rather than pinned as
a hex, because it follows the seed and the hexes this table used to carry were the pre-v2.3 blue —
the same staleness gh#648 found one tier down. Today `--ring` resolves to rgb(122,0,255) light /
rgb(220,188,255) dark and the halo to `rgba(109,0,228,0.11)` / `rgba(153,61,254,0.29)`.

The field pair is the recoloured boundary plus the halo, exactly as the convention specifies for
this seed. The nav row insets its mark into its own shape rather than wrapping an already-shaded surface,
which is the specific stacking that read as heavy.

**No control's box moves when it is focused**, measured with `getBoundingClientRect()` before and
after: the field form only recolours a border and an `outline` is painted outside the box model.
Input, Save and Delete all stay at 32.00px.

**Two criteria, and only one is met by thickness alone.** SC 1.4.11 (AA, non-text contrast) is about
COLOUR — the 1px mark measures at worst 5.30:1 light and 6.87:1 dark across every surface a control
sits on (an accent panel in both themes), so the light weight costs nothing there. `--primary-border`
(#a66de3) reaches 3.49:1 on the page and **2.92:1 light / 1.03:1 dark on that same accent panel**, so
it still could not satisfy the criterion everywhere at any thickness, which is why the on-state takes
the focus hue instead — the single place it departs from the outline form. SC 2.4.13 Focus Appearance
(AAA) additionally wants a 2px perimeter; the on-state does not target it, and
`--focus-outline-weight: var(--stroke-lg)` restores the 3px weight if a customer needs the area
clause.

Both positions of the switch are gated in `src/tokens/__tests__/focus-ring-contrast.test.ts`: with
it off nothing paints and no rebind can route around it, with it on the geometry and the ≥3:1
contrast hold on every surface. Per rule 4 below: a standard that is not enforced is a standard that
has already drifted.

### The indicator is for CONTROLS, not containers

Buttons, fields, links, nav and menu rows carry it. A div, a content region, a scroll wrapper and
`body` do not. `.ui-legal-document-section` was removed from the list: it is a slab of prose that
carries `tabIndex={-1}` only so a table-of-contents link can move focus into it, so it is never in
the tab order and a keyboard user cannot arrive there unannounced. Its `tabIndex={-1}` stays,
because removing it would break the contents anchor.

**Every other focusable non-interactive element in this library is a genuinely scrollable region**
— `.app-main`, `.ui-mobile-shell-main`, `.ui-timeline-grid`, `.ui-code-block` when it overflows,
MasterDetail's bounded master, the DataTable and Table scroll containers when they overflow,
ScrollArea, and the BranchScopePicker list. Each carries `tabIndex={0}` for axe
`scrollable-region-focusable` and must stay reachable, so each keeps BOTH halves rather than
becoming focusable-but-unpainted. The region ring (`--region-focus-ring-width`, off by default) now
also multiplies by `--focus-outline`, so a service that opts it in still cannot paint while the
library-wide switch is off.

### A focusable scroll region is ROLED and NAMED, and only exists while it scrolls (gh#817)

Reaching the overflow is half the contract; knowing what you reached is the other half. A stop with
no role and no accessible name announces nothing at all, which is why `godx-jp/id` found `Table`'s
wrapper sitting at tab stop 27 as a bare `<div tabindex="0">`. So the Table and DataTable scroll
containers carry `role="group"` plus a name that defaults to the localized `dataTable.scrollRegion`
— a consumer is never forced to invent one per table, and `label` overrides it per instance.

`group`, not `region`: a NAMED `region` is a landmark, so a page with three tables would ship three
identically-named landmarks and fail axe `landmark-unique`. That collision is the reason the role
was left off in the first place; `group` is announced, takes a name, and is not a landmark.

And all three attributes are withheld until the box provably overflows (`useScrollsHorizontally`,
`src/lib/hooks.ts`) — a focus stop that scrolls nothing is noise. The measurement may only ever
REMOVE the stop: an unlaid-out box reports `clientWidth === 0` (SSR, jsdom, a `display:none`
ancestor), and reading that as "nothing overflows" would strand the overflow from every keyboard
user, a worse failure than the extra stop. Gated by
`src/components/data-display/__tests__/table-scroll-region-a11y-817.test.tsx`.

## How a decision gets made from here

1. Look it up in Carbon's guidance for the layer in question.
2. If Carbon has an answer, take it, and cite it in the token or component comment.
3. If Carbon has no answer, decide — and add the reason here, so it is looked up next time
   instead of re-decided.
4. Encode the decision as a gate wherever a gate can hold it. A standard that is not enforced is a
   standard that has already drifted; `visual-audit-rules.test.ts` and the scale test above are the
   pattern to follow.
