@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 { useLayoutEffect, useRef, useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { expect } from "storybook/test";
4
+ import { cn } from "../../lib/cn";
4
5
  import { Heading, Text } from "./typography";
5
6
  import {
6
7
  ProseBlockquote,
@@ -242,3 +243,230 @@ export const Prose: Story = {
242
243
  </div>
243
244
  ),
244
245
  };
246
+
247
+ /**
248
+ * The CJK FONT FALLBACK + LINE-BREAKING seam (#15). `--font-cjk-sans` is a
249
+ * dedicated tail in `--font-sans` (system faces only — never bundled; a CJK
250
+ * face is megabytes and every target platform already ships one) that
251
+ * `:lang(zh)` / `:lang(ko)` re-point per locale (`ja` is the root default);
252
+ * `:lang(ja)`/`:lang(zh)` also get `line-break: strict` and `:lang(ko)` gets
253
+ * `word-break: keep-all` (`themes.css` §§ "CJK PER-LOCALE FONT SEAM" / "CJK
254
+ * line-breaking"). Before this story, no story anywhere rendered CJK text, so
255
+ * none of this was exercised.
256
+ *
257
+ * The designed real-world shape is `<html lang="zh">` for a whole document,
258
+ * with `font-family: var(--font-sans)` declared ONCE at that same theme-root
259
+ * element. That matters for how this story has to be built: a custom
260
+ * property's `var()` references are substituted at the element that declares
261
+ * the property, and the RESULT — not the raw token stream — is what
262
+ * descendants inherit. So `--font-sans`'s `var(--font-cjk-sans)` is resolved
263
+ * once, at the theme root, using whatever `lang` is active THERE; a nested
264
+ * `:lang(zh)` further down only re-points `--font-cjk-sans` on that
265
+ * descendant — it does nothing for `--font-sans`, which the descendant never
266
+ * redeclares, only inherits pre-resolved. To show all three locales side by
267
+ * side under one shared theme root (whose own `lang` cannot be three things
268
+ * at once), each "with the seam" sample below redeclares the SAME expansion
269
+ * `--font-sans` itself uses (see `themes/light.css`/`themes/dark.css`)
270
+ * directly in its `style`, so the `var(--font-cjk-sans)` substitution happens
271
+ * fresh, at that element, against the `--font-cjk-sans` value its own `lang`
272
+ * cascades in via `:lang()`.
273
+ */
274
+ type CjkSample = {
275
+ lang: "ja" | "zh" | "ko";
276
+ label: string;
277
+ rule: string;
278
+ text: string;
279
+ width?: string; // Optional custom width, defaults to w-56
280
+ };
281
+
282
+ const CJK_SAMPLES: readonly CjkSample[] = [
283
+ {
284
+ lang: "ja",
285
+ label: "Japanese (ja)",
286
+ rule: "line-break: strict",
287
+ text:
288
+ "この文章は十分に長く、コンテナの幅を超えるとブラウザが自動的に折り返しを行います。" +
289
+ "折り返しの際に句読点や閉じ括弧(このように)が行頭に来ないようにするのが、" +
290
+ "line-break: strict の役割です。",
291
+ },
292
+ {
293
+ lang: "zh",
294
+ label: "Chinese (zh)",
295
+ rule: "line-break: strict",
296
+ text:
297
+ "这段文字足够长,当容器宽度不足时浏览器会自动换行。line-break: strict 的作用是避免" +
298
+ "标点符号(例如逗号、句号或右括号)出现在行首,让排版更符合中文书写习惯。",
299
+ },
300
+ {
301
+ lang: "ko",
302
+ label: "Korean (ko)",
303
+ rule: "word-break: keep-all",
304
+ text:
305
+ "소프트웨어개발환경구축및정보통신기술표준화를위한국제협력과연구개발사업이진행되고있습니다. " +
306
+ "word-break: keep-all 속성은 긴 복합어 중간에서 줄이 바뀌지 않도록 보장하여 " +
307
+ "가독성을 지켜 줍니다.",
308
+ width: "w-36",
309
+ },
310
+ ];
311
+
312
+ /** One locale panel: the seam ON (real `lang`) next to the seam forced OFF, for comparison. */
313
+ function CjkPanel({ sample }: { sample: CjkSample }) {
314
+ const { lang, label, rule, text, width = "w-56" } = sample;
315
+ const withSeamRef = useRef<HTMLParagraphElement>(null);
316
+ const [measured, setMeasured] = useState("");
317
+ useLayoutEffect(() => {
318
+ const el = withSeamRef.current;
319
+ if (!el) return;
320
+ const { lineBreak, wordBreak, fontFamily } = getComputedStyle(el);
321
+ setMeasured(`line-break: ${lineBreak} · word-break: ${wordBreak} · font-family: ${fontFamily}`);
322
+ }, []);
323
+ return (
324
+ <section aria-labelledby={`cjk-${lang}`} className="space-y-2">
325
+ <h3 id={`cjk-${lang}`} className="text-subtitle text-foreground">
326
+ {label} — <code className="text-code">{rule}</code>
327
+ </h3>
328
+ <div className="grid gap-4 sm:grid-cols-2">
329
+ <figure className="m-0 space-y-1">
330
+ <figcaption className="text-meta text-muted-foreground">
331
+ Without the seam (comparison only — line-break/word-break forced to the CSS-initial
332
+ default)
333
+ </figcaption>
334
+ {/* Comparison-only override to show what the seam replaces; a real
335
+ consumer never does this. Font-family is left alone here — this
336
+ column isolates the LINE-BREAKING half of the seam. */}
337
+ <p
338
+ lang={lang}
339
+ style={{ fontFamily: "var(--font-sans)", lineBreak: "auto", wordBreak: "normal" }}
340
+ className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
341
+ >
342
+ {text}
343
+ </p>
344
+ </figure>
345
+ <figure className="m-0 space-y-1">
346
+ <figcaption className="text-meta text-muted-foreground">
347
+ With the seam (the default result of{" "}
348
+ <code className="text-code">lang=&quot;{lang}&quot;</code>)
349
+ </figcaption>
350
+ <p
351
+ ref={withSeamRef}
352
+ lang={lang}
353
+ data-testid={`cjk-with-seam-${lang}`}
354
+ // Redeclares --font-sans's own expansion (themes/light.css,
355
+ // themes/dark.css) directly, rather than using
356
+ // `var(--font-sans)`, because --font-sans is inherited from the
357
+ // shared theme root as an ALREADY-substituted string — see the
358
+ // comment above CjkSample. Writing the expansion here forces the
359
+ // `var(--font-cjk-sans)` substitution to happen at THIS element,
360
+ // against the value its own `lang` cascades in.
361
+ style={{
362
+ fontFamily:
363
+ 'Inter, var(--font-cjk-sans), "Helvetica Neue", Helvetica, Arial, sans-serif',
364
+ }}
365
+ className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
366
+ >
367
+ {text}
368
+ </p>
369
+ </figure>
370
+ </div>
371
+ {/* The declared (not rendered) values — a real, un-bundled system CJK
372
+ face never looks identical across two machines, so THIS is what a
373
+ reviewer can actually verify: the correct stack is being asked for.
374
+
375
+ NOTE: document.fonts.check() is NOT used to verify font resolution
376
+ (issue #62). That API reports load status, not face existence — it
377
+ returns true even for nonexistent font names. The measured values
378
+ instead read the browser's own getComputedStyle output, which is
379
+ verifiable. */}
380
+ <p className="m-0 text-meta text-muted-foreground" data-testid={`cjk-measured-${lang}`}>
381
+ {measured || "measuring…"}
382
+ </p>
383
+ </section>
384
+ );
385
+ }
386
+
387
+ export const CJKFontAndLineBreaking: Story = {
388
+ name: "CJK font fallback + line-breaking (#15)",
389
+ parameters: {
390
+ docs: {
391
+ description: {
392
+ story:
393
+ "Japanese, Chinese and Korean samples, each wrapped in a narrow (14rem) column so " +
394
+ "the text wraps across multiple lines — the line-breaking rules need a real wrap " +
395
+ "point to have anything to do.\n\n" +
396
+ "**Verification by locale:**\n" +
397
+ "* **Japanese (`ja`) and Chinese (`zh`):** `line-break: strict` is verified via " +
398
+ "computed style only; no empirically-validated visual wrap difference has been found at " +
399
+ "this column width for any practical sample text in chromium or webkit, so the rendered " +
400
+ "layout is NOT asserted to differ. The rule is confirmed present via `getComputedStyle`.\n" +
401
+ "* **Korean (`ko`):** `word-break: keep-all` is verified both via computed style AND " +
402
+ "an actual rendered wrap-position difference assertion in the play function.\n\n" +
403
+ "The measured line under each pair reads the browser's OWN computed `line-break` / " +
404
+ "`word-break` / `font-family`, live. Rendered glyph shape depends on which CJK system " +
405
+ "fonts are installed on the machine viewing this story (none are bundled, by design " +
406
+ "— #15); the declared font STACK is what is verifiable everywhere.",
407
+ },
408
+ },
409
+ },
410
+ render: () => (
411
+ <div className="max-w-3xl space-y-8">
412
+ {CJK_SAMPLES.map((s) => (
413
+ <CjkPanel key={s.lang} sample={s} />
414
+ ))}
415
+ </div>
416
+ ),
417
+ // Asserts the seam on the REAL rendered surface, mirroring the DensityScale
418
+ // play function above: read the browser's actual computed style rather than
419
+ // re-deriving the CSS by hand.
420
+ play: async ({ canvas }) => {
421
+ const ja = canvas.getByTestId("cjk-with-seam-ja");
422
+ const zh = canvas.getByTestId("cjk-with-seam-zh");
423
+ const ko = canvas.getByTestId("cjk-with-seam-ko");
424
+
425
+ // :lang(ja), :lang(zh) => line-break: strict. :lang(ko) is NOT in that
426
+ // rule, so it stays at the CSS-initial value ("auto").
427
+ expect(getComputedStyle(ja).lineBreak).toBe("strict");
428
+ expect(getComputedStyle(zh).lineBreak).toBe("strict");
429
+ expect(getComputedStyle(ko).lineBreak).toBe("auto");
430
+
431
+ // :lang(ko) => word-break: keep-all. ja/zh are NOT in that rule, so they
432
+ // stay at the CSS-initial value ("normal").
433
+ expect(getComputedStyle(ko).wordBreak).toBe("keep-all");
434
+ expect(getComputedStyle(ja).wordBreak).toBe("normal");
435
+ expect(getComputedStyle(zh).wordBreak).toBe("normal");
436
+
437
+ // --font-cjk-sans is re-pointed per :lang() — the DECLARED face list
438
+ // differs by locale, which is what the fallback seam actually ships.
439
+ expect(getComputedStyle(ja).fontFamily).toContain("Hiragino Sans");
440
+ expect(getComputedStyle(zh).fontFamily).toContain("PingFang SC");
441
+ expect(getComputedStyle(ko).fontFamily).toContain("Apple SD Gothic Neo");
442
+
443
+ // Korean's word-break: keep-all must produce a RENDERED wrap-position
444
+ // difference. Measure bounding height via getClientRects — if the seam
445
+ // (word-break: keep-all) doesn't change the wrap, the height stays the
446
+ // same. Find the corresponding "without the seam" panel by the section's
447
+ // sibling figure.
448
+ const koSection = canvas.getByTestId("cjk-with-seam-ko").closest("section");
449
+ if (!koSection) throw new Error("Korean section not found");
450
+ const koFigures = Array.from(koSection.querySelectorAll("figure"));
451
+ if (koFigures.length < 2) throw new Error("Korean figures not found");
452
+
453
+ const withSeamFigure = koFigures[1]; // "With the seam" is the second figure
454
+ const withoutSeamFigure = koFigures[0]; // "Without the seam" is the first figure
455
+
456
+ const withSeamP = withSeamFigure.querySelector("p");
457
+ const withoutSeamP = withoutSeamFigure.querySelector("p");
458
+
459
+ if (!withSeamP || !withoutSeamP) {
460
+ throw new Error("Korean paragraph elements not found");
461
+ }
462
+
463
+ // Measure bounding height: the word-break property should change how the
464
+ // text wraps, resulting in a different total height.
465
+ const withSeamHeight = withSeamP.getBoundingClientRect().height;
466
+ const withoutSeamHeight = withoutSeamP.getBoundingClientRect().height;
467
+
468
+ // Assert they differ — word-break: keep-all should prevent word breaks
469
+ // that would otherwise occur, changing the layout.
470
+ expect(withSeamHeight).not.toBe(withoutSeamHeight);
471
+ },
472
+ };
@@ -11,7 +11,15 @@ const meta = {
11
11
  title: "Layout/ViewToolbar",
12
12
  component: ViewToolbar,
13
13
  tags: ["autodocs"],
14
- parameters: { layout: "padded" },
14
+ parameters: {
15
+ layout: "padded",
16
+ docs: {
17
+ description: {
18
+ component:
19
+ "The row above a list, table or board — filters and context on the left, actions on the right. Its controls are ordinary tab stops on purpose: a dense secondary control row that should cost ONE tab stop with arrow-key roving is `Layout/Toolbar`, the component that actually claims the `toolbar` ARIA role. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs) and [Docs / View Toolbar Contract](?path=/docs/docs-view-toolbar-contract--docs) (rule R10) for the filter-chip, result-count and control-height rules of this row.",
20
+ },
21
+ },
22
+ },
15
23
  argTypes: {
16
24
  info: {
17
25
  description: "What this view is — shown in a tooltip on the ⓘ trigger.",
@@ -313,6 +321,38 @@ export const KeyboardRemoval: Story = {
313
321
  },
314
322
  };
315
323
 
324
+ /**
325
+ * `FilterChip`'s `trailing` slot (#284) — a short, non-shrinking secondary
326
+ * text segment (a count) that CSS truncation can never reach, unlike `label`.
327
+ * In a narrow container the label truncates with an ellipsis; the count
328
+ * stays fully visible in its own element.
329
+ */
330
+ export const FilterChipWithTrailing: Story = {
331
+ render: () => (
332
+ <div className="w-[220px]">
333
+ <FilterChip
334
+ label="Status: Awaiting downstream reconciliation review"
335
+ trailing="excluded 1,204"
336
+ onRemove={() => undefined}
337
+ />
338
+ </div>
339
+ ),
340
+ play: async ({ canvas, canvasElement }) => {
341
+ const trailing = await canvas.findByText("excluded 1,204");
342
+ await expect(trailing).toBeInTheDocument();
343
+
344
+ const truncating = canvasElement.querySelector('[class*="truncate"]');
345
+ await expect(truncating).not.toBeNull();
346
+ await expect(truncating?.contains(trailing)).toBe(false);
347
+
348
+ await expect(
349
+ canvas.getByRole("button", {
350
+ name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
351
+ }),
352
+ ).toBeInTheDocument();
353
+ },
354
+ };
355
+
316
356
  /** The same row on `dark`. */
317
357
  export const DarkTheme: Story = {
318
358
  globals: { theme: "dark" },
@@ -208,6 +208,39 @@ describe("FilterChip", () => {
208
208
  expect(chip).not.toBeNull();
209
209
  expect(ref.current).toBe(chip);
210
210
  });
211
+
212
+ describe("trailing slot", () => {
213
+ it("renders trailing in its own element, separate from the truncating label span (#284)", () => {
214
+ const { container } = render(
215
+ <FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
216
+ );
217
+ const label = container.querySelector("[data-slot='view-toolbar-filter-chip-label']");
218
+ const trailing = container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']");
219
+ expect(label).not.toBeNull();
220
+ expect(trailing).not.toBeNull();
221
+ expect(label?.textContent).toBe("Status: Failed");
222
+ expect(trailing?.textContent).toBe("excluded 1,204");
223
+ expect(label?.className).toContain("truncate");
224
+ expect(trailing?.contains(label)).toBe(false);
225
+ expect(label?.contains(trailing as Node)).toBe(false);
226
+ });
227
+
228
+ it("composes label and trailing into the accessible name", () => {
229
+ render(
230
+ <FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
231
+ );
232
+ expect(
233
+ screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
234
+ ).toBeInTheDocument();
235
+ });
236
+
237
+ it("renders no trailing element when trailing is omitted", () => {
238
+ const { container } = render(
239
+ <FilterChip label="Status: Failed" onRemove={() => undefined} />,
240
+ );
241
+ expect(container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']")).toBeNull();
242
+ });
243
+ });
211
244
  });
212
245
 
213
246
  describe("ResultCount", () => {
@@ -200,6 +200,22 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
200
200
  * as a sentence fragment out of context (and in a screen reader).
201
201
  */
202
202
  label: string;
203
+ /**
204
+ * Short, non-shrinking secondary text — a count, a unit (`"excluded 1,204"`).
205
+ * A second text slot rather than folding it into `label` (#284): `label` is
206
+ * the shrinkable, safe-to-truncate part; `trailing` is short and fixed-width
207
+ * and must never lose characters to the ellipsis. Same shape as
208
+ * `TurnStatus`'s and `TerminalWorking`'s `trailing` slot — a fixed-width
209
+ * companion beside a truncating label. Composed into the accessible name
210
+ * after `label` (`"Remove filter: <label> · <trailing>"`), so the byte-
211
+ * identical name from before this slot existed is unchanged.
212
+ *
213
+ * Trade-off: `trailing` is `shrink-0`, so an extremely narrow container now
214
+ * truncates `label` down to nothing before it touches `trailing`. That is
215
+ * the right default — the count is short and fixed, the label is the long,
216
+ * prose-shaped part — but it is a choice, not a free win.
217
+ */
218
+ trailing?: string;
203
219
  /** Remove this filter. The WHOLE chip is the remove control — see below. */
204
220
  onRemove: () => void;
205
221
  }
@@ -217,7 +233,7 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
217
233
  * reach for `FacetFilter` (`@elabs-ai/components-data`), not a variant of this.
218
234
  */
219
235
  export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
220
- { label, onRemove, className, onClick, ...props },
236
+ { label, trailing, onRemove, className, onClick, ...props },
221
237
  ref,
222
238
  ) {
223
239
  const { t } = useLocale();
@@ -227,14 +243,16 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
227
243
  ref={ref}
228
244
  type="button"
229
245
  data-slot="view-toolbar-filter-chip"
230
- aria-label={t("ui.viewToolbar.removeFilter", { label })}
246
+ aria-label={t("ui.viewToolbar.removeFilter", {
247
+ label: trailing ? `${label} · ${trailing}` : label,
248
+ })}
231
249
  className={cn(
232
250
  badgeVariants({ variant: "secondary" }),
233
251
  "group min-h-6 max-w-full gap-1 ps-2.5 pe-1.5",
234
252
  "transition-colors duration-fast ease-standard hover:bg-secondary/70",
235
253
  // Badge's own `focus:` ring is dead weight on a <span> but would fire on
236
254
  // every MOUSE click here; neutralise it and keep the focus-visible ring.
237
- "focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
255
+ "focus:ring-0 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
238
256
  "disabled:pointer-events-none disabled:opacity-50",
239
257
  className,
240
258
  )}
@@ -244,7 +262,22 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
244
262
  }}
245
263
  {...props}
246
264
  >
247
- <span className="truncate">{label}</span>
265
+ <span data-slot="view-toolbar-filter-chip-label" className="min-w-0 truncate">
266
+ {label}
267
+ </span>
268
+ {trailing ? (
269
+ <>
270
+ <span aria-hidden="true" className="shrink-0 text-muted-foreground">
271
+ ·
272
+ </span>
273
+ <span
274
+ data-slot="view-toolbar-filter-chip-trailing"
275
+ className="shrink-0 whitespace-nowrap tabular-nums"
276
+ >
277
+ {trailing}
278
+ </span>
279
+ </>
280
+ ) : null}
248
281
  <X
249
282
  aria-hidden="true"
250
283
  className="size-3 shrink-0 opacity-60 transition-opacity duration-fast ease-standard group-hover:opacity-100"
@@ -10,7 +10,7 @@
10
10
  * - Search filtering while open
11
11
  * - Keyboard navigation (ArrowDown/Up/Enter/Escape) driving aria-activedescendant
12
12
  * - Virtualizer scroll behavior with 10,000 options
13
- * - Six-theme visual correctness
13
+ * - Cross-theme visual correctness
14
14
  */
15
15
  import { describe, expect, it } from "vitest";
16
16
  import { render, screen } from "@testing-library/react";
@@ -234,7 +234,7 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
234
234
  onClick={() => goTo(index)}
235
235
  className={cn(
236
236
  "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums",
237
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
237
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
238
238
  isActive && "border-primary bg-primary text-primary-foreground",
239
239
  // #399 — the step NUMBER is text on a `/10` wash, not plate ink
240
240
  // and not a mark: `-text` rung. The border keeps the fill rung.
@@ -0,0 +1,3 @@
1
+ export { WorkspacePicker } from "./workspace-picker";
2
+ export type { WorkspacePickerProps } from "./workspace-picker";
3
+ export { formatLastOpened, type Workspace } from "./workspace-picker-state";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `WorkspacePicker`'s pure, directly-testable logic — kept out of the
3
+ * component for the same reason `../model-picker/model-picker-state.ts` does
4
+ * (see that file's header comment): an expression buried in JSX cannot be
5
+ * unit-tested in isolation, and relative-time arithmetic in particular has
6
+ * edge cases worth locking down on their own.
7
+ */
8
+
9
+ /**
10
+ * One recent workspace. Deliberately a strict subset of `ModelPickerItem`'s
11
+ * shape (`id` / `name` → `label` / `path` → `description` / `lastOpenedAt` →
12
+ * a formatted `meta` entry) — see the reuse note in `workspace-picker.tsx`.
13
+ */
14
+ export interface Workspace {
15
+ id: string;
16
+ /** Rendered as the row's primary label and, when current, the trigger text. */
17
+ name: string;
18
+ /**
19
+ * The filesystem path. Long, user-supplied content — the row truncates it,
20
+ * never wraps or overflows.
21
+ */
22
+ path: string;
23
+ /**
24
+ * When this workspace was last opened. Rendered as a relative time via
25
+ * `Intl.RelativeTimeFormat` (`formatLastOpened`, below) — never a
26
+ * pre-formatted string the caller hands over.
27
+ */
28
+ lastOpenedAt?: Date;
29
+ }
30
+
31
+ /**
32
+ * "2 hours ago" / "in 3 minutes" / "yesterday" — `Intl.RelativeTimeFormat`
33
+ * picks the coarsest unit (minutes → hours → days) that still reads
34
+ * naturally, instead of e.g. "127 minutes ago".
35
+ *
36
+ * @param now - injection seam for tests; defaults to the real clock.
37
+ */
38
+ export function formatLastOpened(date: Date, locale: string, now: number = Date.now()): string {
39
+ const diffMs = date.getTime() - now;
40
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
41
+
42
+ const minutes = Math.round(diffMs / (60 * 1000));
43
+ if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute");
44
+
45
+ const hours = Math.round(diffMs / (60 * 60 * 1000));
46
+ if (Math.abs(hours) < 24) return rtf.format(hours, "hour");
47
+
48
+ const days = Math.round(diffMs / (24 * 60 * 60 * 1000));
49
+ return rtf.format(days, "day");
50
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, fn, within } from "storybook/test";
3
+ import { WorkspacePicker } from "./workspace-picker";
4
+ import type { Workspace } from "./workspace-picker-state";
5
+
6
+ const workspaces: Workspace[] = [
7
+ {
8
+ id: "ws-brand-ui",
9
+ name: "elabs-components",
10
+ path: "~/dev/elabs-components",
11
+ lastOpenedAt: new Date(Date.now() - 15 * 60 * 1000),
12
+ },
13
+ {
14
+ id: "ws-brainless",
15
+ name: "brainless",
16
+ path: "~/dev/oss/brainless",
17
+ lastOpenedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
18
+ },
19
+ {
20
+ id: "ws-monorepo",
21
+ name: "platform-monorepo",
22
+ // Long, user-supplied content — truncates instead of overflowing.
23
+ path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway",
24
+ lastOpenedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000),
25
+ },
26
+ ];
27
+
28
+ const meta = {
29
+ title: "Core/WorkspacePicker",
30
+ component: WorkspacePicker,
31
+ tags: ["autodocs"],
32
+ parameters: {
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "A recent-workspace switcher, built as a `ModelPicker` PRESET rather than a new " +
37
+ "picker (issue #111) — see `docs/decisions/2026-09-01-brainless-adoption-architecture.md` " +
38
+ "§ 7. Maps `Workspace[]` onto `ModelPicker`'s grouped rows, formats `lastOpenedAt` with " +
39
+ "`Intl.RelativeTimeFormat`, marks the current workspace in text, and renders a free-text " +
40
+ "path entry into `ModelPicker`'s `footer` slot.\n\n" +
41
+ "No `Sidebar` dependency, unlike `TeamSwitcher` — this works anywhere a picker is needed.",
42
+ },
43
+ },
44
+ },
45
+ args: { workspaces, currentId: "ws-brand-ui", onSelect: fn(), onSubmitPath: fn() },
46
+ } satisfies Meta<typeof WorkspacePicker>;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ export const Default: Story = {};
52
+
53
+ /**
54
+ * The current workspace is marked in the row's TEXT (a "Current" badge, not
55
+ * only `ModelPicker`'s check glyph) — open the popover to see it, and confirm
56
+ * it survives a greyscale render.
57
+ */
58
+ export const CurrentWorkspaceMarkedInText: Story = {
59
+ play: async ({ canvas, canvasElement, userEvent }) => {
60
+ await userEvent.click(canvas.getByRole("combobox"));
61
+ const body = within(canvasElement.ownerDocument.body);
62
+ const currentRow = await body.findByRole("option", { name: /elabs-components/i });
63
+ // The marker is part of the row's ACCESSIBLE NAME, not only a visual glyph.
64
+ await expect(currentRow).toHaveAccessibleName(/current/i);
65
+ const otherRow = body.getByRole("option", { name: /brainless/i });
66
+ await expect(otherRow).not.toHaveAccessibleName(/current/i);
67
+ },
68
+ };
69
+
70
+ /** No workspace chosen yet — the trigger falls back to a designed placeholder. */
71
+ export const NoCurrentWorkspace: Story = {
72
+ args: { currentId: undefined },
73
+ };
74
+
75
+ /**
76
+ * Nothing to show — `ModelPicker`'s own designed empty panel (reused, not
77
+ * reimplemented) plus the free-text path entry, which always renders in the
78
+ * footer regardless of body state and is this state's one action.
79
+ */
80
+ export const Empty: Story = {
81
+ args: { workspaces: [], currentId: undefined },
82
+ play: async ({ canvas, canvasElement, userEvent }) => {
83
+ await userEvent.click(canvas.getByRole("combobox"));
84
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
85
+ },
86
+ };
87
+
88
+ /** A very long, deeply nested path truncates instead of overflowing the row. */
89
+ export const LongPath: Story = {
90
+ args: {
91
+ workspaces: [
92
+ {
93
+ id: "ws-deep",
94
+ name: "api-gateway",
95
+ path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway/src/routes/internal",
96
+ },
97
+ ],
98
+ currentId: "ws-deep",
99
+ },
100
+ };
101
+
102
+ export const Disabled: Story = { args: { disabled: true } };
103
+
104
+ /**
105
+ * The free-text path entry validates non-empty and reports the raw string —
106
+ * this component performs no filesystem access of any kind (D5). Also proves
107
+ * Enter submits the path instead of being hijacked by cmdk's own list
108
+ * navigation (both bind a keydown handler on the same `Command` root).
109
+ */
110
+ export const SubmitFreeTextPath: Story = {
111
+ play: async ({ canvas, canvasElement, userEvent, args }) => {
112
+ await userEvent.click(canvas.getByRole("combobox"));
113
+ const body = within(canvasElement.ownerDocument.body);
114
+ const input = body.getByPlaceholderText(/path/i);
115
+ const submit = body.getByRole("button", { name: /open/i });
116
+
117
+ // Nothing typed yet — validated as empty, no call.
118
+ await userEvent.click(submit);
119
+ await expect(args.onSubmitPath).not.toHaveBeenCalled();
120
+
121
+ await userEvent.type(input, "~/dev/new-project{Enter}");
122
+ await expect(args.onSubmitPath).toHaveBeenCalledWith("~/dev/new-project");
123
+ // Enter submitted the path — it did not move cmdk's list highlight/select.
124
+ await expect(args.onSelect).not.toHaveBeenCalled();
125
+ },
126
+ };