cursedbelt 5.90.0 → 5.91.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 (36) hide show
  1. package/dist/react/components/DataTable/DataTable.d.ts +14 -1
  2. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  3. package/dist/react/components/DataTable/DataTable.js +18 -7
  4. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  5. package/dist/react/components/Drawer.d.ts +8 -1
  6. package/dist/react/components/Drawer.d.ts.map +1 -1
  7. package/dist/react/components/Drawer.js +2 -2
  8. package/dist/react/components/Drawer.js.map +1 -1
  9. package/dist/react/components/Page.d.ts +9 -1
  10. package/dist/react/components/Page.d.ts.map +1 -1
  11. package/dist/react/components/Page.js +3 -2
  12. package/dist/react/components/Page.js.map +1 -1
  13. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  14. package/dist/react/file-tree/FileTree.js +9 -3
  15. package/dist/react/file-tree/FileTree.js.map +1 -1
  16. package/dist/react/nav/RailPlaces.d.ts +6 -2
  17. package/dist/react/nav/RailPlaces.d.ts.map +1 -1
  18. package/dist/react/nav/RailPlaces.js +1 -1
  19. package/dist/react/nav/RailPlaces.js.map +1 -1
  20. package/dist/styles-areas/analytics.css +5 -5
  21. package/dist/styles-areas/data-table.css +5 -5
  22. package/dist/styles-utilities.css +10 -0
  23. package/package.json +1 -1
  24. package/src/react/components/DataTable/DataTable.tsx +37 -7
  25. package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
  26. package/src/react/components/Drawer.tsx +13 -0
  27. package/src/react/components/Modal.spec.tsx +14 -0
  28. package/src/react/components/Page.spec.tsx +20 -0
  29. package/src/react/components/Page.tsx +35 -14
  30. package/src/react/file-tree/FileTree.spec.tsx +14 -0
  31. package/src/react/file-tree/FileTree.tsx +9 -3
  32. package/src/react/nav/RailPlaces.spec.tsx +6 -0
  33. package/src/react/nav/RailPlaces.tsx +12 -2
  34. package/src/styles-areas/analytics.css +5 -5
  35. package/src/styles-areas/data-table.css +5 -5
  36. package/src/styles-utilities.css +10 -0
@@ -260,6 +260,19 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
260
260
  * lists must never strand the reader without column context — STANDARDS §5c).
261
261
  * Works inside any vertical scroll ancestor (`ScrollArea`). */
262
262
  stickyHeader?: boolean;
263
+ /**
264
+ * Draw the column-header row. Default `true`.
265
+ *
266
+ * `false` for a list whose columns say what they are by their content — music's tracks, where a
267
+ * title and a length need no `TITLE` / `LENGTH` over them (owner, 2026-10-23: *"remove the row
268
+ * of table headers like "TITLE" because it will save space and users will already know they are
269
+ * seeing the title and length"*). The row is collapsed to nothing rather than removed: each `<th>`
270
+ * keeps its width (a `table-fixed` table takes its column tracks from it) and its name, read by a
271
+ * screen reader only, so a cell is still announced under its column. What the row carried that a
272
+ * reader acts on moves: the select-all box to its own line above the rows (the line the phone
273
+ * cards already draw), and sorting to the caller's own sort control — a hidden header sorts nothing.
274
+ */
275
+ columnHeaders?: boolean;
263
276
  /**
264
277
  * Freeze the FIRST visible column so it stays on screen while the rest of the
265
278
  * table scrolls sideways (owner, 2026-08-15, on auth's people matrix: *"if the
@@ -890,6 +903,12 @@ const HIDE_BELOW_CLASS = {
890
903
  } as const;
891
904
 
892
905
  /** `ColDef.priority`'s collapse — {@link HIDE_BELOW_CLASS}'s, unconditional, for the same reason. */
906
+ /**
907
+ * A header cell of a table whose header row is not drawn (`columnHeaders={false}`): no height, no
908
+ * rule under it, its width kept — `table-fixed` takes the column tracks from this row — and its
909
+ * content an sr-only name.
910
+ */
911
+ const HIDDEN_HEADER_CELL = 'h-0 py-0! border-b-0! leading-none';
893
912
  const COLLAPSED_CLASS = 'w-0! min-w-0! p-0! border-0 invisible text-[length:0] [&>*]:hidden';
894
913
  const NOTHING_DROPPED: ReadonlySet<string> = new Set();
895
914
 
@@ -955,7 +974,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
955
974
  hidePagination,
956
975
  caption,
957
976
  className,
958
- stickyHeader,
977
+ stickyHeader: stickyHeaderProp,
978
+ columnHeaders = true,
959
979
  stickyFirstColumn,
960
980
  renderExpanded,
961
981
  expandedRowId,
@@ -1006,6 +1026,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
1006
1026
  columnsPersistKey,
1007
1027
  ...passThrough
1008
1028
  }: DataTableProps<TRow>) {
1029
+ // A header that is not drawn has nothing to pin (see `columnHeaders`).
1030
+ const stickyHeader = stickyHeaderProp && columnHeaders;
1009
1031
  // Vertical cell padding for the wide grid tier — the one lever the density knob
1010
1032
  // pulls (headers + body cells stay aligned by sharing it).
1011
1033
  const cellPadY = density === 'compact' ? 'py-1.5' : 'py-2.5';
@@ -1889,11 +1911,12 @@ export function DataTable<TRow extends Record<string, unknown>>({
1889
1911
  beside them: a card grid already reflows, so a second breakpoint
1890
1912
  ladder inside it would only fight the container query. ── */}
1891
1913
  {/* Select-all for the tiers with no header row to carry it: the phone
1892
- cards, and the card view at every width. */}
1893
- {selectionShown && (viewMode === 'card' || mobileCards) ? (
1914
+ cards, the card view at every width, and every tier of a table whose header row
1915
+ is not drawn (`columnHeaders`). */}
1916
+ {selectionShown && (viewMode === 'card' || mobileCards || !columnHeaders) ? (
1894
1917
  <div
1895
1918
  data-cb-table-select-all=''
1896
- className={cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden')}
1919
+ className={cn('flex items-center px-2 py-1', viewMode !== 'card' && columnHeaders && '@xl:hidden')}
1897
1920
  >
1898
1921
  {headerCheckbox('Select all')}
1899
1922
  </div>
@@ -2124,7 +2147,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
2124
2147
  });
2125
2148
  return (
2126
2149
  <>
2127
- {header}
2150
+ {columnHeaders ? header : null}
2128
2151
  <div
2129
2152
  ref={activeTier === 'list' && narrowTiers ? (bodyRef as Ref<HTMLDivElement>) : undefined}
2130
2153
  className='divide-y divide-border'
@@ -2213,6 +2236,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
2213
2236
  // rides the <th> (not the <tr>) so it pins with stickyHeader.
2214
2237
  'border-b border-border px-4 text-caption font-bold uppercase tracking-wider text-muted-foreground',
2215
2238
  cellPadY,
2239
+ !columnHeaders && HIDDEN_HEADER_CELL,
2216
2240
  align === 'right' ? 'text-right' : 'text-left',
2217
2241
  meta?.numeric && 'tabular-nums',
2218
2242
  // Under `fit` the column takes its CONTENT's width, and a truncating
@@ -2237,7 +2261,11 @@ export function DataTable<TRow extends Record<string, unknown>>({
2237
2261
  >
2238
2262
  {h.isPlaceholder
2239
2263
  ? null
2240
- : renderTemplate(h.column.columnDef.header, h.getContext())}
2264
+ : columnHeaders
2265
+ ? renderTemplate(h.column.columnDef.header, h.getContext())
2266
+ : // The name alone, for a screen reader: the header's sort button would be
2267
+ // a Tab stop nobody can see.
2268
+ <span className='sr-only'>{labelOf(h.column.id)}</span>}
2241
2269
  </th>
2242
2270
  );
2243
2271
  };
@@ -2254,12 +2282,13 @@ export function DataTable<TRow extends Record<string, unknown>>({
2254
2282
  data-cb-selection-cell=''
2255
2283
  className={cn(
2256
2284
  'w-11 border-b border-border px-0 text-center',
2285
+ !columnHeaders && HIDDEN_HEADER_CELL,
2257
2286
  stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
2258
2287
  stickyFirstColumn && 'sticky left-0 bg-card',
2259
2288
  stickyFirstColumn && (stickyHeader ? 'z-30' : 'z-20'),
2260
2289
  )}
2261
2290
  >
2262
- {headerCheckbox()}
2291
+ {columnHeaders ? headerCheckbox() : <span className='sr-only'>Select</span>}
2263
2292
  </th>
2264
2293
  ) : null}
2265
2294
  {hg.headers.filter((h) => !isLeading(h)).map(renderHeader)}
@@ -2275,6 +2304,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
2275
2304
  : kebabOnly
2276
2305
  ? 'w-11 border-b border-border px-0'
2277
2306
  : 'w-20 border-b border-border px-2',
2307
+ !columnHeaders && HIDDEN_HEADER_CELL,
2278
2308
  stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
2279
2309
  )}
2280
2310
  aria-label='Row actions'
@@ -0,0 +1,83 @@
1
+ import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { useState } from 'react';
4
+ import { DataTable, preloadDataTableParts } from './DataTable.js';
5
+ import type { ColDef } from './types.js';
6
+
7
+ // `columnHeaders={false}` — the owner, 2026-10-23, on music's tracks: *"remove the row of table
8
+ // headers like "TITLE" because it will save space"*. The row is collapsed, not deleted: its
9
+ // widths and its screen-reader names stay, and what a reader acts on in it moves.
10
+ beforeAll(preloadDataTableParts);
11
+ afterEach(cleanup);
12
+
13
+ interface Song extends Record<string, unknown> {
14
+ id: string;
15
+ title: string;
16
+ seconds: number;
17
+ }
18
+ const COLUMNS: ColDef<Song>[] = [
19
+ { key: 'title', label: 'Title', type: 'text', sortable: true },
20
+ { key: 'seconds', label: 'Length', type: 'number', sortable: true, width: 80 },
21
+ ];
22
+ const SONGS: Song[] = [
23
+ { id: 's1', title: 'Alpha', seconds: 10 },
24
+ { id: 's2', title: 'Beta', seconds: 20 },
25
+ ];
26
+
27
+ function Picking({ columnHeaders }: { columnHeaders: boolean }) {
28
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
29
+ return (
30
+ <DataTable
31
+ columnHeaders={columnHeaders}
32
+ columns={COLUMNS}
33
+ data={SONGS}
34
+ getRowId={(row) => row.id}
35
+ mobileCards={false}
36
+ onSelectionChange={setSelected}
37
+ selectedIds={selected}
38
+ stickyHeader
39
+ />
40
+ );
41
+ }
42
+
43
+ describe('DataTable columnHeaders={false}', () => {
44
+ test('🔴 no column name is DRAWN, and no sort button stands in the row', () => {
45
+ const { container } = render(<Picking columnHeaders={false} />);
46
+ const ths = [...container.querySelectorAll('thead th')];
47
+ expect(ths.length).toBe(COLUMNS.length + 1);
48
+ for (const th of ths) {
49
+ // Every cell is a zero-height cell whose only content is an sr-only name.
50
+ expect(th.className).toContain('h-0');
51
+ expect(th.className).not.toContain('sticky');
52
+ for (const child of th.children) expect(child.className).toContain('sr-only');
53
+ }
54
+ expect(container.querySelector('thead button')).toBeNull();
55
+ expect(container.querySelector('thead [role="checkbox"]')).toBeNull();
56
+ });
57
+
58
+ test('the names are still there for a screen reader, and the widths for the column tracks', () => {
59
+ const { container } = render(<Picking columnHeaders={false} />);
60
+ const names = [...container.querySelectorAll('thead th[scope="col"]')].map((th) => th.textContent);
61
+ expect(names).toEqual(['Select', 'Title', 'Length']);
62
+ const length = [...container.querySelectorAll('thead th')].find((th) => th.textContent === 'Length') as HTMLElement;
63
+ expect(length.style.width).toBe('80px');
64
+ });
65
+
66
+ test('select-all moves to its own line above the rows, and still selects', () => {
67
+ const { container } = render(<Picking columnHeaders={false} />);
68
+ const line = container.querySelector('[data-cb-table-select-all]') as HTMLElement;
69
+ expect(line).not.toBeNull();
70
+ // Shown at every width, not only under the phone breakpoint.
71
+ expect(line.className).not.toContain('@xl:hidden');
72
+ fireEvent.click(line.querySelector('[role="checkbox"]') as HTMLElement);
73
+ expect(container.querySelectorAll('tbody [role="checkbox"][aria-checked="true"]').length).toBe(SONGS.length);
74
+ });
75
+
76
+ test('the default draws the row exactly as before', () => {
77
+ const { container } = render(<Picking columnHeaders />);
78
+ expect(container.querySelector('thead button')).not.toBeNull();
79
+ expect(container.querySelector('thead [role="checkbox"]')).not.toBeNull();
80
+ expect(container.querySelector('thead th')?.className).toContain('sticky');
81
+ expect(container.querySelector('[data-cb-table-select-all]')).toBeNull();
82
+ });
83
+ });
@@ -33,6 +33,13 @@ export interface DrawerProps {
33
33
  open: boolean;
34
34
  onClose: () => void;
35
35
  title?: ReactNode;
36
+ /**
37
+ * Icon controls on the title's line, before Close — a sidebar drawer's utilities (help, theme,
38
+ * account, collapse-all), where an app with no top bar keeps them on a phone (owner,
39
+ * 2026-10-23, music: *"Put all the other header buttons in the side menu"*). One line, so the
40
+ * drawer spends no second row on them.
41
+ */
42
+ actions?: ReactNode;
36
43
  /** Width on a left/right drawer, height on a bottom drawer. */
37
44
  size?: DrawerSize;
38
45
  side?: DrawerSide;
@@ -74,6 +81,7 @@ export function Drawer({
74
81
  open,
75
82
  onClose,
76
83
  title,
84
+ actions,
77
85
  size = 'md',
78
86
  side = 'right',
79
87
  isDirty,
@@ -149,6 +157,11 @@ export function Drawer({
149
157
  ) : (
150
158
  <DialogTitle className='sr-only'>Panel</DialogTitle>
151
159
  )}
160
+ {actions ? (
161
+ <div data-cb-drawer-actions='' className='flex shrink-0 items-center gap-0.5'>
162
+ {actions}
163
+ </div>
164
+ ) : null}
152
165
  <IconButton label='Close' onClick={guardedClose}>
153
166
  <X />
154
167
  </IconButton>
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import { confirm } from '../confirm.js';
4
+ import { Button } from './Button.js';
4
5
  import { ConfirmDialog } from './ConfirmDialog.js';
5
6
  import { Drawer } from './Drawer.js';
6
7
  import { Input } from './Input.js';
@@ -336,6 +337,19 @@ describe('Drawer', () => {
336
337
  expect(panel?.className).toContain('max-h-[var(--sheet-h-lg)]');
337
338
  });
338
339
 
340
+ test('`actions` stand on the title line, before Close (a phone sidebar keeps its utilities there)', () => {
341
+ render(
342
+ <Drawer actions={<Button tooltip='Help'>Help</Button>} open onClose={() => {}} side='left' title='Music'>
343
+ x
344
+ </Drawer>,
345
+ );
346
+ const slot = document.querySelector('[data-cb-drawer-actions]') as HTMLElement;
347
+ expect(slot.textContent).toBe('Help');
348
+ const close = document.querySelector('[data-cb-drawer-handle] button[aria-label="Close"]') as HTMLElement;
349
+ expect(slot.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
350
+ expect(slot.parentElement).toBe(close.parentElement);
351
+ });
352
+
339
353
  test('left side: slides in from the left', () => {
340
354
  render(
341
355
  <Drawer open onClose={() => {}} side='left' title='Palette'>
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { Button } from './Button.js';
3
4
  import { GuideRoute, GuideSection } from './Guide.js';
4
5
  import { PageContainer, PageHeader, ReadableColumn } from './Page.js';
5
6
 
@@ -58,6 +59,25 @@ describe('PageHeader — NAV-11: one h1, a one-line subtitle', () => {
58
59
  expect(container.querySelector('[data-cb-page-subtitle] button')).toBeNull();
59
60
  });
60
61
 
62
+ test('`leading` stands before the title on its line, OUTSIDE the heading', () => {
63
+ // The phone's sidebar button in an app with no top bar (owner, 2026-10-23, music).
64
+ const { container } = render(
65
+ <PageHeader leading={<Button tooltip='Open the sidebar'>Open the sidebar</Button>} title='Queue' />,
66
+ );
67
+ const h1 = container.querySelector('h1') as HTMLElement;
68
+ expect(h1.textContent).toBe('Queue');
69
+ const slot = container.querySelector('[data-cb-page-header-leading]') as HTMLElement;
70
+ expect(slot.textContent).toBe('Open the sidebar');
71
+ expect(slot.contains(h1)).toBe(false);
72
+ // Before the title, in document order.
73
+ expect(slot.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
74
+ });
75
+
76
+ test('no `leading`, no slot', () => {
77
+ const { container } = render(<PageHeader title='Queue' />);
78
+ expect(container.querySelector('[data-cb-page-header-leading]')).toBeNull();
79
+ });
80
+
61
81
  test('🔴 a subtitle wider than its line gets an InfoHint carrying the whole text', () => {
62
82
  const proto = HTMLElement.prototype;
63
83
  const scroll = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
@@ -100,6 +100,14 @@ export function ReadableColumn({ className, ...p }: HTMLAttributes<HTMLDivElemen
100
100
 
101
101
  export interface PageHeaderProps {
102
102
  title: ReactNode;
103
+ /**
104
+ * One control BEFORE the title, on its line and outside the heading — the phone's sidebar button,
105
+ * where an app with no top bar keeps its way into the sidebar (owner, 2026-10-23, music: *"put the
106
+ * sidebar opening icon for small views to be to the left of the folder or section name … There
107
+ * will not be a top header row anymore"*; Linear's and Slack's phone layouts). Outside the `h1`,
108
+ * so the heading's name is the title alone.
109
+ */
110
+ leading?: ReactNode;
103
111
  /**
104
112
  * Inline telemetry beside the title — metric pills (`Badge`/`StatusPill`, e.g.
105
113
  * `19 apps` `1 running`), never a raw muted-text counter string that disappears
@@ -154,6 +162,7 @@ const SHORT_LANDSCAPE_TITLE = '[@media(orientation:landscape)_and_(max-height:50
154
162
  */
155
163
  export function PageHeader({
156
164
  title,
165
+ leading,
157
166
  subtitle,
158
167
  meta,
159
168
  actions,
@@ -167,6 +176,23 @@ export function PageHeader({
167
176
  }: PageHeaderProps) {
168
177
  const Heading = level === 2 ? 'h2' : 'h1';
169
178
  const inContainer = useContext(InPageContainer);
179
+ const titleBlock = (
180
+ <div className='min-w-0'>
181
+ <div className='flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1'>
182
+ <Heading
183
+ className={cn(
184
+ 'font-bold tracking-tight text-foreground',
185
+ dense ? 'text-h3' : cn('text-h1', SHORT_LANDSCAPE_TITLE),
186
+ !titleOnMobile && 'hidden sm:block',
187
+ )}
188
+ >
189
+ {title}
190
+ </Heading>
191
+ {meta && <span className='flex flex-wrap items-center gap-1.5'>{meta}</span>}
192
+ </div>
193
+ {subtitle && <PageSubtitle dense={dense}>{subtitle}</PageSubtitle>}
194
+ </div>
195
+ );
170
196
  return (
171
197
  <Split
172
198
  {...rest}
@@ -184,21 +210,16 @@ export function PageHeader({
184
210
  className,
185
211
  )}
186
212
  >
187
- <div className='min-w-0'>
188
- <div className='flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1'>
189
- <Heading
190
- className={cn(
191
- 'font-bold tracking-tight text-foreground',
192
- dense ? 'text-h3' : cn('text-h1', SHORT_LANDSCAPE_TITLE),
193
- !titleOnMobile && 'hidden sm:block',
194
- )}
195
- >
196
- {title}
197
- </Heading>
198
- {meta && <span className='flex flex-wrap items-center gap-1.5'>{meta}</span>}
213
+ {leading != null ? (
214
+ <div className='flex min-w-0 items-center gap-2'>
215
+ <span data-cb-page-header-leading='' className='flex shrink-0 items-center'>
216
+ {leading}
217
+ </span>
218
+ {titleBlock}
199
219
  </div>
200
- {subtitle && <PageSubtitle dense={dense}>{subtitle}</PageSubtitle>}
201
- </div>
220
+ ) : (
221
+ titleBlock
222
+ )}
202
223
  {(actions || guide) && (
203
224
  <div className='flex items-center gap-2'>
204
225
  {guide ? <PageGuideButton>{guide}</PageGuideButton> : null}
@@ -678,6 +678,20 @@ describe('where you are', () => {
678
678
  expect(scrolled).toEqual(['Dinos']);
679
679
  });
680
680
 
681
+ test('🔴 a revealed row lands in the MIDDLE of the tree, as VS Code lands it — not on its last line', () => {
682
+ // Owner, 2026-10-23: `nearest` parked a row revealed from below at the bottom of the sidebar.
683
+ const asked: (ScrollIntoViewOptions | boolean | undefined)[] = [];
684
+ const view = tree({ selectedId: null });
685
+ for (const row of screen.getAllByRole('treeitem') as HTMLElement[]) {
686
+ row.scrollIntoView = (options?: ScrollIntoViewOptions | boolean) => asked.push(options);
687
+ row.getBoundingClientRect = () => ({ top: 900, bottom: 920 }) as DOMRect;
688
+ }
689
+ (view.container.querySelector('.cbft-scroll') as HTMLElement).getBoundingClientRect = () =>
690
+ ({ top: 0, bottom: 400 }) as DOMRect;
691
+ view.rerender(<FileTree nodes={TREE} selectedId='dinos' onSelect={() => undefined} />);
692
+ expect(asked).toEqual([{ block: 'center', inline: 'nearest' }]);
693
+ });
694
+
681
695
  test('a row already in view does not jump', () => {
682
696
  const scrolled: string[] = [];
683
697
  const view = tree({ selectedId: null });
@@ -1462,9 +1462,15 @@ export function FileTree({
1462
1462
  */
1463
1463
  const frame = (scroll === 'host' ? hostScroller(scroller) : scroller).getBoundingClientRect();
1464
1464
  if (box.top >= frame.top && box.bottom <= frame.bottom) return;
1465
- // `nearest` on both axes: the least movement that makes the row visible, and no sideways
1466
- // travel in a list that does not scroll sideways.
1467
- row.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1465
+ // 🔴 `center`, as VS Code's explorer lands a revealed file: in the middle of the tree, with its
1466
+ // neighbors above and below it, wherever the scroll has room (the browser clamps it at either
1467
+ // end). It was `nearest`, the least movement, which parked a row revealed from below on the
1468
+ // tree's last line — owner, 2026-10-23, music's "Show in folders": *"have them show the
1469
+ // highlighted track in the middle of the screen when there is enough scroll space, instead of
1470
+ // letting them be at other places like at the bottom of the side menu. This is how vscode shows
1471
+ // files when you click between them in the file tree."* `nearest` sideways: no travel in a list
1472
+ // that does not scroll sideways.
1473
+ row.scrollIntoView({ block: 'center', inline: 'nearest' });
1468
1474
  }, [selectedId, revealOpened, revealKey]);
1469
1475
 
1470
1476
  // ── The drag ──────────────────────────────────────────────────────────────────────────
@@ -26,6 +26,12 @@ describe('RailPlaces — the top bar moved into the sidebar (owner, 2026-10-20)'
26
26
  expect(getByRole('link', { name: 'Stats · 3' }).getAttribute('href')).toBe('#/stats');
27
27
  });
28
28
 
29
+ test('no brand (a drawer titled with the app): the places alone, no brand line', () => {
30
+ const { container, getByRole } = render(<RailPlaces items={items} />);
31
+ expect(container.querySelector('[data-cb-rail-brand]')).toBeNull();
32
+ expect(getByRole('link', { name: 'Library' }).getAttribute('href')).toBe('#/');
33
+ });
34
+
29
35
  test('the current place is marked three ways (NAV-6): aria-current, the raised fill, the 3px accent edge', () => {
30
36
  const { getByRole } = render(<RailPlaces brand={brand} items={items} />);
31
37
  const library = getByRole('link', { name: 'Library' });
@@ -6,8 +6,12 @@ import { NAV_BADGE_TONE } from './navShared.js';
6
6
  import type { TopBarItem, TopBarProps } from './TopBar.js';
7
7
 
8
8
  export interface RailPlacesProps {
9
- /** The app's mark and name, linking to its root — the same shape `TopBar` takes. */
10
- brand: TopBarProps['brand'];
9
+ /**
10
+ * The app's mark and name, linking to its root — the same shape `TopBar` takes. Absent where the
11
+ * sidebar's container already names the app (a phone's drawer, titled with it, whose title line
12
+ * carries the utilities too — `Drawer`'s `actions`): then only the places are drawn.
13
+ */
14
+ brand?: TopBarProps['brand'];
11
15
  /** The app's places, in IA order — the same items a `TopBar` would have drawn. */
12
16
  items: readonly TopBarItem[];
13
17
  /**
@@ -67,6 +71,7 @@ export function RailPlaces({
67
71
  data-cb-rail-places=''
68
72
  className={cn('flex flex-none flex-col gap-1 border-b border-rule px-2 pt-2 pb-2', className)}
69
73
  >
74
+ {brand ? (
70
75
  <div className={cn('flex h-10 min-w-0 items-center gap-1', collapsed && 'justify-center')}>
71
76
  <Brand
72
77
  {...brand.linkProps}
@@ -96,6 +101,11 @@ export function RailPlaces({
96
101
  </div>
97
102
  ) : null}
98
103
  </div>
104
+ ) : trailing && !collapsed ? (
105
+ <div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex h-10 items-center justify-end gap-0.5'>
106
+ {trailing}
107
+ </div>
108
+ ) : null}
99
109
  {trailing && collapsed ? (
100
110
  <div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex flex-col items-center gap-0.5'>
101
111
  {trailing}
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 156 candidate(s).
21
+ * 159 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -33,19 +33,19 @@
33
33
  @source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
34
34
  @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis");
35
35
  @source inline("basis-full bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30");
36
- @source inline("bg-muted/50 border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col");
37
- @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
36
+ @source inline("bg-muted/50 border-accent-solid border-accent/60 border-b-0! border-field-error border-solid");
37
+ @source inline("border-t-2 col collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
38
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
39
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
40
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
41
- @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
41
+ @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
42
42
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
43
43
  @source inline("hover:border-field-ring hover:text-warning italic justify-self-end last:border-0 left-11");
44
44
  @source inline("left-2.5 list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0");
45
45
  @source inline("max-sm:min-w-24 max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48");
46
46
  @source inline("max-w-60 min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-4 min-w-52");
47
47
  @source inline("min-w-[var(--anchor-width)] min-w-max mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5");
48
- @source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3");
48
+ @source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 py-0! resize-none resize-y right-3");
49
49
  @source inline("ring-field-error/30 self self-stretch shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-7");
50
50
  @source inline("slide-in-from-top-1 table-fixed text-[length:0] text-accent-solid-foreground");
51
51
  @source inline("text-field-placeholder text-success text-xl to-transparent top-3 transition-[transform,opacity]");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 151 candidate(s).
21
+ * 154 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -33,19 +33,19 @@
33
33
  @source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
34
34
  @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis-full");
35
35
  @source inline("bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
36
- @source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col collapse");
37
- @source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
36
+ @source inline("border-accent-solid border-accent/60 border-b-0! border-field-error border-solid border-t-2 col");
37
+ @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
38
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
39
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
40
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
41
- @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
41
+ @source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
42
42
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
43
43
  @source inline("hover:border-field-ring hover:text-warning justify-self-end last:border-0 left-11 left-2.5");
44
44
  @source inline("list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0 max-sm:min-w-24");
45
45
  @source inline("max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48 max-w-60");
46
46
  @source inline("min-h-20 min-h-dvh min-w-0! min-w-24 min-w-4 min-w-52 min-w-[var(--anchor-width)] min-w-max mr-2");
47
47
  @source inline("mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9");
48
- @source inline("pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
48
+ @source inline("pt-1 py-0! resize-none resize-y right-3 ring-field-error/30 self self-stretch");
49
49
  @source inline("shadow-[1px_0_0_0_var(--color-border)] size-7 slide-in-from-top-1 table-fixed text-[length:0]");
50
50
  @source inline("text-accent-solid-foreground text-field-placeholder text-success text-xl to-transparent top-3");
51
51
  @source inline("transition-[transform,opacity] w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
@@ -656,6 +656,9 @@
656
656
  width: 100%;
657
657
  height: 100%;
658
658
  }
659
+ .h-0 {
660
+ height: 0px;
661
+ }
659
662
  .h-0\.5 {
660
663
  height: calc(var(--spacing) * 0.5);
661
664
  }
@@ -1726,6 +1729,10 @@
1726
1729
  border-bottom-style: var(--tw-border-style);
1727
1730
  border-bottom-width: 0px;
1728
1731
  }
1732
+ .border-b-0\! {
1733
+ border-bottom-style: var(--tw-border-style) !important;
1734
+ border-bottom-width: 0px !important;
1735
+ }
1729
1736
  .border-b-2 {
1730
1737
  border-bottom-style: var(--tw-border-style);
1731
1738
  border-bottom-width: 2px;
@@ -2639,6 +2646,9 @@
2639
2646
  .py-0 {
2640
2647
  padding-block: 0px;
2641
2648
  }
2649
+ .py-0\! {
2650
+ padding-block: 0px !important;
2651
+ }
2642
2652
  .py-0\.5 {
2643
2653
  padding-block: calc(var(--spacing) * 0.5);
2644
2654
  }