@elabs-ai/components-ui 4.1.0 → 4.2.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 (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. package/dist/chunk-JKPPTL63.js.map +0 -1
@@ -7,6 +7,7 @@ import {
7
7
  BUILT_IN_THEME_DEFINITIONS,
8
8
  defineTheme,
9
9
  ThemeProvider,
10
+ useTheme,
10
11
  } from "@elabs-ai/components-tokens";
11
12
 
12
13
  import { ThemeSwitcher } from "./theme-switcher";
@@ -251,3 +252,184 @@ describe("ThemeSwitcher — consumer themes (ADR 0029)", () => {
251
252
  );
252
253
  });
253
254
  });
255
+
256
+ describe("ThemeSwitcher — theme families (ADR 0036)", () => {
257
+ const oceanLight = defineTheme({
258
+ value: "ocean-light",
259
+ label: "Ocean light",
260
+ dark: false,
261
+ family: "ocean",
262
+ familyLabel: "Ocean",
263
+ });
264
+ const oceanDark = defineTheme({
265
+ value: "ocean-dark",
266
+ label: "Ocean dark",
267
+ dark: true,
268
+ family: "ocean",
269
+ });
270
+ const dusk = defineTheme({
271
+ value: "dusk",
272
+ label: "Dusk",
273
+ dark: true,
274
+ family: "dusk",
275
+ familyLabel: "Dusk",
276
+ });
277
+ const midnight = defineTheme({ value: "midnight", label: "Midnight", dark: true });
278
+ const daylight = defineTheme({ value: "daylight", label: "Daylight", dark: false });
279
+ const REGISTRY = [...BUILT_IN_THEME_DEFINITIONS, oceanLight, oceanDark, dusk];
280
+
281
+ async function openMenu() {
282
+ await userEvent.click(screen.getByRole("button", { name: "Theme" }));
283
+ return screen.findByRole("menu");
284
+ }
285
+
286
+ it("BACKWARD COMPAT: the default provider still renders the cycle toggle", () => {
287
+ setup(<ThemeSwitcher />);
288
+ expect(screen.getByRole("button", { name: /theme:/i })).toBeInTheDocument();
289
+ });
290
+
291
+ it("BACKWARD COMPAT: built-ins plus one undeclared theme stays a flat dropdown", async () => {
292
+ render(
293
+ <ThemeProvider themes={[...BUILT_IN_THEME_DEFINITIONS, midnight]}>
294
+ <ThemeSwitcher />
295
+ </ThemeProvider>,
296
+ );
297
+ const menu = await openMenu();
298
+ expect(within(menu).queryAllByRole("menuitemradio")).toHaveLength(0);
299
+ expect(within(menu).getByText("Midnight")).toBeInTheDocument();
300
+ });
301
+
302
+ it("BACKWARD COMPAT: an undeclared two-theme pair stays a light/dark toggle", () => {
303
+ render(
304
+ <ThemeProvider themes={[daylight, midnight]} defaultTheme="daylight">
305
+ <ThemeSwitcher showSystem={false} />
306
+ </ThemeProvider>,
307
+ );
308
+ expect(screen.getByRole("button", { name: /theme:/i })).toHaveAccessibleName(
309
+ "Theme: Daylight. Activate to switch to Midnight.",
310
+ );
311
+ });
312
+
313
+ it("offers families and a separate mode group once two families are declared", async () => {
314
+ render(
315
+ <ThemeProvider themes={REGISTRY} defaultTheme="light">
316
+ <ThemeSwitcher />
317
+ </ThemeProvider>,
318
+ );
319
+ const menu = await openMenu();
320
+ const radios = within(menu).getAllByRole("menuitemradio");
321
+ expect(radios.map((r) => r.textContent)).toEqual([
322
+ "Default",
323
+ "Ocean",
324
+ "Dusk",
325
+ "Light",
326
+ "Dark",
327
+ "System",
328
+ ]);
329
+ expect(within(menu).getByRole("menuitemradio", { name: "Default" })).toBeChecked();
330
+ expect(within(menu).getByRole("menuitemradio", { name: "Light" })).toBeChecked();
331
+ });
332
+
333
+ it("switching family keeps the chosen mode", async () => {
334
+ render(
335
+ <ThemeProvider themes={REGISTRY} defaultTheme="dark">
336
+ <ThemeSwitcher />
337
+ </ThemeProvider>,
338
+ );
339
+ await openMenu();
340
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "Ocean" }));
341
+ expect(document.documentElement.getAttribute("data-theme")).toBe("ocean-dark");
342
+ });
343
+
344
+ it("switching mode stays within the family", async () => {
345
+ render(
346
+ <ThemeProvider themes={REGISTRY} defaultTheme="ocean-light">
347
+ <ThemeSwitcher />
348
+ </ThemeProvider>,
349
+ );
350
+ await openMenu();
351
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "Dark" }));
352
+ expect(document.documentElement.getAttribute("data-theme")).toBe("ocean-dark");
353
+ });
354
+
355
+ it("hides the mode group for a single-mode family", async () => {
356
+ render(
357
+ <ThemeProvider themes={REGISTRY} defaultTheme="dusk">
358
+ <ThemeSwitcher />
359
+ </ThemeProvider>,
360
+ );
361
+ const menu = await openMenu();
362
+ expect(within(menu).queryByRole("menuitemradio", { name: "Light" })).not.toBeInTheDocument();
363
+ expect(within(menu).queryByText("Mode")).not.toBeInTheDocument();
364
+ });
365
+
366
+ it("remembers dark across a light-only family", async () => {
367
+ const sand = defineTheme({ value: "sand", label: "Sand", dark: false, family: "sand" });
368
+ render(
369
+ <ThemeProvider themes={[...REGISTRY, sand]} defaultTheme="dark">
370
+ <ThemeSwitcher />
371
+ </ThemeProvider>,
372
+ );
373
+ await openMenu();
374
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "sand" }));
375
+ expect(document.documentElement.getAttribute("data-theme")).toBe("sand");
376
+ await openMenu();
377
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "Ocean" }));
378
+ expect(document.documentElement.getAttribute("data-theme")).toBe("ocean-dark");
379
+ });
380
+
381
+ it("checks the right family when a second same-scheme variant is active", async () => {
382
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
383
+ const oceanDeep = defineTheme({
384
+ value: "ocean-deep",
385
+ label: "Ocean deep",
386
+ dark: true,
387
+ family: "ocean",
388
+ });
389
+ render(
390
+ <ThemeProvider themes={[...REGISTRY, oceanDeep]} defaultTheme="ocean-deep">
391
+ <ThemeSwitcher />
392
+ </ThemeProvider>,
393
+ );
394
+ const menu = await openMenu();
395
+ expect(within(menu).getByRole("menuitemradio", { name: "Ocean" })).toBeChecked();
396
+ expect(within(menu).getByRole("menuitemradio", { name: "Default" })).not.toBeChecked();
397
+ warn.mockRestore();
398
+ });
399
+
400
+ it("forgets a remembered mode once another control changes the theme", async () => {
401
+ function ExternalLight() {
402
+ const { setTheme } = useTheme();
403
+ return (
404
+ <button type="button" onClick={() => setTheme("light")}>
405
+ External light
406
+ </button>
407
+ );
408
+ }
409
+ render(
410
+ <ThemeProvider themes={REGISTRY} defaultTheme="light">
411
+ <ThemeSwitcher />
412
+ <ExternalLight />
413
+ </ThemeProvider>,
414
+ );
415
+ await openMenu();
416
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "Dark" }));
417
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
418
+ await userEvent.click(screen.getByRole("button", { name: "External light" }));
419
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
420
+ await openMenu();
421
+ await userEvent.click(screen.getByRole("menuitemradio", { name: "Ocean" }));
422
+ expect(document.documentElement.getAttribute("data-theme")).toBe("ocean-light");
423
+ });
424
+
425
+ it("forced dropdown mode keeps the flat list even with families", async () => {
426
+ render(
427
+ <ThemeProvider themes={REGISTRY}>
428
+ <ThemeSwitcher mode="dropdown" />
429
+ </ThemeProvider>,
430
+ );
431
+ const menu = await openMenu();
432
+ expect(within(menu).queryAllByRole("menuitemradio")).toHaveLength(0);
433
+ expect(within(menu).getByText("Ocean dark")).toBeInTheDocument();
434
+ });
435
+ });
@@ -1,14 +1,27 @@
1
1
  "use client";
2
2
 
3
- import { forwardRef, useCallback, useEffect, useState, type HTMLAttributes } from "react";
3
+ import { forwardRef, useCallback, useEffect, useRef, useState, type HTMLAttributes } from "react";
4
4
  import { Check, Monitor, Moon, Sun } from "lucide-react";
5
- import { useTheme, type ThemeDefinition, type ThemeName } from "@elabs-ai/components-tokens";
5
+ import {
6
+ groupThemeFamilies,
7
+ resolveThemeVariant,
8
+ themeFamilyIdOf,
9
+ themeSchemeOf,
10
+ useTheme,
11
+ type ThemeDefinition,
12
+ type ThemeName,
13
+ type ThemeScheme,
14
+ } from "@elabs-ai/components-tokens";
6
15
 
7
16
  import { Button } from "../button";
8
17
  import {
9
18
  DropdownMenu,
10
19
  DropdownMenuContent,
11
20
  DropdownMenuItem,
21
+ DropdownMenuLabel,
22
+ DropdownMenuRadioGroup,
23
+ DropdownMenuRadioItem,
24
+ DropdownMenuSeparator,
12
25
  DropdownMenuTrigger,
13
26
  } from "../dropdown-menu";
14
27
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
@@ -129,16 +142,43 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
129
142
  const labelOf = (name: ThemeName) => offered.find((d) => d.value === name)?.label ?? name;
130
143
  const isDark = (name: ThemeName) => offered.find((d) => d.value === name)?.dark ?? false;
131
144
 
132
- // `?? theme` (the active theme), never a "light"/"dark" literal: with an open
133
- // registry those names may not exist at all in this app.
134
- const lightTheme: ThemeName = pickByDarkness(offered, false) ?? theme;
135
- const darkTheme: ThemeName = pickByDarkness(offered, true) ?? theme;
136
-
137
145
  // The concrete theme to DISPLAY as current: the controlled preference when
138
146
  // it names one, otherwise the theme actually applied via the provider.
139
147
  const resolvedTheme: ThemeName =
140
148
  isControlled && preference !== undefined && preference !== "system" ? preference : theme;
141
149
 
150
+ // ADR 0036 — the family layout is OPT-IN: only when the offered list (not the
151
+ // provider's `families`, so the `themes` prop still narrows) holds two or
152
+ // more DECLARED families. An undeclared `[daylight, midnight]` pair keeps
153
+ // today's toggle byte-for-byte.
154
+ const families = groupThemeFamilies(offered);
155
+ const useFamilies = mode === "auto" && families.filter((f) => f.declared).length >= 2;
156
+ // Match by the active definition's declared family, not the retained
157
+ // light/dark slots: a second same-scheme variant is still reachable via
158
+ // `setTheme` but is not in either slot.
159
+ const activeDefinition = offered.find((d) => d.value === resolvedTheme);
160
+ const activeFamily = useFamilies
161
+ ? ((activeDefinition && families.find((f) => f.id === themeFamilyIdOf(activeDefinition))) ??
162
+ families[0])
163
+ : undefined;
164
+ // "System" is scoped to the active family in the family layout.
165
+ const scope: readonly ThemeDefinition[] = activeFamily
166
+ ? [activeFamily.light, activeFamily.dark].filter((d): d is ThemeDefinition => d !== undefined)
167
+ : offered;
168
+
169
+ // `?? theme` (the active theme), never a "light"/"dark" literal: with an open
170
+ // registry those names may not exist at all in this app.
171
+ const lightTheme: ThemeName = pickByDarkness(scope, false) ?? theme;
172
+ const darkTheme: ThemeName = pickByDarkness(scope, true) ?? theme;
173
+
174
+ // The scheme the user last chose, so dark → a light-only family → a
175
+ // two-scheme family lands on dark again (mirrors the provider's `setFamily`).
176
+ // Remembered together with the theme it produced: once something else changes
177
+ // the theme (another control calling `setTheme`), the memory no longer applies.
178
+ const intendedSchemeRef = useRef<{ scheme: ThemeScheme; theme: ThemeName } | undefined>(
179
+ undefined,
180
+ );
181
+
142
182
  // Hydrate the "system" choice from localStorage and apply it on mount —
143
183
  // uncontrolled mode only. Controlled mode's "system" state comes from the
144
184
  // `preference` prop and never touches this component's own storage key.
@@ -195,6 +235,90 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
195
235
  }, [switchTheme, darkTheme, lightTheme, isControlled, onPreferenceChange]);
196
236
 
197
237
  const TriggerIcon = isSystem ? Monitor : isDark(resolvedTheme) ? Moon : Sun;
238
+
239
+ // ---- Family layout (≥2 declared families): family group + scheme group ----
240
+ if (activeFamily) {
241
+ const activeScheme = activeDefinition;
242
+ const remembered = intendedSchemeRef.current;
243
+ const intended =
244
+ remembered && remembered.theme === resolvedTheme
245
+ ? remembered.scheme
246
+ : activeScheme
247
+ ? themeSchemeOf(activeScheme)
248
+ : "light";
249
+ const pickFamily = (familyId: string) => {
250
+ // System re-resolves against the OS immediately on a family change
251
+ // instead of waiting for the next `change` event.
252
+ const scheme: ThemeScheme = isSystem ? (prefersDark() ? "dark" : "light") : intended;
253
+ const next = resolveThemeVariant(families, familyId, scheme);
254
+ if (next === undefined) return;
255
+ intendedSchemeRef.current = { scheme: intended, theme: next };
256
+ if (isSystem) switchTheme(next);
257
+ else pickTheme(next);
258
+ };
259
+ const pickScheme = (value: string) => {
260
+ if (value === "system") {
261
+ pickSystem();
262
+ return;
263
+ }
264
+ const scheme = value as ThemeScheme;
265
+ const next = activeFamily[scheme]?.value;
266
+ if (next === undefined) return;
267
+ intendedSchemeRef.current = { scheme, theme: next };
268
+ pickTheme(next);
269
+ };
270
+ const schemeValue = isSystem ? "system" : activeScheme ? themeSchemeOf(activeScheme) : "";
271
+
272
+ return (
273
+ <DropdownMenu>
274
+ <DropdownMenuTrigger asChild>
275
+ <Button
276
+ ref={ref}
277
+ variant="outline"
278
+ size={ICON_SIZE[size]}
279
+ aria-label={t("ui.themeSwitcher.theme")}
280
+ className={className}
281
+ {...props}
282
+ >
283
+ <TriggerIcon />
284
+ </Button>
285
+ </DropdownMenuTrigger>
286
+ <DropdownMenuContent align="end">
287
+ <DropdownMenuLabel>{t("ui.themeSwitcher.theme")}</DropdownMenuLabel>
288
+ <DropdownMenuRadioGroup value={activeFamily.id} onValueChange={pickFamily}>
289
+ {families.map((f) => (
290
+ <DropdownMenuRadioItem key={f.id} value={f.id}>
291
+ {f.label}
292
+ </DropdownMenuRadioItem>
293
+ ))}
294
+ </DropdownMenuRadioGroup>
295
+ {activeFamily.schemes.length === 2 ? (
296
+ <>
297
+ <DropdownMenuSeparator />
298
+ <DropdownMenuLabel>{t("ui.themeSwitcher.mode")}</DropdownMenuLabel>
299
+ <DropdownMenuRadioGroup value={schemeValue} onValueChange={pickScheme}>
300
+ <DropdownMenuRadioItem value="light">
301
+ <Sun aria-hidden="true" />
302
+ {t("ui.themeSwitcher.light")}
303
+ </DropdownMenuRadioItem>
304
+ <DropdownMenuRadioItem value="dark">
305
+ <Moon aria-hidden="true" />
306
+ {t("ui.themeSwitcher.dark")}
307
+ </DropdownMenuRadioItem>
308
+ {showSystem ? (
309
+ <DropdownMenuRadioItem value="system">
310
+ <Monitor aria-hidden="true" />
311
+ {t("ui.themeSwitcher.system")}
312
+ </DropdownMenuRadioItem>
313
+ ) : null}
314
+ </DropdownMenuRadioGroup>
315
+ </>
316
+ ) : null}
317
+ </DropdownMenuContent>
318
+ </DropdownMenu>
319
+ );
320
+ }
321
+
198
322
  const useDropdown = mode === "dropdown" || (mode === "auto" && safeThemes.length > 2);
199
323
 
200
324
  // ---- Dropdown mode (>2 themes) ----
@@ -206,7 +330,7 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
206
330
  ref={ref}
207
331
  variant="outline"
208
332
  size={ICON_SIZE[size]}
209
- aria-label="Theme"
333
+ aria-label={t("ui.themeSwitcher.theme")}
210
334
  className={className}
211
335
  {...props}
212
336
  >
@@ -218,14 +342,14 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
218
342
  <DropdownMenuItem onSelect={() => pickSystem()}>
219
343
  <Monitor />
220
344
  <span>{t("ui.themeSwitcher.system")}</span>
221
- {isSystem ? <Check className="ml-auto" /> : null}
345
+ {isSystem ? <Check className="ms-auto" /> : null}
222
346
  </DropdownMenuItem>
223
347
  ) : null}
224
348
  {offered.map((d) => (
225
349
  <DropdownMenuItem key={d.value} onSelect={() => pickTheme(d.value)}>
226
350
  {d.dark ? <Moon /> : <Sun />}
227
351
  <span>{d.label}</span>
228
- {!isSystem && resolvedTheme === d.value ? <Check className="ml-auto" /> : null}
352
+ {!isSystem && resolvedTheme === d.value ? <Check className="ms-auto" /> : null}
229
353
  </DropdownMenuItem>
230
354
  ))}
231
355
  </DropdownMenuContent>
@@ -15,9 +15,6 @@
15
15
  * parts. Keeps the editor-facing `done|active|pending` API unchanged by
16
16
  * mapping in via `fromTimelineStatus` at the boundary.
17
17
  *
18
- * Fork-prevention: a Timeline re-implementation outside this folder fails
19
- * `pnpm timeline-fork:check` (scripts/check-timeline-fork.mjs).
20
- *
21
18
  * Deliberately NOT on this rail (a different grammar, not an oversight —
22
19
  * RM-014, #133): `RevisionTimeline` (@elabs-ai/components-ui). Its rail is a
23
20
  * multi-lane commit DAG whose node colour is LANE IDENTITY (the chart ramp),
@@ -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-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-control text-body font-control 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: {
@@ -44,7 +44,11 @@ export const toggleVariants = cva(
44
44
  segmented:
45
45
  "border-0 bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground aria-checked:bg-surface-elevated aria-checked:text-foreground aria-checked:font-medium aria-checked:shadow-sm aria-pressed:bg-surface-elevated aria-pressed:text-foreground aria-pressed:font-medium aria-pressed:shadow-sm data-[state=on]:bg-surface-elevated data-[state=on]:text-foreground data-[state=on]:font-medium data-[state=on]:shadow-sm",
46
46
  },
47
- size: { default: "h-9 px-2.5 min-w-9", sm: "h-8 px-2 min-w-8", lg: "h-10 px-3 min-w-10" },
47
+ size: {
48
+ default: "h-control px-2.5 min-w-control",
49
+ sm: "h-control-sm px-2 min-w-control-sm",
50
+ lg: "h-control-lg px-3 min-w-control-lg",
51
+ },
48
52
  },
49
53
  defaultVariants: { variant: "default", size: "default" },
50
54
  },
@@ -57,6 +61,7 @@ export const Toggle = forwardRef<
57
61
  return (
58
62
  <TogglePrimitive.Root
59
63
  ref={ref}
64
+ data-slot="toggle"
60
65
  className={cn(toggleVariants({ variant, size }), className)}
61
66
  {...props}
62
67
  />
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -14,9 +14,10 @@ export const TooltipContent = forwardRef<
14
14
  <TooltipPrimitive.Portal>
15
15
  <TooltipPrimitive.Content
16
16
  ref={ref}
17
+ data-slot="tooltip-content"
17
18
  sideOffset={sideOffset}
18
19
  className={cn(
19
- "z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md",
20
+ "z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-meta text-background shadow-md",
20
21
  "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
21
22
  className,
22
23
  )}
@@ -17,7 +17,7 @@ export const TopNav = forwardRef<HTMLElement, TopNavProps>(function TopNav(
17
17
  <header
18
18
  ref={ref}
19
19
  className={cn(
20
- "sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-surface-elevated/80 px-4 backdrop-blur",
20
+ "sticky top-0 z-30 flex h-header items-center gap-4 border-b bg-surface-elevated/80 px-4 backdrop-blur",
21
21
  className,
22
22
  )}
23
23
  {...props}
@@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../card";
18
18
  import { Checkbox } from "../checkbox";
19
19
  import { Input } from "../input";
20
20
  import { ScrollArea } from "../scroll-area";
21
+ import { useLocale } from "../locale-provider";
21
22
 
22
23
  // ─── Public types ─────────────────────────────────────────────────────────────
23
24
 
@@ -87,6 +88,7 @@ function TransferPanel({
87
88
  titleId,
88
89
  rowIdBase,
89
90
  }: PanelProps) {
91
+ const { t } = useLocale();
90
92
  // When a search is active, filter visible items; move operations affect VISIBLE enabled items.
91
93
  const visibleItems = useMemo(() => {
92
94
  if (!searchQuery) return items;
@@ -109,10 +111,12 @@ function TransferPanel({
109
111
  <Checkbox
110
112
  checked={someChecked ? "indeterminate" : allChecked}
111
113
  onCheckedChange={(v) => onCheckAll(v === true)}
112
- aria-label={`Select all in ${typeof title === "string" ? title : "panel"}`}
114
+ aria-label={t("ui.transfer.selectAllIn", {
115
+ title: typeof title === "string" ? title : t("ui.transfer.panelFallback"),
116
+ })}
113
117
  disabled={enabledVisible.length === 0}
114
118
  />
115
- <CardTitle id={titleId} className="text-sm">
119
+ <CardTitle id={titleId} className="text-body">
116
120
  {title}
117
121
  </CardTitle>
118
122
  </div>
@@ -124,15 +128,17 @@ function TransferPanel({
124
128
  {showSearch && (
125
129
  <div className="px-3 pb-2">
126
130
  <label htmlFor={searchInputId} className="sr-only">
127
- Search {typeof title === "string" ? title : "items"}
131
+ {t("ui.transfer.searchIn", {
132
+ title: typeof title === "string" ? title : t("ui.transfer.itemsFallback"),
133
+ })}
128
134
  </label>
129
135
  <Input
130
136
  id={searchInputId}
131
137
  type="search"
132
- placeholder="Search…"
138
+ placeholder={t("ui.transfer.searchPlaceholder")}
133
139
  value={searchQuery}
134
140
  onChange={(e) => onSearchChange(e.target.value)}
135
- className="h-7 text-xs"
141
+ className="h-7 text-meta"
136
142
  />
137
143
  </div>
138
144
  )}
@@ -140,8 +146,8 @@ function TransferPanel({
140
146
  <CardContent className="flex-1 overflow-hidden p-0">
141
147
  <ScrollArea className="h-full min-h-[200px]">
142
148
  {visibleItems.length === 0 ? (
143
- <p className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
144
- No items
149
+ <p className="flex h-[200px] items-center justify-center text-body text-muted-foreground">
150
+ {t("ui.transfer.empty")}
145
151
  </p>
146
152
  ) : (
147
153
  <ul role="list" className="flex flex-col py-1">
@@ -156,7 +162,7 @@ function TransferPanel({
156
162
  <li
157
163
  key={item.value}
158
164
  className={cn(
159
- "flex select-none items-center gap-2 px-3 py-1.5 text-sm",
165
+ "flex select-none items-center gap-2 px-3 py-1.5 text-body",
160
166
  "hover:bg-accent hover:text-accent-foreground",
161
167
  item.disabled && "opacity-50 hover:bg-transparent hover:text-foreground",
162
168
  )}
@@ -198,13 +204,18 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
198
204
  defaultTargetKeys,
199
205
  onChange,
200
206
  showSearch = false,
201
- titles = ["Source", "Target"],
207
+ titles,
202
208
  renderItem = (item) => item.label,
203
209
  className,
204
210
  ...props
205
211
  },
206
212
  ref,
207
213
  ) {
214
+ const { t } = useLocale();
215
+ const resolvedTitles: [ReactNode, ReactNode] = titles ?? [
216
+ t("ui.transfer.defaultSourceTitle"),
217
+ t("ui.transfer.defaultTargetTitle"),
218
+ ];
208
219
  const isControlled = controlledTargetKeys !== undefined;
209
220
 
210
221
  // Uncontrolled internal state
@@ -273,13 +284,13 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
273
284
  );
274
285
  if (toMove.size === 0) return;
275
286
  applyMove([...targetKeys, ...toMove]);
276
- setAnnouncement(`${toMove.size} item${toMove.size === 1 ? "" : "s"} moved to the target list.`);
287
+ setAnnouncement(t("ui.transfer.movedToTarget", { count: toMove.size }));
277
288
  setSourceChecked((prev) => {
278
289
  const next = new Set(prev);
279
290
  toMove.forEach((v) => next.delete(v));
280
291
  return next;
281
292
  });
282
- }, [sourceChecked, dataSource, targetKeys, targetSet, applyMove]);
293
+ }, [sourceChecked, dataSource, targetKeys, targetSet, applyMove, t]);
283
294
 
284
295
  /** Move ALL visible enabled source items → target */
285
296
  const handleMoveAllRight = useCallback(() => {
@@ -290,11 +301,9 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
290
301
  if (toAdd.length === 0) return;
291
302
  const next = [...new Set([...targetKeys, ...toAdd])];
292
303
  applyMove(next);
293
- setAnnouncement(
294
- `${toAdd.length} item${toAdd.length === 1 ? "" : "s"} moved to the target list.`,
295
- );
304
+ setAnnouncement(t("ui.transfer.movedToTarget", { count: toAdd.length }));
296
305
  setSourceChecked(new Set());
297
- }, [visibleSourceEnabled, targetKeys, applyMove]);
306
+ }, [visibleSourceEnabled, targetKeys, applyMove, t]);
298
307
 
299
308
  /** Move target-panel checked items → source */
300
309
  const handleMoveCheckedLeft = useCallback(() => {
@@ -306,26 +315,22 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
306
315
  );
307
316
  if (toRemove.size === 0) return;
308
317
  applyMove(targetKeys.filter((k) => !toRemove.has(k)));
309
- setAnnouncement(
310
- `${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
311
- );
318
+ setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
312
319
  setTargetChecked((prev) => {
313
320
  const next = new Set(prev);
314
321
  toRemove.forEach((v) => next.delete(v));
315
322
  return next;
316
323
  });
317
- }, [targetChecked, dataSource, targetKeys, targetSet, applyMove]);
324
+ }, [targetChecked, dataSource, targetKeys, targetSet, applyMove, t]);
318
325
 
319
326
  /** Move ALL visible enabled target items → source */
320
327
  const handleMoveAllLeft = useCallback(() => {
321
328
  const toRemove = new Set(visibleTargetEnabled.map((it) => it.value));
322
329
  if (toRemove.size === 0) return;
323
330
  applyMove(targetKeys.filter((k) => !toRemove.has(k)));
324
- setAnnouncement(
325
- `${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
326
- );
331
+ setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
327
332
  setTargetChecked(new Set());
328
- }, [visibleTargetEnabled, targetKeys, applyMove]);
333
+ }, [visibleTargetEnabled, targetKeys, applyMove, t]);
329
334
 
330
335
  // ── Per-panel check handlers ──────────────────────────────────────────────
331
336
 
@@ -402,7 +407,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
402
407
 
403
408
  {/* ── Source panel ─────────────────────────────── */}
404
409
  <TransferPanel
405
- title={titles[0]}
410
+ title={resolvedTitles[0]}
406
411
  items={sourceItems}
407
412
  checked={sourceChecked}
408
413
  onCheckItem={handleSourceCheckItem}
@@ -420,12 +425,12 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
420
425
  <div
421
426
  className="flex flex-col items-center justify-center gap-1"
422
427
  role="group"
423
- aria-label="Move controls"
428
+ aria-label={t("ui.transfer.moveControls")}
424
429
  >
425
430
  <Button
426
431
  variant="outline"
427
432
  size="icon-sm"
428
- aria-label="Move selected right"
433
+ aria-label={t("ui.transfer.moveSelectedRight")}
429
434
  disabled={!canMoveCheckedRight}
430
435
  onClick={handleMoveCheckedRight}
431
436
  >
@@ -434,7 +439,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
434
439
  <Button
435
440
  variant="outline"
436
441
  size="icon-sm"
437
- aria-label="Move all right"
442
+ aria-label={t("ui.transfer.moveAllRight")}
438
443
  disabled={!canMoveAllRight}
439
444
  onClick={handleMoveAllRight}
440
445
  >
@@ -443,7 +448,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
443
448
  <Button
444
449
  variant="outline"
445
450
  size="icon-sm"
446
- aria-label="Move selected left"
451
+ aria-label={t("ui.transfer.moveSelectedLeft")}
447
452
  disabled={!canMoveCheckedLeft}
448
453
  onClick={handleMoveCheckedLeft}
449
454
  className="mt-2"
@@ -453,7 +458,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
453
458
  <Button
454
459
  variant="outline"
455
460
  size="icon-sm"
456
- aria-label="Move all left"
461
+ aria-label={t("ui.transfer.moveAllLeft")}
457
462
  disabled={!canMoveAllLeft}
458
463
  onClick={handleMoveAllLeft}
459
464
  >
@@ -463,7 +468,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
463
468
 
464
469
  {/* ── Target panel ─────────────────────────────── */}
465
470
  <TransferPanel
466
- title={titles[1]}
471
+ title={resolvedTitles[1]}
467
472
  items={targetItems}
468
473
  checked={targetChecked}
469
474
  onCheckItem={handleTargetCheckItem}