@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
@@ -0,0 +1,33 @@
1
+ /**
2
+ * One verification verdict about an action — a linter, a type check, a test run,
3
+ * a policy hook. Shared vocabulary across packages: `ChangeReview` (#112) shows the
4
+ * checks a proposed edit already passed, `AgentEvent` (#109) shows the checks a
5
+ * runtime hook ran around a tool call. One runtime concept, one shape.
6
+ *
7
+ * Presentational only (D5): this type records a verdict someone else computed.
8
+ * Nothing in brand-ui ever runs a check.
9
+ *
10
+ * Rendering contract, binding on every consumer: `ok` must reach the user through
11
+ * an ICON and ACCESSIBLE TEXT, never through colour alone — the greyscale test in
12
+ * `.claude/rules/accessibility.md`.
13
+ */
14
+ export interface CheckResult {
15
+ /** What ran, verbatim as the runtime names it: "eslint", "tsc", "pre_tool_use". */
16
+ label: string;
17
+ /** Did it pass? The verdict; the only required field besides `label`. */
18
+ ok: boolean;
19
+ /** One line of explanation. Renders as a secondary line, collapsed when long. */
20
+ detail?: string;
21
+ /** Wall-clock duration in milliseconds. Format with `formatElapsed`. */
22
+ durationMs?: number;
23
+ /** When it ran relative to the action it gates. Groups results into sections. */
24
+ phase?: "before" | "after";
25
+ }
26
+
27
+ /** A count summary, when the individual verdicts are not available. */
28
+ export interface CheckSummary {
29
+ /** How many checks ran. */
30
+ ran: number;
31
+ /** How many of them passed. `passed <= ran`. */
32
+ passed: number;
33
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { diffLineAccessibleLabel, diffLineMarker, type DiffLineType } from "./diff-line";
3
+
4
+ describe("diffLineMarker", () => {
5
+ it.each([
6
+ ["add", "+"],
7
+ ["del", "−"],
8
+ ["context", " "],
9
+ ["hunk", ""],
10
+ ["meta", ""],
11
+ ] as const)("returns %s's marker as %s", (type, expected) => {
12
+ expect(diffLineMarker(type)).toBe(expected);
13
+ });
14
+
15
+ it("uses a NO-BREAK SPACE (U+00A0) for context, not a plain space — must survive JSX/HTML whitespace collapsing", () => {
16
+ expect(diffLineMarker("context")).toBe(" ");
17
+ expect(diffLineMarker("context").charCodeAt(0)).toBe(0xa0);
18
+ });
19
+ });
20
+
21
+ describe("diffLineAccessibleLabel", () => {
22
+ it("returns the added-line key for an add row", () => {
23
+ expect(diffLineAccessibleLabel("add")).toBe("ai.diffView.addedLine");
24
+ });
25
+
26
+ it("returns the removed-line key for a del row", () => {
27
+ expect(diffLineAccessibleLabel("del")).toBe("ai.diffView.removedLine");
28
+ });
29
+
30
+ it.each(["context", "hunk", "meta"] as DiffLineType[])(
31
+ "returns undefined for %s (no polarity)",
32
+ (type) => {
33
+ expect(diffLineAccessibleLabel(type)).toBeUndefined();
34
+ },
35
+ );
36
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The unified-diff line model behind `DiffView` (`@elabs-ai/components-ai`,
3
+ * #102) and the terminal CLI look-alike family's own diff hunk (issue #117).
4
+ *
5
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
6
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
7
+ * each other — `ui` is upstream of both
8
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). This
9
+ * module is the shiki-free half: the line shape, the marker glyph and the
10
+ * screen-reader polarity label. Intra-line syntax highlighting (Shiki) and
11
+ * the diff's presentational chrome stay in `@elabs-ai/components-ai`'s
12
+ * `diff-view.tsx` — `ui` does not depend on Shiki and never will for one
13
+ * component (see the decision doc § 3).
14
+ *
15
+ * The `DiffLine` model and the polarity-prefix technique are adapted from
16
+ * `theswerd/brainless` (MIT) — see `ATTRIBUTION.md`.
17
+ */
18
+
19
+ /** What role a line plays in the diff. */
20
+ export type DiffLineType = "add" | "del" | "context" | "hunk" | "meta";
21
+
22
+ /** One line of a unified diff. Absent gutter numbers are load-bearing (see below). */
23
+ export interface DiffLine {
24
+ type: DiffLineType;
25
+ /** Line number in the original file. Absent on `add` — there is no old line. */
26
+ oldNumber?: number;
27
+ /** Line number in the new file. Absent on `del` — there is no new line. */
28
+ newNumber?: number;
29
+ /** The line's text, without its leading `+` / `-` / ` ` marker. */
30
+ text: string;
31
+ }
32
+
33
+ /**
34
+ * The single-character marker glyph for a real source line (`add`/`del`/
35
+ * `context`). `hunk`/`meta` lines are diff headers, not source, and render
36
+ * through a full-width row instead — this returns `""` for them.
37
+ */
38
+ export function diffLineMarker(type: DiffLineType): string {
39
+ switch (type) {
40
+ case "add":
41
+ return "+";
42
+ case "del":
43
+ return "−";
44
+ case "context":
45
+ return " ";
46
+ default:
47
+ return "";
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The `ui` locale-message KEY (not the translated text) for the `sr-only`
53
+ * screen-reader polarity word an `add`/`del` row carries ALONGSIDE its marker
54
+ * glyph and its fill tint — colour is never the only channel (WCAG 1.4.1).
55
+ * `undefined` for `context`/`hunk`/`meta`, which carry no polarity.
56
+ */
57
+ export function diffLineAccessibleLabel(
58
+ type: DiffLineType,
59
+ ): "ai.diffView.addedLine" | "ai.diffView.removedLine" | undefined {
60
+ if (type === "add") return "ai.diffView.addedLine";
61
+ if (type === "del") return "ai.diffView.removedLine";
62
+ return undefined;
63
+ }
@@ -0,0 +1,76 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DiffLine } from "./diff-line";
4
+ import { collapseDiffRows, useDiffRows } from "./diff-rows";
5
+
6
+ function contextLine(index: number): DiffLine {
7
+ return { type: "context", oldNumber: index, newNumber: index, text: `line ${index}` };
8
+ }
9
+
10
+ describe("collapseDiffRows", () => {
11
+ it("renders every line when contextLines is undefined", () => {
12
+ const lines = Array.from({ length: 5 }, (_, i) => contextLine(i));
13
+ const rows = collapseDiffRows(lines, undefined);
14
+ expect(rows).toHaveLength(5);
15
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
16
+ });
17
+
18
+ it("does not collapse a run at or below the threshold", () => {
19
+ const lines = Array.from({ length: 4 }, (_, i) => contextLine(i));
20
+ const rows = collapseDiffRows(lines, 4);
21
+ expect(rows).toHaveLength(4);
22
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
23
+ });
24
+
25
+ it("collapses a run longer than contextLines, keeping top and bottom context", () => {
26
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
27
+ const rows = collapseDiffRows(lines, 4);
28
+ // top=2, bottom=2, collapsed hiddenCount = 10 - 2 - 2 = 6. `runStart` is the
29
+ // run's OWN start index (0 — the whole 10-line run is one context block),
30
+ // not the index of the first hidden line — it is the key `expand()` takes.
31
+ expect(rows.map((row) => row.kind)).toEqual(["line", "line", "collapsed", "line", "line"]);
32
+ const collapsed = rows.find((row) => row.kind === "collapsed");
33
+ expect(collapsed).toMatchObject({ kind: "collapsed", runStart: 0, hiddenCount: 6 });
34
+ });
35
+
36
+ it("renders a run in full when its start index is in `expanded`", () => {
37
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
38
+ const rows = collapseDiffRows(lines, 4, new Set([0]));
39
+ expect(rows).toHaveLength(10);
40
+ expect(rows.every((row) => row.kind === "line")).toBe(true);
41
+ });
42
+
43
+ it("does not collapse non-context lines flanking a run", () => {
44
+ const lines: DiffLine[] = [
45
+ { type: "add", newNumber: 1, text: "added" },
46
+ ...Array.from({ length: 8 }, (_, i) => contextLine(i)),
47
+ { type: "del", oldNumber: 99, text: "removed" },
48
+ ];
49
+ const rows = collapseDiffRows(lines, 4);
50
+ expect(rows[0]).toMatchObject({ kind: "line", index: 0 });
51
+ expect(rows.at(-1)).toMatchObject({ kind: "line", index: 9 });
52
+ expect(rows.some((row) => row.kind === "collapsed")).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe("useDiffRows", () => {
57
+ it("starts with no run expanded", () => {
58
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
59
+ const { result } = renderHook(() => useDiffRows(lines, 4));
60
+ expect(result.current.rows.some((row) => row.kind === "collapsed")).toBe(true);
61
+ });
62
+
63
+ it("expand() reveals the collapsed run's lines", () => {
64
+ const lines = Array.from({ length: 10 }, (_, i) => contextLine(i));
65
+ const { result } = renderHook(() => useDiffRows(lines, 4));
66
+
67
+ const collapsed = result.current.rows.find((row) => row.kind === "collapsed");
68
+ expect(collapsed).toBeDefined();
69
+ const runStart = (collapsed as { runStart: number }).runStart;
70
+
71
+ act(() => result.current.expand(runStart));
72
+
73
+ expect(result.current.rows.every((row) => row.kind === "line")).toBe(true);
74
+ expect(result.current.rows).toHaveLength(10);
75
+ });
76
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The context-run-collapsing hunk-windowing algorithm behind `DiffView`
3
+ * (`@elabs-ai/components-ai`, #102, where it shipped as the private
4
+ * `useVisibleRows`) and the terminal CLI look-alike family's own diff hunk
5
+ * (issue #117).
6
+ *
7
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
8
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
9
+ * each other — `ui` is upstream of both
10
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). The
11
+ * windowing algorithm is shiki-free and DOM-free; `ui` already hosts hooks
12
+ * (`use-mobile`, `use-copy-to-clipboard`), so a hook alongside a pure
13
+ * function is an ordinary shape here, not a new category.
14
+ */
15
+ import { useCallback, useMemo, useState } from "react";
16
+ import type { DiffLine } from "./diff-line";
17
+
18
+ /**
19
+ * One rendered row: either a real diff line, or a collapsed run of
20
+ * consecutive `context` lines longer than the caller's `contextLines`.
21
+ */
22
+ export type DiffRow =
23
+ | { kind: "line"; line: DiffLine; index: number }
24
+ | { kind: "collapsed"; runStart: number; hiddenCount: number };
25
+
26
+ /**
27
+ * Collapse long runs of consecutive `context` lines behind a single
28
+ * `"collapsed"` row, keeping `Math.ceil(contextLines / 2)` lines of context
29
+ * at the top of the run and `Math.floor(contextLines / 2)` at the bottom —
30
+ * so a reader always sees context on both sides of a hidden gap. A run
31
+ * already in `expanded` (by its start index) renders in full instead.
32
+ *
33
+ * Pure: no state, no effect. `useDiffRows` below is the stateful wrapper
34
+ * that owns the `expanded` set for a component.
35
+ */
36
+ export function collapseDiffRows(
37
+ lines: DiffLine[],
38
+ contextLines: number | undefined,
39
+ expanded: ReadonlySet<number> = new Set(),
40
+ ): DiffRow[] {
41
+ if (!contextLines || contextLines <= 0) {
42
+ return lines.map((line, index) => ({ kind: "line", line, index }));
43
+ }
44
+ const out: DiffRow[] = [];
45
+ let i = 0;
46
+ while (i < lines.length) {
47
+ if (lines[i]?.type !== "context") {
48
+ out.push({ kind: "line", line: lines[i]!, index: i });
49
+ i++;
50
+ continue;
51
+ }
52
+ const runStart = i;
53
+ let j = i;
54
+ while (j < lines.length && lines[j]?.type === "context") j++;
55
+ const runLength = j - runStart;
56
+ if (runLength <= contextLines || expanded.has(runStart)) {
57
+ for (let k = runStart; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
58
+ } else {
59
+ const top = Math.ceil(contextLines / 2);
60
+ const bottom = Math.floor(contextLines / 2);
61
+ for (let k = runStart; k < runStart + top; k++)
62
+ out.push({ kind: "line", line: lines[k]!, index: k });
63
+ out.push({ kind: "collapsed", runStart, hiddenCount: runLength - top - bottom });
64
+ for (let k = j - bottom; k < j; k++) out.push({ kind: "line", line: lines[k]!, index: k });
65
+ }
66
+ i = j;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Stateful wrapper around `collapseDiffRows`: owns the set of run-start
73
+ * indices a caller has expanded via the "show more" control, and returns the
74
+ * current row list alongside an `expand` action.
75
+ */
76
+ export function useDiffRows(
77
+ lines: DiffLine[],
78
+ contextLines: number | undefined,
79
+ ): { rows: DiffRow[]; expand: (runStart: number) => void } {
80
+ const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
81
+
82
+ const rows = useMemo(
83
+ () => collapseDiffRows(lines, contextLines, expanded),
84
+ [lines, contextLines, expanded],
85
+ );
86
+
87
+ const expand = useCallback((runStart: number) => {
88
+ setExpanded((prev) => new Set(prev).add(runStart));
89
+ }, []);
90
+
91
+ return { rows, expand };
92
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatElapsed } from "./format-duration";
3
+
4
+ describe("formatElapsed", () => {
5
+ it.each([
6
+ // [elapsedMs, expected]
7
+ [0, "0ms"],
8
+ [420, "420ms"],
9
+ [999, "999ms"],
10
+ [1000, "1.0s"],
11
+ [8000, "8.0s"],
12
+ [9949, "9.9s"],
13
+ [10000, "10s"],
14
+ [42000, "42s"],
15
+ [59000, "59s"],
16
+ [60000, "1m00s"],
17
+ [77000, "1m17s"],
18
+ [605000, "10m05s"],
19
+ ] as const)("formats %dms as %s", (elapsedMs, expected) => {
20
+ expect(formatElapsed(elapsedMs)).toBe(expected);
21
+ });
22
+ });
@@ -0,0 +1,30 @@
1
+ const MS_PER_SECOND = 1000;
2
+ const SECONDS_PER_MINUTE = 60;
3
+ const TEN_SECONDS = 10;
4
+
5
+ /**
6
+ * Human-readable elapsed time — one formatter for every "how long did this take"
7
+ * surface in the system: `TurnStatus`'s running turn (#105), `ChangeReview`'s check
8
+ * rows (#112), `AgentEvent`'s hook durations (#109).
9
+ *
10
+ * Exported so a consumer's own elapsed-time surfaces agree with ours.
11
+ * Adapted from `brainless`'s turn-status elapsed formatter (MIT, see `ATTRIBUTION.md`).
12
+ *
13
+ * Rungs: sub-second → "420ms" · under ten seconds → "8.0s" · under a minute → "45s" ·
14
+ * beyond → "2m05s".
15
+ */
16
+ export function formatElapsed(elapsedMs: number): string {
17
+ if (elapsedMs < MS_PER_SECOND) {
18
+ return `${Math.round(elapsedMs)}ms`;
19
+ }
20
+
21
+ const totalSeconds = elapsedMs / MS_PER_SECOND;
22
+
23
+ if (totalSeconds < SECONDS_PER_MINUTE) {
24
+ return `${totalSeconds.toFixed(totalSeconds < TEN_SECONDS ? 1 : 0)}s`;
25
+ }
26
+
27
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
28
+ const seconds = Math.round(totalSeconds % SECONDS_PER_MINUTE);
29
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
30
+ }
@@ -0,0 +1,49 @@
1
+ import { createElement } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { hasRenderableContent } from "./has-renderable-content";
4
+
5
+ describe("hasRenderableContent", () => {
6
+ it("is false for every scalar falsy value", () => {
7
+ expect(hasRenderableContent(undefined)).toBe(false);
8
+ expect(hasRenderableContent(null)).toBe(false);
9
+ expect(hasRenderableContent(false)).toBe(false);
10
+ expect(hasRenderableContent("")).toBe(false);
11
+ expect(hasRenderableContent(0)).toBe(false);
12
+ });
13
+
14
+ it("is true for a non-empty string or a truthy number", () => {
15
+ expect(hasRenderableContent("Required.")).toBe(true);
16
+ expect(hasRenderableContent(1)).toBe(true);
17
+ });
18
+
19
+ it("is false for an empty array — arrays are always truthy, which is exactly the bug this helper exists to avoid", () => {
20
+ expect(hasRenderableContent([])).toBe(false);
21
+ });
22
+
23
+ it("is false for an array of only falsy children", () => {
24
+ expect(hasRenderableContent([false, null, undefined])).toBe(false);
25
+ });
26
+
27
+ it("is false for an array of only empty strings — Children.toArray alone keeps '' and would still report content", () => {
28
+ expect(hasRenderableContent(["", ""])).toBe(false);
29
+ });
30
+
31
+ it("is true for an array containing at least one renderable child", () => {
32
+ expect(hasRenderableContent([false, "Required."])).toBe(true);
33
+ });
34
+
35
+ it("is true for a single React element", () => {
36
+ expect(hasRenderableContent(createElement("span", null, "hi"))).toBe(true);
37
+ });
38
+
39
+ // [KNOWN LIMIT] — an element naming a component that will render `null` is
40
+ // still an inert, truthy descriptor at this point; whether it renders
41
+ // anything is not decidable before React actually renders it. Documented
42
+ // limit, not a bug — see the JSDoc above `hasRenderableContent`.
43
+ it("[KNOWN LIMIT] is true for an element whose component will render null — not knowable before render", () => {
44
+ function RendersNull() {
45
+ return null;
46
+ }
47
+ expect(hasRenderableContent(createElement(RendersNull))).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,32 @@
1
+ import { Children, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Whether `node` has any renderable content. `{condition && "message"}` is
5
+ * the ordinary React idiom for optional content, and `condition === false`
6
+ * must render nothing — a `false`/`0`/`""`/`null`/`undefined` value is "no
7
+ * content", not an empty paragraph/alert. `Boolean(node)` alone is not
8
+ * enough: an array is always truthy, so `{errors.map(...)}` on an empty list
9
+ * (or `[a && "x", b && "y"]` with both false) would still count as content.
10
+ * `Children.toArray` drops `null`/`undefined`/booleans from an array but
11
+ * KEEPS `""` (and `0`), so an array like `["", ""]` would still slip through
12
+ * as "content" without the trailing `.filter(Boolean)` — filtering after
13
+ * `toArray` is what makes this match the scalar check for every array shape,
14
+ * not just the ones `toArray` already prunes.
15
+ *
16
+ * Shared by `FieldDescription`/`FieldError` (`../components/field/field.tsx`)
17
+ * and `FieldRow` (`../components/field-row/field-row.tsx`) — extracted so a
18
+ * fix to the predicate reaches every caller instead of drifting between
19
+ * hand-duplicated copies, which is exactly how `FieldRow` ended up with the
20
+ * weaker, bare-truthiness version of this check for two rounds of `Field*`
21
+ * fixes (#93).
22
+ *
23
+ * **Known limit:** a `node` that is itself a component returning `null` (or
24
+ * an empty fragment) is not knowable from the element before render — a
25
+ * React element is always truthy, whatever the component it names will
26
+ * return, so this function reports `true` for it. That limit is documented
27
+ * on `FieldDescription`/`FieldError`/`FieldRow`'s own JSDoc; it is not, and
28
+ * cannot be, fixed here.
29
+ */
30
+ export function hasRenderableContent(node: ReactNode): boolean {
31
+ return Boolean(node) && Children.toArray(node).filter(Boolean).length > 0;
32
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { effortRungForIndex } from "./operating-mode";
3
+
4
+ describe("effortRungForIndex", () => {
5
+ it("returns the smallest rung for a single-level scale", () => {
6
+ expect(effortRungForIndex(0, 1)).toBe("size-2.5");
7
+ });
8
+
9
+ it("returns the smallest rung for the first of several levels", () => {
10
+ expect(effortRungForIndex(0, 4)).toBe("size-2.5");
11
+ });
12
+
13
+ it("returns the largest rung for the last of several levels", () => {
14
+ expect(effortRungForIndex(3, 4)).toBe("size-6");
15
+ });
16
+
17
+ it("grows monotonically across the scale", () => {
18
+ const RUNGS = [
19
+ "size-2.5",
20
+ "size-3",
21
+ "size-3.5",
22
+ "size-4",
23
+ "size-4.5",
24
+ "size-5",
25
+ "size-5.5",
26
+ "size-6",
27
+ ];
28
+ const count = 5;
29
+ const indices = Array.from({ length: count }, (_, i) =>
30
+ RUNGS.indexOf(effortRungForIndex(i, count)),
31
+ );
32
+ for (let i = 1; i < indices.length; i++) {
33
+ expect(indices[i]).toBeGreaterThanOrEqual(indices[i - 1]!);
34
+ }
35
+ });
36
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The operating-mode and reasoning-effort vocabulary behind
3
+ * `PromptInputMode`/`PromptInputEffort` (`@elabs-ai/components-ai`, #104/#107)
4
+ * and the terminal CLI look-alike family's own composer chips (issue #117).
5
+ *
6
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
7
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
8
+ * each other — `ui` is upstream of both
9
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Both
10
+ * types are caller-supplied vocabulary objects — never a vendor union — which
11
+ * #117 requires: no vendor mode/effort union may appear in a public type.
12
+ */
13
+ import type { ReactNode } from "react";
14
+
15
+ /**
16
+ * One app-defined operating mode — how autonomously the agent may act while
17
+ * this mode is active. `brand-ui` ships no vocabulary: `id`/`label` are
18
+ * entirely the consumer's (e.g. `"auto"`/`"Auto"`, `"plan"`/`"Plan first"`,
19
+ * or a completely different product's own terms).
20
+ */
21
+ export interface OperatingMode {
22
+ /** Stable identifier — round-trips through `value`/`onValueChange`. */
23
+ id: string;
24
+ /** Visible + accessible name. */
25
+ label: string;
26
+ /** One line of guidance shown under the label in the menu. */
27
+ description?: string;
28
+ /** A shortcut hint rendered as a `Kbd` chip next to the label (e.g. `"⇧ Tab"`). */
29
+ keyHint?: string;
30
+ /** Leading glyph. Decorative — the mode is identified by `label`, not the icon. */
31
+ icon?: ReactNode;
32
+ }
33
+
34
+ /**
35
+ * One rung of an app-defined reasoning-effort scale. `brand-ui` ships no
36
+ * vocabulary: `id`/`label` are entirely the consumer's (e.g.
37
+ * `"low"`/`"Low"` … `"max"`/`"Max"`, or a completely different product's own
38
+ * terms and however many rungs it wants).
39
+ */
40
+ export interface EffortLevel {
41
+ /** Stable identifier — round-trips through `value`/`onValueChange`. */
42
+ id: string;
43
+ /** Visible + accessible name for this rung. */
44
+ label: string;
45
+ }
46
+
47
+ /**
48
+ * Growing-square size rungs (Tailwind's own spacing scale — `size-2.5` through
49
+ * `size-6`), indexed by ordinal position so a caller never invents a raw
50
+ * pixel value. All in ONE `tailwind-merge` class group (`size`), so
51
+ * overriding a base size class is a guaranteed, order-independent swap
52
+ * rather than a `w-*`/`h-*` vs `size-*` conflict tailwind-merge can't see.
53
+ */
54
+ const EFFORT_SIZE_RUNGS = [
55
+ "size-2.5",
56
+ "size-3",
57
+ "size-3.5",
58
+ "size-4",
59
+ "size-4.5",
60
+ "size-5",
61
+ "size-5.5",
62
+ "size-6",
63
+ ] as const;
64
+
65
+ /**
66
+ * The size class for rung `index` of `count` evenly-spaced rungs — the
67
+ * growing-square ramp `PromptInputEffort` (and the terminal composer's own
68
+ * effort chips) render, low to high.
69
+ */
70
+ export function effortRungForIndex(
71
+ index: number,
72
+ count: number,
73
+ ): (typeof EFFORT_SIZE_RUNGS)[number] {
74
+ if (count <= 1) return EFFORT_SIZE_RUNGS[0];
75
+ const position = index / (count - 1);
76
+ const rungIndex = Math.min(
77
+ EFFORT_SIZE_RUNGS.length - 1,
78
+ Math.max(0, Math.round(position * (EFFORT_SIZE_RUNGS.length - 1))),
79
+ );
80
+ return EFFORT_SIZE_RUNGS[rungIndex] ?? EFFORT_SIZE_RUNGS[0];
81
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Detect a dynamic `import()` of an OPTIONAL peer that is not installed
3
+ * (issue #33 — mermaid / xterm / Rive / media-chrome are optional peers of
4
+ * `@elabs-ai/components-ai`, reached only through its lazy boundaries:
5
+ * `_lazy-mermaid.ts`, `_interactive-terminal-xterm.ts`, `_persona-rive.tsx` and
6
+ * `_audio-player-media-chrome.tsx`).
7
+ *
8
+ * Bundlers and runtimes report this differently (`ERR_MODULE_NOT_FOUND`,
9
+ * `Cannot find module`, `Failed to resolve module specifier`, Vite/browser's
10
+ * `Failed to fetch dynamically imported module`), so match on the SHAPES
11
+ * rather than one runtime's wording. A false positive here is cheap — the
12
+ * developer is told to install a package instead of seeing a raw
13
+ * module-resolution stack trace — and a false negative is the failure mode
14
+ * this exists to avoid.
15
+ *
16
+ * **Canonical home (promoted here per
17
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 2).** `ui` is
18
+ * upstream of every layer-2 package (`ai`, `terminal`, …) in the one-way
19
+ * package graph (`tokens` → `ui`/`icons` →
20
+ * `data`/`ai`/`flow`/`maps`/`charts`/`marketing`/`editor`/`viewer`), so this is
21
+ * an ordinary downward import for all of them — unlike the sibling-to-sibling
22
+ * case (e.g. `ai` ↔ `viewer`), which the graph forbids and which is why a
23
+ * safety-critical predicate like this one must never be copy-pasted sideways
24
+ * again. `packages/ai/src/_optional-peer.ts` still exists as a private,
25
+ * underived-prefixed module for one merge window (issue #116 deletes it and
26
+ * rewires its three importers onto this one); nothing imports this copy yet.
27
+ *
28
+ * It does NOT mirror `@elabs-ai/components-viewer`'s PRIMARY mechanism: the
29
+ * viewer's adapters throw a typed `ViewerError` carrying `code`/`packages`
30
+ * fields, and only fall back to string-matching for an error that didn't
31
+ * originate as one. This module has no typed-error primary path — every lazy
32
+ * boundary that uses it classifies purely by matching the caught error's
33
+ * message shape. That difference is why a shape mismatch (a resolved module
34
+ * with the wrong exports, rather than a rejected import) can slip past this
35
+ * detector silently instead of being caught by construction — see
36
+ * `_lazy-mermaid.ts`'s `loadEngine()` guard, added for exactly that gap
37
+ * (issue #33 review).
38
+ */
39
+ const MODULE_NOT_FOUND_PATTERN =
40
+ /cannot find module|failed to resolve|dynamically imported module|module not found/i;
41
+
42
+ /** True when a raw error MESSAGE names a module the runtime could not load. */
43
+ export function isModuleNotFoundMessage(message: string): boolean {
44
+ return MODULE_NOT_FOUND_PATTERN.test(message);
45
+ }
46
+
47
+ /**
48
+ * True when `value` is (or looks like) a failed dynamic `import()` of a
49
+ * missing optional peer. Accepts anything a `.catch()` or an error boundary
50
+ * might see: an `Error` instance (checked by `code` first, then message), or
51
+ * `undefined`/other for anything that clearly is not one.
52
+ */
53
+ export function isOptionalPeerMissing(value: unknown): boolean {
54
+ if (!(value instanceof Error)) return false;
55
+ if ("code" in value && (value as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
56
+ return true;
57
+ }
58
+ return isModuleNotFoundMessage(value.message);
59
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The session-launch vocabulary behind `SessionHeader`
3
+ * (`@elabs-ai/components-ai`, #110) and the terminal CLI look-alike family's
4
+ * own launch banner (issue #117, T7 — the "Empty" and "First-run" states).
5
+ *
6
+ * Promoted here (not duplicated) because `@elabs-ai/components-ai` and
7
+ * `@elabs-ai/components-terminal` are layer-2 DAG SIBLINGS and may not import
8
+ * each other — `ui` is upstream of both
9
+ * (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Pure
10
+ * item types, no behaviour.
11
+ */
12
+ import type { ReactNode } from "react";
13
+
14
+ export interface SessionCapability {
15
+ /** Short capability name, e.g. "Web search". */
16
+ label: string;
17
+ /** One-line detail rendered under the label. */
18
+ description?: string;
19
+ /** Decorative leading glyph — a Lucide icon element. */
20
+ icon?: ReactNode;
21
+ }
22
+
23
+ export interface SessionWhatsNewItem {
24
+ /** What changed, e.g. "Faster file search". */
25
+ label: string;
26
+ /** Optional link to release notes / a changelog entry. */
27
+ href?: string;
28
+ }
29
+
30
+ export interface SessionQuickAction {
31
+ /** Visible label, e.g. "New chat". */
32
+ label: string;
33
+ /** Shortcut hint, rendered with the `Kbd` primitive (e.g. "⌘N"). */
34
+ keyHint?: string;
35
+ /** Invoked when the action is chosen. */
36
+ onSelect?: () => void;
37
+ }