cursedbelt 5.59.0 → 5.63.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 (75) hide show
  1. package/dist/react/components/ColorSwatches.d.ts +33 -0
  2. package/dist/react/components/ColorSwatches.d.ts.map +1 -0
  3. package/dist/react/components/ColorSwatches.js +42 -0
  4. package/dist/react/components/ColorSwatches.js.map +1 -0
  5. package/dist/react/components/EditorPage.d.ts +1 -0
  6. package/dist/react/components/EditorPage.d.ts.map +1 -1
  7. package/dist/react/components/EditorPage.js +6 -1
  8. package/dist/react/components/EditorPage.js.map +1 -1
  9. package/dist/react/components/FormPreviewLayout.d.ts +1 -0
  10. package/dist/react/components/FormPreviewLayout.d.ts.map +1 -1
  11. package/dist/react/components/FormPreviewLayout.js +2 -1
  12. package/dist/react/components/FormPreviewLayout.js.map +1 -1
  13. package/dist/react/components/PropertyList.d.ts +17 -3
  14. package/dist/react/components/PropertyList.d.ts.map +1 -1
  15. package/dist/react/components/PropertyList.js +11 -3
  16. package/dist/react/components/PropertyList.js.map +1 -1
  17. package/dist/react/components/SwatchPicker.d.ts +37 -9
  18. package/dist/react/components/SwatchPicker.d.ts.map +1 -1
  19. package/dist/react/components/SwatchPicker.js +52 -9
  20. package/dist/react/components/SwatchPicker.js.map +1 -1
  21. package/dist/react/components/TreeView.d.ts +39 -4
  22. package/dist/react/components/TreeView.d.ts.map +1 -1
  23. package/dist/react/components/TreeView.js +54 -15
  24. package/dist/react/components/TreeView.js.map +1 -1
  25. package/dist/react/components/editorPage.css +1 -7
  26. package/dist/react/components/formKit.css +5 -6
  27. package/dist/react/components/scrollRegion.css +31 -0
  28. package/dist/react/index.d.ts +2 -1
  29. package/dist/react/index.d.ts.map +1 -1
  30. package/dist/react/index.js +1 -0
  31. package/dist/react/index.js.map +1 -1
  32. package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
  33. package/dist/react/master-detail/MasterDetail.js +1 -1
  34. package/dist/react/master-detail/MasterDetail.js.map +1 -1
  35. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  36. package/dist/styles-areas/core.css +6 -6
  37. package/dist/styles-areas/disk-usage.css +7 -7
  38. package/dist/styles-areas/fields.css +24 -22
  39. package/dist/styles-areas/master-detail.css +2 -2
  40. package/dist/styles-areas/nav.css +25 -26
  41. package/dist/styles-areas/workbench.css +2 -2
  42. package/dist/styles-utilities.css +31 -0
  43. package/dist/theme.css +23 -20
  44. package/package.json +24 -5
  45. package/scripts/guardrails-baseline.json +2 -2
  46. package/scripts/guardrailsEnforce.spec.ts +31 -0
  47. package/scripts/guardrailsEnforce.ts +37 -2
  48. package/scripts/styleAreas.ts +1 -0
  49. package/src/react/components/ColorSwatches.spec.tsx +62 -0
  50. package/src/react/components/ColorSwatches.tsx +100 -0
  51. package/src/react/components/EditorPage.spec.tsx +18 -0
  52. package/src/react/components/EditorPage.tsx +12 -2
  53. package/src/react/components/FormPreviewLayout.tsx +2 -1
  54. package/src/react/components/PropertyList.spec.tsx +33 -0
  55. package/src/react/components/PropertyList.tsx +31 -6
  56. package/src/react/components/SwatchPicker.spec.tsx +92 -0
  57. package/src/react/components/SwatchPicker.tsx +119 -33
  58. package/src/react/components/TreeView.spec.tsx +157 -0
  59. package/src/react/components/TreeView.tsx +161 -14
  60. package/src/react/components/editorPage.css +1 -7
  61. package/src/react/components/formKit.css +5 -6
  62. package/src/react/components/formKit.spec.tsx +9 -0
  63. package/src/react/components/scrollRegion.css +31 -0
  64. package/src/react/index.ts +2 -0
  65. package/src/react/master-detail/MasterDetail.spec.tsx +21 -0
  66. package/src/react/master-detail/MasterDetail.tsx +7 -3
  67. package/src/styles-areas/core.css +6 -6
  68. package/src/styles-areas/disk-usage.css +7 -7
  69. package/src/styles-areas/fields.css +24 -22
  70. package/src/styles-areas/master-detail.css +2 -2
  71. package/src/styles-areas/nav.css +25 -26
  72. package/src/styles-areas/workbench.css +2 -2
  73. package/src/styles-utilities.css +31 -0
  74. package/src/theme.css +23 -20
  75. package/src/themeDarkScopeAliases.spec.ts +30 -0
@@ -332,3 +332,160 @@ describe('TreeView — aria attributes', () => {
332
332
  expect(getByText('Alpha 1')).toBeTruthy();
333
333
  });
334
334
  });
335
+
336
+ // --- Row slots, opening and sections (forge task 2839) ---------------------------
337
+ //
338
+ // What desk's Components tree drew by hand beside this one: a trailing count and kind word, a
339
+ // second "why it matched" line, double-click/Enter to open an editor, and "Lists" / "Components"
340
+ // headings between its roots.
341
+
342
+ describe('TreeView — meta, description and row attributes', () => {
343
+ const RICH: TreeNode[] = [
344
+ {
345
+ id: 'base',
346
+ label: 'Generic item',
347
+ meta: 'base template',
348
+ rowProps: { 'data-kind': 'type', className: 'app-row' },
349
+ children: [{ id: 'svc', label: 'Service X', description: 'Notes: …the api pod…', rowProps: { 'data-kind': 'component' } }],
350
+ },
351
+ ];
352
+
353
+ test('meta trails the label inside the row, and the description is a second line under it', () => {
354
+ const { getByText } = render(<TreeView nodes={RICH} defaultExpanded={['base']} />);
355
+ const meta = getByText('base template');
356
+ expect(meta.getAttribute('data-slot')).toBe('tree-meta');
357
+ // The label comes first in the row; the meta after it.
358
+ const row = meta.parentElement as HTMLElement;
359
+ expect(row.firstChild?.textContent).not.toBe('base template');
360
+ expect(row.lastElementChild).toBe(meta);
361
+ const why = getByText('Notes: …the api pod…');
362
+ expect(why.getAttribute('data-slot')).toBe('tree-description');
363
+ expect(why.previousElementSibling?.textContent).toBe('Service X');
364
+ });
365
+
366
+ test('🔴 rowProps land on the ROW, so a hook never matches the ancestors that wrap a nested row', () => {
367
+ const { getByText, container } = render(<TreeView nodes={RICH} defaultExpanded={['base']} />);
368
+ expect(container.querySelectorAll('[data-kind="type"]')).toHaveLength(1);
369
+ expect(container.querySelector('[data-kind="type"]')?.getAttribute('role')).toBeNull();
370
+ expect(container.querySelector('[data-kind="type"]')?.textContent).not.toContain('Service X');
371
+ expect(getByText('Service X').closest('[data-kind]')?.getAttribute('data-kind')).toBe('component');
372
+ expect(container.querySelector('.app-row')?.textContent).toContain('Generic item');
373
+ });
374
+ });
375
+
376
+ describe('TreeView — onOpen', () => {
377
+ test('a double-click opens that row, and only that row — not the parent that wraps it', () => {
378
+ const onOpen = mock((_id: string) => {});
379
+ const { getByText } = render(<TreeView nodes={NODES} defaultExpanded={['a']} onOpen={onOpen} />);
380
+ fireEvent.doubleClick(getByText('Alpha 1'));
381
+ expect(onOpen.mock.calls).toEqual([['a1']]);
382
+ });
383
+
384
+ test('Enter selects and opens; Space only selects', () => {
385
+ const onOpen = mock((_id: string) => {});
386
+ const onSelectionChange = mock((_ids: string[]) => {});
387
+ const { getByText } = render(<TreeView nodes={NODES} onOpen={onOpen} onSelectionChange={onSelectionChange} />);
388
+ const beta = getByText('Beta').closest('[role="treeitem"]') as HTMLElement;
389
+ fireEvent.keyDown(beta, { key: ' ' });
390
+ expect(onSelectionChange.mock.calls).toEqual([[['b']]]);
391
+ expect(onOpen).not.toHaveBeenCalled();
392
+ fireEvent.keyDown(beta, { key: 'Enter' });
393
+ expect(onSelectionChange.mock.calls.at(-1)).toEqual([['b']]);
394
+ expect(onOpen.mock.calls).toEqual([['b']]);
395
+ });
396
+
397
+ test('a disabled row does not open', () => {
398
+ const onOpen = mock((_id: string) => {});
399
+ const { getByText } = render(<TreeView nodes={NODES} onOpen={onOpen} />);
400
+ fireEvent.doubleClick(getByText('Gamma'));
401
+ expect(onOpen).not.toHaveBeenCalled();
402
+ });
403
+ });
404
+
405
+ describe('TreeView — sections', () => {
406
+ const SECTIONS = [
407
+ { id: 'lists', label: 'Lists', nodes: [{ id: 'todo', label: 'To-do list' }] },
408
+ { id: 'components', label: 'Components', nodes: [{ id: 'base', label: 'Generic item' }, { id: 'tool', label: 'Tool' }] },
409
+ ];
410
+
411
+ test('each section is a group named by its heading, and the heading is not a row', () => {
412
+ const { getByRole, getAllByRole } = render(<TreeView sections={SECTIONS} aria-label='Library' />);
413
+ expect(getByRole('group', { name: 'Lists' })).toBeTruthy();
414
+ expect(getByRole('group', { name: 'Components' })).toBeTruthy();
415
+ expect(getAllByRole('treeitem').map((item) => item.textContent)).toEqual(['To-do list', 'Generic item', 'Tool']);
416
+ });
417
+
418
+ test('🔴 one tab stop across every section, not one per section', () => {
419
+ const { getAllByRole } = render(<TreeView sections={SECTIONS} />);
420
+ const stops = getAllByRole('treeitem').filter((item) => item.tabIndex === 0);
421
+ expect(stops.map((item) => item.textContent)).toEqual(['To-do list']);
422
+ });
423
+
424
+ test('ArrowDown steps from the last row of one section to the first of the next, over the heading', () => {
425
+ const { getByText } = render(<TreeView sections={SECTIONS} />);
426
+ const todo = getByText('To-do list').closest('[role="treeitem"]') as HTMLElement;
427
+ todo.focus();
428
+ fireEvent.keyDown(todo, { key: 'ArrowDown' });
429
+ expect(document.activeElement).toBe(getByText('Generic item').closest('[role="treeitem"]'));
430
+ });
431
+
432
+ test('a section with no label draws no heading', () => {
433
+ const { container } = render(<TreeView sections={[{ id: 'only', nodes: [{ id: 'x', label: 'X' }] }]} />);
434
+ expect(container.querySelector('[data-slot="tree-section"]')).toBeNull();
435
+ });
436
+ });
437
+
438
+ describe('TreeView — the tab stop', () => {
439
+ test('rests on the selected row until a row is focused', () => {
440
+ const { getAllByRole } = render(<TreeView nodes={NODES} selected={['b']} />);
441
+ expect(getAllByRole('treeitem').filter((item) => item.tabIndex === 0).map((item) => item.textContent)).toEqual(['Beta']);
442
+ });
443
+
444
+ test('🔴 survives the focused row being filtered away', () => {
445
+ const { getAllByRole, rerender } = render(<TreeView nodes={NODES} />);
446
+ const beta = getAllByRole('treeitem')[1] as HTMLElement;
447
+ fireEvent.focus(beta);
448
+ rerender(<TreeView nodes={NODES.filter((node) => node.id !== 'b')} />);
449
+ expect(getAllByRole('treeitem').filter((item) => item.tabIndex === 0)).toHaveLength(1);
450
+ });
451
+ });
452
+
453
+ describe('TreeView — the focus ring', () => {
454
+ // The DOM under test answers :focus-visible as the engine does; each case pins the answer so
455
+ // the spec holds whichever way this DOM would have decided.
456
+ const answering = (visible: boolean) => {
457
+ const proto = HTMLElement.prototype as unknown as { matches(selector: string): boolean };
458
+ const real = proto.matches;
459
+ proto.matches = function (this: HTMLElement, selector: string) {
460
+ return selector === ':focus-visible' ? visible : real.call(this, selector);
461
+ };
462
+ return () => {
463
+ proto.matches = real;
464
+ };
465
+ };
466
+ const rowOf = (el: HTMLElement) => el.closest('[role="treeitem"]')?.firstElementChild as HTMLElement;
467
+
468
+ test('🔴 a click selects the row and draws no ring (forge task 2839)', () => {
469
+ const restore = answering(false);
470
+ try {
471
+ const { getByText } = render(<TreeView nodes={NODES} />);
472
+ fireEvent.focus(getByText('Beta').closest('[role="treeitem"]') as HTMLElement);
473
+ fireEvent.click(getByText('Beta'));
474
+ expect(rowOf(getByText('Beta')).className).not.toContain('ring-2');
475
+ } finally {
476
+ restore();
477
+ }
478
+ });
479
+
480
+ test('keyboard focus draws it, on the focused row only — not on the parent that wraps it', () => {
481
+ const restore = answering(true);
482
+ try {
483
+ const { getByText } = render(<TreeView nodes={NODES} defaultExpanded={['a']} />);
484
+ fireEvent.focus(getByText('Alpha 1').closest('[role="treeitem"]') as HTMLElement);
485
+ expect(rowOf(getByText('Alpha 1')).className).toContain('ring-2');
486
+ expect(rowOf(getByText('Alpha')).className).not.toContain('ring-2');
487
+ } finally {
488
+ restore();
489
+ }
490
+ });
491
+ });
@@ -1,11 +1,13 @@
1
1
  import { ChevronRight, Loader2 } from 'lucide-react';
2
2
  import {
3
3
  createContext,
4
+ type HTMLAttributes,
4
5
  type KeyboardEvent,
5
6
  type ReactNode,
6
7
  useCallback,
7
8
  useContext,
8
9
  useEffect,
10
+ useId,
9
11
  useMemo,
10
12
  useRef,
11
13
  useState,
@@ -29,12 +31,39 @@ export interface TreeNode {
29
31
  /** True when children exist but haven't been fetched yet (triggers `onLoadChildren`). */
30
32
  hasChildren?: boolean;
31
33
  disabled?: boolean;
34
+ /**
35
+ * Trailing content after the label: a count, a status dot, a word or two of kind. Drawn muted
36
+ * and never truncated, so it says the same thing at any width; the label gives way first.
37
+ */
38
+ meta?: ReactNode;
39
+ /** A second, muted line under the label: why a search kept this row, a path, a summary. */
40
+ description?: ReactNode;
41
+ /**
42
+ * Attributes for the visible ROW — not the treeitem, which also wraps the node's children, so
43
+ * a hook on it would match every ancestor of the row you meant. A `data-*` an app's test or
44
+ * stylesheet keys on, a className.
45
+ */
46
+ rowProps?: HTMLAttributes<HTMLDivElement> & { [key: `data-${string}`]: string | undefined };
47
+ }
48
+
49
+ /** A run of root nodes under a heading ("Lists", "Components"). Arrow keys pass over the heading. */
50
+ export interface TreeSection {
51
+ id: string;
52
+ /** The heading. Omitted, the section draws no heading (a single section, or the first). */
53
+ label?: ReactNode;
54
+ nodes: TreeNode[];
32
55
  }
33
56
 
34
57
  export type TreeSelectionMode = 'none' | 'single' | 'multi';
35
58
 
36
59
  export interface TreeViewProps {
37
- nodes: TreeNode[];
60
+ /** The root nodes. Give `sections` instead to draw the roots in headed groups. */
61
+ nodes?: TreeNode[];
62
+ /**
63
+ * The roots in headed groups. One tree, one tab stop and one keyboard path across all of them
64
+ * — the arrows step from the last row of one section to the first of the next.
65
+ */
66
+ sections?: TreeSection[];
38
67
  /** Selection behavior (default `'single'`). */
39
68
  selectionMode?: TreeSelectionMode;
40
69
  /** Controlled selected ids. */
@@ -47,11 +76,18 @@ export interface TreeViewProps {
47
76
  onExpandedChange?: (ids: string[]) => void;
48
77
  /** Called to lazily load children for a node (they are cached after the first load). */
49
78
  onLoadChildren?: (id: string) => Promise<TreeNode[]>;
79
+ /**
80
+ * Open a node — a double-click, or Enter (which selects it first). Separate from selection:
81
+ * selecting shows a thing, opening acts on it (an editor, a document), as Finder and VS Code's
82
+ * explorer split the two. Space only selects.
83
+ */
84
+ onOpen?: (id: string) => void;
50
85
  /** Enable drag-to-reorder within each level (default `false`). */
51
86
  draggable?: boolean;
52
87
  /**
53
88
  * Called when nodes at a level are reordered.
54
- * `parentId` is the parent node's id, or `null` for the root level.
89
+ * `parentId` is the parent node's id, or `null` for the root level — with `sections`, the
90
+ * section's id, since a row is dragged only within its own section.
55
91
  */
56
92
  onReorder?: (parentId: string | null, nodes: TreeNode[]) => void;
57
93
  className?: string;
@@ -67,6 +103,10 @@ interface TreeCtx {
67
103
  loading: Set<string>;
68
104
  childrenMap: Map<string, TreeNode[]>;
69
105
  focusedId: string | null;
106
+ /** Whether the focused row shows its ring: only when the browser calls that focus :focus-visible. */
107
+ ringVisible: boolean;
108
+ /** The one row with tabIndex 0: the focused row while it is visible, else the first selected, else the first. */
109
+ tabStop: string | null;
70
110
  selectionMode: TreeSelectionMode;
71
111
  draggable: boolean;
72
112
  /** Flat ordered list of currently-visible node ids (for keyboard navigation). */
@@ -78,8 +118,10 @@ interface TreeCtx {
78
118
  setExpanded: (id: string, open: boolean) => void;
79
119
  setSelected: (id: string, mod: { ctrl: boolean; shift: boolean }) => void;
80
120
  setFocused: (id: string | null) => void;
121
+ setRingVisible: (visible: boolean) => void;
81
122
  triggerLoad: (id: string) => void;
82
123
  handleReorder: (parentId: string | null, nodes: TreeNode[]) => void;
124
+ open: ((id: string) => void) | undefined;
83
125
  }
84
126
 
85
127
  const TreeContext = createContext<TreeCtx>(null!);
@@ -121,14 +163,26 @@ function buildParentMap(
121
163
  return map;
122
164
  }
123
165
 
166
+ /** `:focus-visible`, where the engine knows it; a DOM without it keeps the ring (the old behaviour). */
167
+ function isFocusVisible(el: HTMLElement): boolean {
168
+ try {
169
+ return el.matches(':focus-visible');
170
+ } catch {
171
+ return true;
172
+ }
173
+ }
174
+
124
175
  function isExpandable(node: TreeNode, childrenMap: Map<string, TreeNode[]>): boolean {
125
176
  return Boolean(node.hasChildren || node.children?.length || childrenMap.has(node.id));
126
177
  }
127
178
 
128
179
  // --- Main component -------------------------------------------------------------
129
180
 
181
+ const NO_NODES: TreeNode[] = [];
182
+
130
183
  export function TreeView({
131
- nodes,
184
+ nodes: rootNodes,
185
+ sections,
132
186
  selectionMode = 'single',
133
187
  selected: controlledSelected,
134
188
  defaultSelected,
@@ -137,12 +191,20 @@ export function TreeView({
137
191
  defaultExpanded,
138
192
  onExpandedChange,
139
193
  onLoadChildren,
194
+ onOpen,
140
195
  draggable = false,
141
196
  onReorder,
142
197
  className,
143
198
  'aria-label': ariaLabel,
144
199
  'aria-labelledby': ariaLabelledBy,
145
200
  }: TreeViewProps) {
201
+ // Every root, in order, whichever way they were given: keyboard paths and parents cross sections.
202
+ const nodes = useMemo(
203
+ () => (sections ? sections.flatMap((section) => section.nodes) : (rootNodes ?? NO_NODES)),
204
+ [sections, rootNodes],
205
+ );
206
+ const headingId = useId();
207
+
146
208
  // Expanded state (controlled or internal)
147
209
  const expandedIsControlled = controlledExpanded !== undefined;
148
210
  const [expandedInternal, setExpandedInternal] = useState<Set<string>>(
@@ -173,6 +235,7 @@ export function TreeView({
173
235
  selectedRef.current = selected;
174
236
 
175
237
  const [focusedId, setFocused] = useState<string | null>(null);
238
+ const [ringVisible, setRingVisible] = useState(false);
176
239
  const [loading, setLoading] = useState<Set<string>>(new Set());
177
240
  const [childrenMap, setChildrenMap] = useState<Map<string, TreeNode[]>>(new Map());
178
241
 
@@ -193,6 +256,13 @@ export function TreeView({
193
256
 
194
257
  const parentMap = useMemo(() => buildParentMap(nodes, childrenMap), [nodes, childrenMap]);
195
258
 
259
+ // 🔴 Computed from what is VISIBLE, not "the first root": with sections every section's first
260
+ // row was a tab stop, and a focused row that a filter then hid left the tree with none at all.
261
+ const tabStop =
262
+ focusedId !== null && visibleIds.includes(focusedId)
263
+ ? focusedId
264
+ : (visibleIds.find((id) => selected.has(id)) ?? visibleIds[0] ?? null);
265
+
196
266
  const triggerLoad = useCallback(
197
267
  (id: string) => {
198
268
  if (!onLoadChildren || loadingRef.current.has(id) || childrenMapRef.current.has(id)) return;
@@ -265,6 +335,8 @@ export function TreeView({
265
335
  loading,
266
336
  childrenMap,
267
337
  focusedId,
338
+ ringVisible,
339
+ tabStop,
268
340
  selectionMode,
269
341
  draggable,
270
342
  visibleIds,
@@ -273,8 +345,10 @@ export function TreeView({
273
345
  setExpanded,
274
346
  setSelected,
275
347
  setFocused,
348
+ setRingVisible,
276
349
  triggerLoad,
277
350
  handleReorder,
351
+ open: onOpen,
278
352
  }),
279
353
  [
280
354
  expanded,
@@ -282,6 +356,8 @@ export function TreeView({
282
356
  loading,
283
357
  childrenMap,
284
358
  focusedId,
359
+ ringVisible,
360
+ tabStop,
285
361
  selectionMode,
286
362
  draggable,
287
363
  visibleIds,
@@ -291,6 +367,7 @@ export function TreeView({
291
367
  setSelected,
292
368
  triggerLoad,
293
369
  handleReorder,
370
+ onOpen,
294
371
  ],
295
372
  );
296
373
 
@@ -303,7 +380,31 @@ export function TreeView({
303
380
  aria-multiselectable={selectionMode === 'multi' || undefined}
304
381
  className={cn('select-none', className)}
305
382
  >
306
- <TreeLevel nodes={nodes} parentId={null} level={1} />
383
+ {sections ? (
384
+ sections.map((section) => (
385
+ // biome-ignore lint/a11y/useSemanticElements: an ARIA tree's grouping is role="group"; a <fieldset> is a form control group.
386
+ <div
387
+ key={section.id}
388
+ role='group'
389
+ aria-labelledby={section.label != null ? `${headingId}-${section.id}` : undefined}
390
+ >
391
+ {section.label != null && (
392
+ // aria-hidden: the group is named BY it, so it is not read a second time as a row.
393
+ <div
394
+ id={`${headingId}-${section.id}`}
395
+ aria-hidden='true'
396
+ data-slot='tree-section'
397
+ className='px-2 pt-3 pb-1 text-xs font-medium text-muted-foreground first:pt-1'
398
+ >
399
+ {section.label}
400
+ </div>
401
+ )}
402
+ <TreeLevel nodes={section.nodes} parentId={null} reorderId={section.id} level={1} />
403
+ </div>
404
+ ))
405
+ ) : (
406
+ <TreeLevel nodes={nodes} parentId={null} reorderId={null} level={1} />
407
+ )}
307
408
  </div>
308
409
  </TreeContext.Provider>
309
410
  );
@@ -314,10 +415,13 @@ export function TreeView({
314
415
  function TreeLevel({
315
416
  nodes,
316
417
  parentId,
418
+ reorderId,
317
419
  level,
318
420
  }: {
319
421
  nodes: TreeNode[];
320
422
  parentId: string | null;
423
+ /** What `onReorder` is told the level is: the parent's id, or a section's at the root. */
424
+ reorderId: string | null;
321
425
  level: number;
322
426
  }) {
323
427
  const { draggable, handleReorder } = useContext(TreeContext);
@@ -325,7 +429,7 @@ function TreeLevel({
325
429
  const { DragContext, Sortable, items } = useDragReorder({
326
430
  items: nodes,
327
431
  getKey: (n) => n.id,
328
- onReorder: (next) => handleReorder(parentId, next),
432
+ onReorder: (next) => handleReorder(reorderId, next),
329
433
  disabled: !draggable,
330
434
  handle: true,
331
435
  });
@@ -375,6 +479,8 @@ function TreeItem({
375
479
  loading,
376
480
  childrenMap,
377
481
  focusedId,
482
+ ringVisible,
483
+ tabStop,
378
484
  selectionMode,
379
485
  draggable,
380
486
  visibleIds,
@@ -383,7 +489,9 @@ function TreeItem({
383
489
  setExpanded,
384
490
  setSelected,
385
491
  setFocused,
492
+ setRingVisible,
386
493
  triggerLoad,
494
+ open,
387
495
  } = useContext(TreeContext);
388
496
 
389
497
  const isExpanded = expanded.has(node.id);
@@ -413,9 +521,7 @@ function TreeItem({
413
521
  }
414
522
  }, [isFocused]);
415
523
 
416
- // First item at the root gets tabIndex=0 before any interaction.
417
- const isDefaultFocusable = focusedId === null && posInSet === 1 && level === 1;
418
- const tabIndex = isFocused || isDefaultFocusable ? 0 : -1;
524
+ const tabIndex = tabStop === node.id ? 0 : -1;
419
525
 
420
526
  const handleExpand = useCallback(
421
527
  (e: React.MouseEvent) => {
@@ -527,6 +633,7 @@ function TreeItem({
527
633
  ctrl: selectionMode === 'multi',
528
634
  shift: false,
529
635
  });
636
+ if (e.key === 'Enter') open?.(node.id);
530
637
  break;
531
638
  }
532
639
  }
@@ -545,9 +652,19 @@ function TreeItem({
545
652
  setExpanded,
546
653
  setSelected,
547
654
  triggerLoad,
655
+ open,
548
656
  ],
549
657
  );
550
658
 
659
+ const handleDoubleClick = useCallback(
660
+ (e: React.MouseEvent) => {
661
+ // Stopped for the same reason as a click: an ancestor's treeitem would open too.
662
+ e.stopPropagation();
663
+ if (!node.disabled) open?.(node.id);
664
+ },
665
+ [node, open],
666
+ );
667
+
551
668
  return (
552
669
  <div
553
670
  ref={mergeRef}
@@ -560,10 +677,17 @@ function TreeItem({
560
677
  aria-disabled={node.disabled || undefined}
561
678
  tabIndex={tabIndex}
562
679
  onClick={handleClick}
680
+ onDoubleClick={open ? handleDoubleClick : undefined}
563
681
  onKeyDown={handleKeyDown}
564
682
  // Only the row itself: focus bubbles too, and a nested row's focus named its parent focused.
565
683
  onFocus={(e) => {
566
- if (e.target === e.currentTarget) setFocused(node.id);
684
+ if (e.target !== e.currentTarget) return;
685
+ setFocused(node.id);
686
+ // 🔴 The ring is for the KEYBOARD. Drawn on every focus, a click left a 2px ring around
687
+ // the row it selected (desk's Components tree, forge task 2839). The browser already
688
+ // decides which focus is visible — Tab and the arrows' programmatic focus are, a click
689
+ // is not — so ask it, rather than a CSS variant that put +453 B in every consumer.
690
+ setRingVisible(isFocusVisible(e.currentTarget));
567
691
  }}
568
692
  style={dragApi.style}
569
693
  // A disabled row reads in muted ink, not at half opacity: opacity-50 dragged its text
@@ -572,6 +696,7 @@ function TreeItem({
572
696
  >
573
697
  {/* Row */}
574
698
  <div
699
+ {...node.rowProps}
575
700
  className={cn(
576
701
  // 32px rows for a mouse, as VS Code's explorer and Finder's list run; a touch screen
577
702
  // still gets the 44px target (LAY-13). Every row was 44px everywhere (forge task 2594).
@@ -581,9 +706,11 @@ function TreeItem({
581
706
  // text-accent-strong (cbux-101) — plain text-accent only clears 4.43:1 on this tint.
582
707
  isSelected && 'bg-accent/10 text-accent-strong font-medium',
583
708
  dragApi.isDragging && 'opacity-50',
584
- isFocused && 'ring-2 ring-inset ring-ring',
709
+ isFocused && ringVisible && 'ring-2 ring-inset ring-ring',
710
+ node.description != null && 'py-1',
711
+ node.rowProps?.className,
585
712
  )}
586
- style={{ paddingLeft: `calc(${(level - 1) * INDENT_PX}px + 0.25rem)` }}
713
+ style={{ ...node.rowProps?.style, paddingLeft: `calc(${(level - 1) * INDENT_PX}px + 0.25rem)` }}
587
714
  >
588
715
  {/* Drag handle — visible on hover, always present when drag enabled */}
589
716
  {draggable && (
@@ -622,15 +749,35 @@ function TreeItem({
622
749
  </span>
623
750
  )}
624
751
 
625
- {/* Label */}
626
- <span className='min-w-0 flex-1 truncate'>{node.label}</span>
752
+ {/* Label, and the line under it */}
753
+ {node.description != null ? (
754
+ <span className='flex min-w-0 flex-1 flex-col'>
755
+ <span data-slot='tree-label' className='truncate'>
756
+ {node.label}
757
+ </span>
758
+ <span data-slot='tree-description' className='truncate text-xs font-normal text-muted-foreground'>
759
+ {node.description}
760
+ </span>
761
+ </span>
762
+ ) : (
763
+ <span data-slot='tree-label' className='min-w-0 flex-1 truncate'>
764
+ {node.label}
765
+ </span>
766
+ )}
767
+
768
+ {/* Trailing meta — after the label, which gives way first */}
769
+ {node.meta != null && (
770
+ <span data-slot='tree-meta' className='flex shrink-0 items-center gap-1.5 text-xs font-normal text-muted-foreground'>
771
+ {node.meta}
772
+ </span>
773
+ )}
627
774
  </div>
628
775
 
629
776
  {/* Children (only rendered when expanded and children are available) */}
630
777
  {expandable && isExpanded && children.length > 0 && (
631
778
  // biome-ignore lint/a11y/useSemanticElements: ARIA tree spec requires role="group" on the children container; <fieldset> is semantically wrong here.
632
779
  <div role='group'>
633
- <TreeLevel nodes={children} parentId={node.id} level={level + 1} />
780
+ <TreeLevel nodes={children} parentId={node.id} reorderId={node.id} level={level + 1} />
634
781
  </div>
635
782
  )}
636
783
  </div>
@@ -124,13 +124,7 @@
124
124
  box-sizing: border-box;
125
125
  min-width: 0;
126
126
  min-height: 0;
127
- /* guardrails-ignore no-raw-overflow: task 2827 — each side scrolls on its own in a Tailwind-free host, where ScrollArea cannot draw; the keyboard inset below is ScrollArea's. */
128
- overflow: auto;
129
- overscroll-behavior: contain;
130
- /* ScrollArea's keyboard clearance (a no-op 0px on desktop): a focused field is never
131
- parked under a phone's keyboard, and the last one can be scrolled above it. */
132
- scroll-padding-bottom: calc(var(--keyboard-inset, 0px) + 1rem);
133
- padding-bottom: var(--keyboard-inset, 0px);
127
+ /* Each side scrolls on its own: `.cbfk-scroll` (scrollRegion.css) on the element. */
134
128
  }
135
129
 
136
130
  .cbfk-page-form {
@@ -470,11 +470,8 @@
470
470
  .cbfk-split-body {
471
471
  max-height: 45dvh;
472
472
  margin-top: 0.5rem;
473
- /* A card taller than 45dvh scrolls inside the pinned bar rather than covering the form.
474
- guardrails-ignore no-raw-overflow: plain CSS for a no-Tailwind host — ScrollArea is a
475
- React component and this is one capped box inside a sticky bar, with no keyboard to clear. */
476
- overflow: auto;
477
- overscroll-behavior: contain;
473
+ /* A card taller than 45dvh scrolls inside the pinned bar (`.cbfk-scroll`, scrollRegion.css)
474
+ rather than covering the form. */
478
475
  }
479
476
 
480
477
  .cbfk-split[data-preview-open="false"] .cbfk-split-body {
@@ -533,7 +530,9 @@
533
530
  grid-column: 1 / -1;
534
531
  }
535
532
 
536
- .cbfk-split-body,
533
+ /* `.cbfk-split` in front so this out-ranks `.cbfk-scroll` (scrollRegion.css), which loads
534
+ after this sheet: wide, the preview is not a scroller. */
535
+ .cbfk-split .cbfk-split-body,
537
536
  .cbfk-split[data-preview-open="false"] .cbfk-split-body {
538
537
  display: block;
539
538
  max-height: none;
@@ -135,4 +135,13 @@ describe('KeyBadge / SettingsStrip / FormPreviewLayout', () => {
135
135
  expect(getByRole('region', { name: 'Preview' })).not.toBeNull();
136
136
  expect(container.querySelector('.cbfk-split-below ol')).not.toBeNull();
137
137
  });
138
+
139
+ test('FormPreviewLayout: the pinned preview scrolls through the form kit’s scroller (forge task 2827)', () => {
140
+ const { container } = render(
141
+ <FormPreviewLayout preview={<p>card</p>} previewLabel='Preview' previewSummary={<span>DY</span>}>
142
+ <Input aria-label='Name' />
143
+ </FormPreviewLayout>,
144
+ );
145
+ expect(container.querySelector('.cbfk-split-body')?.classList.contains('cbfk-scroll')).toBe(true);
146
+ });
138
147
  });
@@ -0,0 +1,31 @@
1
+ /*
2
+ * The form kit's scroll region — `.cbfk-scroll`. Its own file, imported only by the pieces that
3
+ * scroll (`EditorPage`, `FormPreviewLayout`), because `formKit.css` lands in every view that
4
+ * draws one KeyBadge (see formKitStylesheets.spec.ts): in formKit.css it cost station's
5
+ * DuplicatesView and music's TubeView +0.06 kB gzip each for a box neither draws.
6
+ *
7
+ * Inside `@layer base`, for formKit.css's reason: a Tailwind app's utilities must still win.
8
+ */
9
+
10
+ @layer base {
11
+ /* `ScrollArea`, drawn from plain CSS for a host with no Tailwind (forge task 2827). A form-kit
12
+ box that scrolls takes this class and `useCaretInView`'s `onFocusCapture`, and never writes
13
+ `overflow` itself — `no-raw-overflow` accepts this one rule, and only while it carries the
14
+ keyboard clearance below. `--keyboard-inset` is `0px` on a desktop, so it is a no-op there.
15
+ - `scroll-padding-bottom` keeps the browser's own "scroll the focused field into view" from
16
+ parking a field under a phone's keyboard;
17
+ - the `::after` spacer lifts the last row above the keyboard. It is ScrollArea's trailing
18
+ spacer: ADDED to the box's own padding, so a box's `padding` shorthand cannot undo it;
19
+ - `overscroll-behavior: contain` keeps pull-to-refresh from bleeding through. */
20
+ .cbfk-scroll {
21
+ overflow: auto;
22
+ overscroll-behavior: contain;
23
+ scroll-padding-bottom: calc(var(--keyboard-inset, 0px) + 1rem);
24
+ }
25
+
26
+ .cbfk-scroll::after {
27
+ content: "";
28
+ display: block;
29
+ height: var(--keyboard-inset, 0px);
30
+ }
31
+ }
@@ -170,6 +170,7 @@ export { CodeArea, type CodeAreaProps } from './components/CodeArea.js';
170
170
  export { CodeFieldInput, type CodeFieldInputProps } from './components/CodeFieldInput.js';
171
171
  export { Collapsible, type CollapsibleProps } from './components/Collapsible.js';
172
172
  export { ColorField, type ColorFieldProps } from './components/ColorField.js';
173
+ export { ColorSwatches, type ColorSwatchesProps } from './components/ColorSwatches.js';
173
174
  export {
174
175
  ComboboxField,
175
176
  type ComboboxFieldMultiProps,
@@ -679,6 +680,7 @@ export {
679
680
  } from './components/Tooltip.js';
680
681
  export {
681
682
  type TreeNode,
683
+ type TreeSection,
682
684
  type TreeSelectionMode,
683
685
  TreeView,
684
686
  type TreeViewProps,
@@ -59,3 +59,24 @@ describe('MasterDetail — DESIGN.md §2 empty states', () => {
59
59
  expect(getByText('Nothing matches.')).toBeTruthy();
60
60
  });
61
61
  });
62
+
63
+ describe('MasterDetail — a row keeps its title before its subtitle', () => {
64
+ test('the subtitle shrinks first, and the title can still truncate once it is gone', () => {
65
+ // 🔴 Seen on station's queue at 390 (forge task 2849): title and file name shrank equally,
66
+ // so every title read "A session t…" beside its whole file name. happy-dom draws no layout,
67
+ // so this pins the two classes that decide the split.
68
+ const { getByText } = render(
69
+ <MasterDetail
70
+ items={[{ id: 'q', title: 'A session task', subtitle: '102-a-session-task.md' }]}
71
+ selectedId={null}
72
+ onSelect={() => {}}
73
+ detail={<p>detail</p>}
74
+ />,
75
+ );
76
+ const subtitle = getByText('102-a-session-task.md');
77
+ expect(subtitle.getAttribute('data-slot')).toBe('master-detail-subtitle');
78
+ expect(subtitle.className).toContain('shrink-[1000]');
79
+ expect(subtitle.className).toContain('min-w-0');
80
+ expect(getByText('A session task').className).toContain('min-w-0');
81
+ });
82
+ });