@elabs-ai/components-ui 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { Table, TableBody, TableCell, TableRow } from "./table";
4
+
4
5
  describe("Table", () => {
5
6
  it("renders cells", () => {
6
7
  render(
@@ -15,3 +16,143 @@ describe("Table", () => {
15
16
  expect(screen.getByText("Alpha")).toBeInTheDocument();
16
17
  });
17
18
  });
19
+
20
+ /**
21
+ * jsdom reports 0 for every layout metric, so overflow has to be simulated —
22
+ * same idiom as `packages/data/src/data-table/data-table.test.tsx`. Metrics
23
+ * are (re-)applied and the component's own `onScroll` handler is fired so the
24
+ * re-measurement runs through the real code path, not by poking at state.
25
+ */
26
+ function setScrollMetrics(
27
+ el: HTMLElement,
28
+ { scrollWidth, clientWidth, scrollHeight, clientHeight }: Record<string, number>,
29
+ ) {
30
+ Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
31
+ Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
32
+ Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
33
+ Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
34
+ fireEvent.scroll(el);
35
+ }
36
+
37
+ function scrollRegionOf(container: HTMLElement): HTMLElement {
38
+ const el = container.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
39
+ if (!el) throw new Error('no [data-slot="table-scroll-region"] in the rendered output');
40
+ return el;
41
+ }
42
+
43
+ describe("Table — #366 the scroll wrapper's tab stop exists only while it measurably overflows", () => {
44
+ it("adds NO tab stop and NO accessible name to a table that fits its container", () => {
45
+ const { container } = render(
46
+ <Table>
47
+ <TableBody>
48
+ <TableRow>
49
+ <TableCell>Alpha</TableCell>
50
+ </TableRow>
51
+ </TableBody>
52
+ </Table>,
53
+ );
54
+ const scrollRegion = scrollRegionOf(container);
55
+ setScrollMetrics(scrollRegion, {
56
+ scrollWidth: 300,
57
+ clientWidth: 300,
58
+ scrollHeight: 100,
59
+ clientHeight: 100,
60
+ });
61
+ // A table that doesn't scroll must not gain a focus stop that does
62
+ // nothing, nor announce itself as "scrollable" — axe's
63
+ // `scrollable-region-focusable` only fires the other way round, so this
64
+ // is the locking assertion for it.
65
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
66
+ expect(scrollRegion).not.toHaveAttribute("aria-label");
67
+ expect(screen.queryByLabelText("Table contents, scrollable")).toBeNull();
68
+ });
69
+
70
+ it("gains the tab stop + accessible name once the region measurably overflows vertically", () => {
71
+ const { container } = render(
72
+ <Table>
73
+ <TableBody>
74
+ <TableRow>
75
+ <TableCell>Alpha</TableCell>
76
+ </TableRow>
77
+ </TableBody>
78
+ </Table>,
79
+ );
80
+ const scrollRegion = scrollRegionOf(container);
81
+ setScrollMetrics(scrollRegion, {
82
+ scrollWidth: 300,
83
+ clientWidth: 300,
84
+ scrollHeight: 800,
85
+ clientHeight: 200,
86
+ });
87
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
88
+ expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
89
+ });
90
+
91
+ it("gains the tab stop + accessible name once the region measurably overflows horizontally", () => {
92
+ const { container } = render(
93
+ <Table>
94
+ <TableBody>
95
+ <TableRow>
96
+ <TableCell>Alpha</TableCell>
97
+ </TableRow>
98
+ </TableBody>
99
+ </Table>,
100
+ );
101
+ const scrollRegion = scrollRegionOf(container);
102
+ setScrollMetrics(scrollRegion, {
103
+ scrollWidth: 900,
104
+ clientWidth: 300,
105
+ scrollHeight: 100,
106
+ clientHeight: 100,
107
+ });
108
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
109
+ expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
110
+ });
111
+
112
+ it("drops the tab stop again when the overflow goes away (e.g. the container grows)", () => {
113
+ const { container } = render(
114
+ <Table>
115
+ <TableBody>
116
+ <TableRow>
117
+ <TableCell>Alpha</TableCell>
118
+ </TableRow>
119
+ </TableBody>
120
+ </Table>,
121
+ );
122
+ const scrollRegion = scrollRegionOf(container);
123
+ setScrollMetrics(scrollRegion, {
124
+ scrollWidth: 300,
125
+ clientWidth: 300,
126
+ scrollHeight: 800,
127
+ clientHeight: 200,
128
+ });
129
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
130
+ setScrollMetrics(scrollRegion, {
131
+ scrollWidth: 300,
132
+ clientWidth: 300,
133
+ scrollHeight: 800,
134
+ clientHeight: 900,
135
+ });
136
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
137
+ });
138
+
139
+ it('never adds a redundant role="region" landmark', () => {
140
+ const { container } = render(
141
+ <Table>
142
+ <TableBody>
143
+ <TableRow>
144
+ <TableCell>Alpha</TableCell>
145
+ </TableRow>
146
+ </TableBody>
147
+ </Table>,
148
+ );
149
+ const scrollRegion = scrollRegionOf(container);
150
+ setScrollMetrics(scrollRegion, {
151
+ scrollWidth: 300,
152
+ clientWidth: 300,
153
+ scrollHeight: 800,
154
+ clientHeight: 200,
155
+ });
156
+ expect(scrollRegion).not.toHaveAttribute("role");
157
+ });
158
+ });
@@ -1,17 +1,76 @@
1
1
  import {
2
2
  forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
3
7
  type HTMLAttributes,
4
8
  type TdHTMLAttributes,
5
9
  type ThHTMLAttributes,
6
10
  } from "react";
7
11
  import { cn } from "../../lib/cn";
12
+ import { useLocale } from "../locale-provider";
8
13
 
14
+ /**
15
+ * `Table`'s own scroll wrapper, gated on MEASURED overflow (#366).
16
+ *
17
+ * `className`/`ref`/`...props` all land on the `<table>` — unchanged, no new
18
+ * prop — so this wrapper is internal plumbing, not a new public surface. It
19
+ * follows `DataTable`'s non-virtualized branch verbatim
20
+ * (`packages/data/src/data-table/data-table.tsx:1658-1695`, #330), the
21
+ * closest precedent: a plain `overflow-auto` box with no pinned columns, no
22
+ * loading overlay and no edge fade to carry along, just the keyboard
23
+ * affordance itself.
24
+ *
25
+ * A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
26
+ * `aria-label`, no ring — an unconditional stop would announce "scrollable"
27
+ * on content that never scrolls. Only a table that measurably overflows
28
+ * gets the tab stop, its accessible name (WCAG 4.1.2) and the compound focus
29
+ * ring (`focus-ring-inset` — the wrapper's own edge is the scroll clip, so an
30
+ * outside ring would be cut, `.claude/rules/theming.md`). No `role="region"`:
31
+ * both existing precedents (`DataTable`, `SheetTable`) decided against a
32
+ * redundant landmark over a real `<table>`, and this wrapper adds none either.
33
+ */
9
34
  export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
10
35
  { className, ...props },
11
36
  ref,
12
37
  ) {
38
+ const { t } = useLocale();
39
+ const scrollRef = useRef<HTMLDivElement>(null);
40
+ const [scrollOverflows, setScrollOverflows] = useState(false);
41
+
42
+ const updateScrollAffordance = useCallback(() => {
43
+ const el = scrollRef.current;
44
+ if (!el) return;
45
+ // 1px tolerance absorbs sub-pixel layout rounding, which would otherwise
46
+ // report a permanent 0.5px overflow on a table that visually fits.
47
+ setScrollOverflows(
48
+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
49
+ );
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ const el = scrollRef.current;
54
+ if (!el) return;
55
+ updateScrollAffordance();
56
+ if (typeof ResizeObserver === "undefined") return;
57
+ // Observe the CONTAINER (viewport changes) and the <table> inside it
58
+ // (content changes its intrinsic size without resizing the container).
59
+ const observer = new ResizeObserver(updateScrollAffordance);
60
+ observer.observe(el);
61
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
62
+ return () => observer.disconnect();
63
+ }, [updateScrollAffordance]);
64
+
13
65
  return (
14
- <div className="relative w-full overflow-auto">
66
+ <div
67
+ ref={scrollRef}
68
+ data-slot="table-scroll-region"
69
+ tabIndex={scrollOverflows ? 0 : undefined}
70
+ aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
71
+ onScroll={updateScrollAffordance}
72
+ className="relative w-full overflow-auto focus-ring-inset"
73
+ >
15
74
  <table ref={ref} className={cn("w-full caption-bottom text-body", className)} {...props} />
16
75
  </div>
17
76
  );
@@ -142,7 +142,13 @@ export const TabsTrigger = forwardRef<
142
142
  ref={mergedRef}
143
143
  className={cn(
144
144
  "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors duration-fast",
145
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
145
+ // `ring-offset-1`, not `-2`: the compound indicator's reach is
146
+ // `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
147
+ // `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
148
+ // and the scroll box clips the contour on three sides (#67 fix round 2,
149
+ // measured on `patterns-templates-object-detail-hub--default` tab stop
150
+ // 9); at offset 1 it is exactly 4px and the loop closes.
151
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
146
152
  "disabled:pointer-events-none disabled:opacity-50",
147
153
  "data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
148
154
  className,
@@ -160,7 +166,7 @@ export const TabsContent = forwardRef<
160
166
  <TabsPrimitive.Content
161
167
  ref={ref}
162
168
  className={cn(
163
- "mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
169
+ "mt-2 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
164
170
  "data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:[--tw-ease:var(--ease-entrance)]",
165
171
  className,
166
172
  )}
@@ -208,7 +208,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
208
208
  }}
209
209
  className={cn(
210
210
  "rounded-full p-0.5 transition-colors",
211
- "hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
211
+ "hover:bg-secondary-foreground/20 focus-ring",
212
212
  "disabled:pointer-events-none disabled:opacity-50",
213
213
  )}
214
214
  >
@@ -240,7 +240,8 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
240
240
  />
241
241
  </InputGroup>
242
242
  {error && (
243
- <p role="alert" className="text-xs text-destructive">
243
+ // #124: running text (the validation message) — ink rung.
244
+ <p role="alert" className="text-xs text-destructive-text">
244
245
  {error}
245
246
  </p>
246
247
  )}
@@ -12,6 +12,7 @@ import {
12
12
  DropdownMenuTrigger,
13
13
  } from "../dropdown-menu";
14
14
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "../sidebar";
15
+ import { useLocale } from "../locale-provider";
15
16
  import { cn } from "../../lib/cn";
16
17
 
17
18
  export type TeamSwitcherTeam = {
@@ -37,6 +38,7 @@ export interface TeamSwitcherProps {
37
38
  */
38
39
  export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherProps) {
39
40
  const { isMobile } = useSidebar();
41
+ const { t } = useLocale();
40
42
  const [activeTeam, setActiveTeam] = React.useState(teams[0]);
41
43
  if (!activeTeam) return null;
42
44
  const Logo = activeTeam.logo;
@@ -48,21 +50,44 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
48
50
  <DropdownMenuTrigger asChild>
49
51
  <SidebarMenuButton
50
52
  size="lg"
53
+ // The name is written out rather than left to the button's own
54
+ // contents, because the contents cannot carry it in every state:
55
+ // collapsed to the 48px rail the trigger is one glyph, and the
56
+ // label beside it is either gone or clipped out of view by the
57
+ // button's `overflow-hidden` — either way axe reads a nameless
58
+ // button (`button-name`, critical). The string is exactly the
59
+ // visible label, in reading order, so WCAG 2.5.3 (label in name)
60
+ // holds expanded, where that label IS on screen. Verified by
61
+ // deleting this line and watching three dashboard stories fail
62
+ // `button-name` (critical).
63
+ aria-label={`${activeTeam.name} ${activeTeam.plan}`}
51
64
  className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
52
65
  >
66
+ {/* Collapsed, this row is ONE ICON in a 48px rail and has to sit
67
+ on the same centre line as every nav entry above and below it.
68
+ Measured before this fix: the 32px plate did not fit the
69
+ button's 16px content box, so it overflowed (clipped by the
70
+ button's own `overflow-hidden`) and its glyph landed 8px to the
71
+ trailing side of every other icon in the rail. So the plate
72
+ becomes the glyph's own 16px box — no fill, no radius, nothing
73
+ to overflow — and the label and chevron stand down: at 48px
74
+ there is no room to read them, and they are what crowds the
75
+ plate. Expanded, all three come back unchanged. */}
53
76
  <div
54
77
  className={cn(
55
- "flex aspect-square size-8 items-center justify-center rounded-lg",
78
+ "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg",
56
79
  logoClassName ?? "bg-sidebar-accent text-sidebar-accent-foreground",
80
+ "group-data-[collapsible=icon]:size-4 group-data-[collapsible=icon]:rounded-none group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:text-sidebar-foreground",
57
81
  )}
58
82
  >
59
83
  <Logo className="size-4" />
60
84
  </div>
61
- <div className="grid flex-1 text-start text-sm leading-tight">
85
+ <div className="grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden">
62
86
  <span className="truncate font-semibold">{activeTeam.name}</span>
63
87
  <span className="truncate text-xs">{activeTeam.plan}</span>
64
88
  </div>
65
- <ChevronsUpDown className="ms-auto" />
89
+ {/* Decorative, so it simply goes. */}
90
+ <ChevronsUpDown className="ms-auto group-data-[collapsible=icon]:hidden" />
66
91
  </SidebarMenuButton>
67
92
  </DropdownMenuTrigger>
68
93
  <DropdownMenuContent
@@ -71,7 +96,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
71
96
  side={isMobile ? "bottom" : "right"}
72
97
  sideOffset={4}
73
98
  >
74
- <DropdownMenuLabel className="text-xs text-muted-foreground">Teams</DropdownMenuLabel>
99
+ <DropdownMenuLabel className="text-xs text-muted-foreground">
100
+ {t("ui.teamSwitcher.label")}
101
+ </DropdownMenuLabel>
75
102
  {teams.map((team, index) => (
76
103
  <DropdownMenuItem
77
104
  key={team.name}
@@ -90,7 +117,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
90
117
  <div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
91
118
  <Plus className="size-4" />
92
119
  </div>
93
- <div className="font-medium text-muted-foreground">Add team</div>
120
+ <div className="font-medium text-muted-foreground">
121
+ {t("ui.teamSwitcher.addTeam")}
122
+ </div>
94
123
  </DropdownMenuItem>
95
124
  </DropdownMenuContent>
96
125
  </DropdownMenu>
@@ -13,7 +13,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
13
13
  className={cn(
14
14
  "flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
15
15
  "placeholder:text-muted-foreground",
16
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
16
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
17
17
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
18
18
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
19
19
  className,
@@ -12,6 +12,7 @@ import {
12
12
  DropdownMenuTrigger,
13
13
  } from "../dropdown-menu";
14
14
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
15
+ import { useLocale } from "../locale-provider";
15
16
  import { useThemeTransition, type ThemeTransitionEffect } from "./use-theme-transition";
16
17
 
17
18
  const SYSTEM_STORAGE_KEY = "brand-ui-theme-system";
@@ -107,6 +108,7 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
107
108
  ref,
108
109
  ) {
109
110
  const { theme, themeDefinitions, setTheme } = useTheme();
111
+ const { t } = useLocale();
110
112
  const switchTheme = useThemeTransition(effect);
111
113
  const isControlled = preference !== undefined;
112
114
  const [uncontrolledIsSystem, setUncontrolledIsSystem] = useState(false);
@@ -215,7 +217,7 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
215
217
  {showSystem ? (
216
218
  <DropdownMenuItem onSelect={() => pickSystem()}>
217
219
  <Monitor />
218
- <span>System</span>
220
+ <span>{t("ui.themeSwitcher.system")}</span>
219
221
  {isSystem ? <Check className="ml-auto" /> : null}
220
222
  </DropdownMenuItem>
221
223
  ) : null}
@@ -254,7 +256,9 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
254
256
  // `role` is optional only because `order[i]` is an indexed read under
255
257
  // `noUncheckedIndexedAccess`; the modulo above makes it always present.
256
258
  const roleLabel = (role: "light" | "dark" | "system" | undefined) =>
257
- role === "system" ? "System" : labelOf(role === "dark" ? darkTheme : lightTheme);
259
+ role === "system"
260
+ ? t("ui.themeSwitcher.system")
261
+ : labelOf(role === "dark" ? darkTheme : lightTheme);
258
262
 
259
263
  return (
260
264
  <TooltipProvider>
@@ -15,8 +15,8 @@ type ViewTransitionDocument = Document & {
15
15
  * Transitions API (the chanhdai.com "theme toggle effect", adapted to brand-ui).
16
16
  *
17
17
  * - Direction-aware: sets `data-vt="to-dark|to-light"` on `<html>` from the
18
- * target theme's `dark` flag, so the clip-path wipes the right way for ANY of
19
- * the three themes (not just light/dark).
18
+ * target theme's `dark` flag, so the clip-path wipes the right way for ANY
19
+ * theme (not just light/dark).
20
20
  * - `data-vt-effect` selects the reveal shape (default "polygon").
21
21
  * - Motion-aware: under `useReducedMotion()` (OS or in-app preference) — or when
22
22
  * the browser lacks `startViewTransition` — it falls back to an instant,
@@ -11,6 +11,9 @@ const meta = {
11
11
  docs: {
12
12
  description: {
13
13
  component:
14
+ "The GENERIC ordered-steps rail; an agent execution trace is `AI/AgentTimeline` " +
15
+ "and git history is `Data/RevisionTimeline` — see " +
16
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
14
17
  "Ordered steps with a vertical connector and status-colored nodes — THE shared " +
15
18
  "rail spine (#190). Two front doors over one rail: the array `items` API (the " +
16
19
  "editor's `:::timeline` target, `done|active|pending`) and the compound " +
@@ -78,6 +81,30 @@ export const Compound: Story = {
78
81
  },
79
82
  };
80
83
 
84
+ export const TabularTimestamps: Story = {
85
+ // #386 — a right-aligned timestamp column of differing digit widths
86
+ // ("09:14" vs "11:47" vs "9:02") must not jitter: the span needs
87
+ // `tabular-nums` so every digit occupies equal width.
88
+ args: { items: [] },
89
+ render: () => (
90
+ <TimelineRoot aria-label="Run with numeric timestamps">
91
+ <TimelineItem status="complete" timestamp="09:14">
92
+ Searched financial filings
93
+ </TimelineItem>
94
+ <TimelineItem status="complete" timestamp="11:47">
95
+ Queried finance.revenue
96
+ </TimelineItem>
97
+ <TimelineItem status="pending" timestamp="9:02">
98
+ Draft the board note
99
+ </TimelineItem>
100
+ </TimelineRoot>
101
+ ),
102
+ play: async ({ canvas }) => {
103
+ const timestamp = await waitFor(() => canvas.getByText("09:14"));
104
+ await expect(getComputedStyle(timestamp).fontVariantNumeric).toContain("tabular-nums");
105
+ },
106
+ };
107
+
81
108
  export const CanonicalStatuses: Story = {
82
109
  args: { items: [] },
83
110
  render: () => (
@@ -17,6 +17,16 @@
17
17
  *
18
18
  * Fork-prevention: a Timeline re-implementation outside this folder fails
19
19
  * `pnpm timeline-fork:check` (scripts/check-timeline-fork.mjs).
20
+ *
21
+ * Deliberately NOT on this rail (a different grammar, not an oversight —
22
+ * RM-014, #133): `RevisionTimeline` (@elabs-ai/components-ui). Its rail is a
23
+ * multi-lane commit DAG whose node colour is LANE IDENTITY (the chart ramp),
24
+ * not `Status`, and whose fork/merge joins are cross-row beziers living in one
25
+ * SVG coordinate space spanning the whole list — neither of which a
26
+ * status-keyed node or an item-local `w-px` connector can express. Carrying
27
+ * both grammars here would cost ~5 opt-in props, four of them switching this
28
+ * rail's own node/connector/geometry/status announcement off. The four-point
29
+ * reason is in that component's docblock; reopen #133 before converging them.
20
30
  */
21
31
  import { forwardRef, type HTMLAttributes, type LiHTMLAttributes, type ReactNode } from "react";
22
32
  import { cn } from "../../lib/cn";
@@ -141,7 +151,7 @@ export const TimelineItem = forwardRef<HTMLLIElement, TimelineItemProps>(functio
141
151
  {children}
142
152
  </span>
143
153
  {timestamp ? (
144
- <span className="shrink-0 text-meta text-muted-foreground">{timestamp}</span>
154
+ <span className="shrink-0 text-meta text-muted-foreground tabular-nums">{timestamp}</span>
145
155
  ) : null}
146
156
  </div>
147
157
  {description ? <p className="mt-0.5 text-body text-muted-foreground">{description}</p> : null}
@@ -21,7 +21,7 @@ export const toggleVariants = cva(
21
21
  // asChild wrapper overwrites data-state="on" with "closed" — aria-pressed
22
22
  // (Toggle) and aria-checked (ToggleGroupItem) are set by the toggle primitive
23
23
  // alone and survive the merge. (#214)
24
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-fast ease-standard hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground data-[state=on]:font-semibold aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:font-semibold aria-checked:bg-accent aria-checked:text-accent-foreground aria-checked:font-semibold [&_svg]:size-4 [&_svg]:shrink-0",
24
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-fast ease-standard hover:bg-muted hover:text-muted-foreground focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground data-[state=on]:font-semibold aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:font-semibold aria-checked:bg-accent aria-checked:text-accent-foreground aria-checked:font-semibold [&_svg]:size-4 [&_svg]:shrink-0",
25
25
  {
26
26
  variants: {
27
27
  variant: {
@@ -28,10 +28,18 @@ import {
28
28
  * expect there.
29
29
  */
30
30
  const meta = {
31
- title: "Foundation/Toolbar",
31
+ title: "Layout/Toolbar",
32
32
  component: Toolbar,
33
33
  tags: ["autodocs"],
34
- parameters: { layout: "padded" },
34
+ parameters: {
35
+ layout: "padded",
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "The one row that genuinely claims the `toolbar` ARIA role: Radix delegates a roving tabindex to it, so ten controls cost one tab stop instead of ten. The row above a list, table or board — filters left, actions right — is `Layout/ViewToolbar`, which deliberately does NOT claim that role because its controls are ordinary tab stops. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
40
+ },
41
+ },
42
+ },
35
43
  args: { "aria-label": "Formatting" },
36
44
  } satisfies Meta<typeof Toolbar>;
37
45
 
@@ -483,7 +483,7 @@ function RevealSelectedNodeDemo() {
483
483
  key={id}
484
484
  type="button"
485
485
  onClick={() => setTarget(id)}
486
- className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
486
+ className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-ring"
487
487
  >
488
488
  Navigate to Node {Number(id.split("-")[1]) + 1}
489
489
  </button>
@@ -535,7 +535,7 @@ export const RevealSelectedNode: Story = {
535
535
  * (like the Sidebar itself) perceivability comes from the foreground/weight shift
536
536
  * plus `aria-selected`, not fill contrast alone.
537
537
  *
538
- * NOTE: cross-theme visual correctness (the three shipped themes — light,
538
+ * NOTE: cross-theme visual correctness (every shipped theme — light,
539
539
  * dark) requires a real render and is owed to a human reviewer;
540
540
  * the tokens are semantic so they adapt, but pixel-level confirmation cannot be
541
541
  * automated in this environment.
@@ -15,6 +15,7 @@ import { ChevronRight, AlertCircle, RotateCcw } from "lucide-react";
15
15
  import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
16
16
  import { cn } from "../../lib/cn";
17
17
  import { Checkbox } from "../checkbox/checkbox";
18
+ import { useLocale } from "../locale-provider";
18
19
  import { Skeleton } from "../skeleton/skeleton";
19
20
  import { Spinner } from "../spinner/spinner";
20
21
  import { useTreeKeyboard } from "./use-tree-keyboard";
@@ -222,6 +223,7 @@ function VirtualFlatRow<T>({
222
223
  registerNodeRef,
223
224
  setActiveId,
224
225
  }: VirtualFlatRowProps<T>) {
226
+ const { t } = useLocale();
225
227
  const { node, level, setSize, posInSet } = row;
226
228
  const isLoading = lazyState === "loading";
227
229
  const isError = lazyState === "error";
@@ -262,7 +264,7 @@ function VirtualFlatRow<T>({
262
264
  "flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
263
265
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
264
266
  "hover:bg-accent hover:text-accent-foreground",
265
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
267
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
266
268
  isSelected &&
267
269
  selectionMode !== "none" &&
268
270
  (surface === "sidebar"
@@ -319,9 +321,11 @@ function VirtualFlatRow<T>({
319
321
 
320
322
  {/* label */}
321
323
  {isError ? (
322
- <span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive">
324
+ // #124: running text ("Failed to load…"), not a mark — the ink rung
325
+ // clears >=4.5:1 and the wrapped icon inherits it fine (>=3:1 trivially).
326
+ <span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive-text">
323
327
  <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
324
- <span className="truncate">Failed to load</span>
328
+ <span className="truncate">{t("ui.tree.failedToLoad")}</span>
325
329
  <button
326
330
  type="button"
327
331
  aria-label="Retry loading children"
@@ -332,7 +336,7 @@ function VirtualFlatRow<T>({
332
336
  className={cn(
333
337
  "ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-caption",
334
338
  "text-muted-foreground hover:text-foreground",
335
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
339
+ "focus-ring",
336
340
  )}
337
341
  >
338
342
  <RotateCcw className="size-3" aria-hidden="true" />
@@ -392,19 +396,20 @@ function LoadingRow({ level }: { level: number }) {
392
396
  }
393
397
 
394
398
  function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
399
+ const { t } = useLocale();
395
400
  const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
396
401
  return (
397
402
  <div
398
403
  role="treeitem"
399
- aria-label="Failed to load"
404
+ aria-label={t("ui.tree.failedToLoad")}
400
405
  aria-disabled="true"
401
406
  tabIndex={-1}
402
- className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive"
407
+ className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive-text"
403
408
  style={indentStyle}
404
409
  >
405
410
  <span aria-hidden="true" className="size-4 shrink-0" />
406
411
  <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
407
- <span className="truncate">Failed to load</span>
412
+ <span className="truncate">{t("ui.tree.failedToLoad")}</span>
408
413
  <button
409
414
  type="button"
410
415
  onClick={(e) => {
@@ -414,7 +419,7 @@ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
414
419
  className={cn(
415
420
  "ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-xs",
416
421
  "text-muted-foreground hover:text-foreground",
417
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
422
+ "focus-ring",
418
423
  )}
419
424
  >
420
425
  <RotateCcw className="size-3" aria-hidden="true" />
@@ -541,7 +546,7 @@ function TreeItem<T>({
541
546
  "flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
542
547
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
543
548
  "hover:bg-accent hover:text-accent-foreground",
544
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
549
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
545
550
  isSelected &&
546
551
  selectionMode !== "none" &&
547
552
  (surface === "sidebar"
@@ -119,7 +119,7 @@ export const ProseLink = forwardRef<HTMLAnchorElement, ProseLinkProps>(function
119
119
  // #399 — `text-primary-text`, not the `text-primary` FILL rung: a prose
120
120
  // link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
121
121
  // fill only guarantees at the 1.4.11 mark bar (3:1).
122
- "font-medium text-primary-text underline underline-offset-4 hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
122
+ "font-medium text-primary-text underline underline-offset-4 hover:no-underline focus-ring",
123
123
  className,
124
124
  )}
125
125
  {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}