cursedbelt 5.89.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 (73) hide show
  1. package/dist/react/LazyToaster.d.ts +3 -0
  2. package/dist/react/LazyToaster.d.ts.map +1 -0
  3. package/dist/react/LazyToaster.js +21 -0
  4. package/dist/react/LazyToaster.js.map +1 -0
  5. package/dist/react/components/DataTable/DataTable.d.ts +14 -1
  6. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/DataTable.js +18 -7
  8. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  9. package/dist/react/components/Drawer.d.ts +8 -1
  10. package/dist/react/components/Drawer.d.ts.map +1 -1
  11. package/dist/react/components/Drawer.js +2 -2
  12. package/dist/react/components/Drawer.js.map +1 -1
  13. package/dist/react/components/Page.d.ts +9 -1
  14. package/dist/react/components/Page.d.ts.map +1 -1
  15. package/dist/react/components/Page.js +3 -2
  16. package/dist/react/components/Page.js.map +1 -1
  17. package/dist/react/createApiHooks.d.ts +30 -0
  18. package/dist/react/createApiHooks.d.ts.map +1 -1
  19. package/dist/react/createApiHooks.js +31 -2
  20. package/dist/react/createApiHooks.js.map +1 -1
  21. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  22. package/dist/react/file-tree/FileTree.js +9 -3
  23. package/dist/react/file-tree/FileTree.js.map +1 -1
  24. package/dist/react/index.d.ts +2 -0
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +2 -0
  27. package/dist/react/index.js.map +1 -1
  28. package/dist/react/nav/RailPlaces.d.ts +6 -2
  29. package/dist/react/nav/RailPlaces.d.ts.map +1 -1
  30. package/dist/react/nav/RailPlaces.js +1 -1
  31. package/dist/react/nav/RailPlaces.js.map +1 -1
  32. package/dist/react/optimistic/index.d.ts +10 -0
  33. package/dist/react/optimistic/index.d.ts.map +1 -0
  34. package/dist/react/optimistic/index.js +22 -0
  35. package/dist/react/optimistic/index.js.map +1 -0
  36. package/dist/react/optimistic/optimistic.d.ts +159 -0
  37. package/dist/react/optimistic/optimistic.d.ts.map +1 -0
  38. package/dist/react/optimistic/optimistic.js +216 -0
  39. package/dist/react/optimistic/optimistic.js.map +1 -0
  40. package/dist/react/optimistic/revert.d.ts +47 -0
  41. package/dist/react/optimistic/revert.d.ts.map +1 -0
  42. package/dist/react/optimistic/revert.js +235 -0
  43. package/dist/react/optimistic/revert.js.map +1 -0
  44. package/dist/styles-areas/analytics.css +5 -5
  45. package/dist/styles-areas/data-table.css +5 -5
  46. package/dist/styles-utilities.css +10 -0
  47. package/package.json +1 -1
  48. package/scripts/styleAreas.ts +3 -0
  49. package/src/react/LazyToaster.spec.tsx +18 -0
  50. package/src/react/LazyToaster.tsx +26 -0
  51. package/src/react/components/DataTable/DataTable.tsx +37 -7
  52. package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
  53. package/src/react/components/Drawer.tsx +13 -0
  54. package/src/react/components/Modal.spec.tsx +14 -0
  55. package/src/react/components/Page.spec.tsx +20 -0
  56. package/src/react/components/Page.tsx +35 -14
  57. package/src/react/createApiHooks.spec.tsx +77 -0
  58. package/src/react/createApiHooks.ts +58 -1
  59. package/src/react/file-tree/FileTree.spec.tsx +14 -0
  60. package/src/react/file-tree/FileTree.tsx +9 -3
  61. package/src/react/index.ts +2 -0
  62. package/src/react/nav/RailPlaces.spec.tsx +6 -0
  63. package/src/react/nav/RailPlaces.tsx +12 -2
  64. package/src/react/optimistic/index.ts +36 -0
  65. package/src/react/optimistic/optimistic.spec.tsx +225 -0
  66. package/src/react/optimistic/optimistic.ts +326 -0
  67. package/src/react/optimistic/queryTarget.spec.ts +64 -0
  68. package/src/react/optimistic/revert.spec.ts +114 -0
  69. package/src/react/optimistic/revert.ts +238 -0
  70. package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
  71. package/src/styles-areas/analytics.css +5 -5
  72. package/src/styles-areas/data-table.css +5 -5
  73. package/src/styles-utilities.css +10 -0
@@ -122,11 +122,14 @@ export const AREAS: readonly Area[] = [
122
122
  'authSignal.ts',
123
123
  'sessionAuth.ts',
124
124
  'createApiHooks.ts',
125
+ // Draws nothing; `createApiHooks` imports it, and every app's changes go through it (STA-10).
126
+ 'optimistic',
125
127
  'usePagedList.ts',
126
128
  'lazy',
127
129
  'confirm.tsx',
128
130
  'prompt.tsx',
129
131
  'Toast.tsx',
132
+ 'LazyToaster.tsx',
130
133
  'colorScheme',
131
134
  // The look store and its picker: the sibling of colorScheme, installed at boot beside it.
132
135
  'look',
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { act, render } from '@testing-library/react';
3
+ import { LazyToaster } from './LazyToaster.js';
4
+ import { toast } from './Toast.js';
5
+
6
+ describe('LazyToaster', () => {
7
+ test('mounts the stack once its chunk lands, and a toast shows in it', async () => {
8
+ const { container } = render(<LazyToaster />);
9
+ await act(async () => {
10
+ await new Promise((resolve) => setTimeout(resolve, 20));
11
+ });
12
+ await act(async () => {
13
+ toast('Saved');
14
+ await new Promise((resolve) => setTimeout(resolve, 20));
15
+ });
16
+ expect(container.ownerDocument.body.textContent).toContain('Saved');
17
+ });
18
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `<LazyToaster />` — the toast stack, mounted without putting sonner on the entry chunk.
3
+ *
4
+ * Rollup colors a module into the chunk of its static importers, so an app that mounts `Toaster`
5
+ * from its root pays for the whole stack (~15 kB gzip, measured in family, forge task 2900) on
6
+ * first paint, however lazily its toasts are CALLED (`Button`, `optimistic()`). family solved it
7
+ * by hand with a lazy wrapper of its own; flix, roms and station mounted `Toaster` statically the
8
+ * day they first needed a toast (forge task 3583) and their entries grew by it. This is that
9
+ * wrapper, once: the stack's chunk loads after first paint, and a toast raised before it lands
10
+ * waits for it (sonner queues calls made before its Toaster mounts).
11
+ *
12
+ * Takes `Toaster`'s props. Mount it once, at the app's root, in place of `Toaster`.
13
+ */
14
+ import { Suspense } from 'react';
15
+ import { lazyWithReload } from './lazy/staleChunk.js';
16
+ import type { ToasterProps } from './Toast.js';
17
+
18
+ const Stack = lazyWithReload(() => import('./Toast.js').then((m) => ({ default: m.Toaster })));
19
+
20
+ export function LazyToaster(props: ToasterProps) {
21
+ return (
22
+ <Suspense fallback={null}>
23
+ <Stack {...props} />
24
+ </Suspense>
25
+ );
26
+ }
@@ -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}
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { act, renderHook } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { createApiHooks, OptimisticFailure } from './createApiHooks.js';
6
+ import { toast } from './Toast.js';
7
+
8
+ type Rom = { id: string; favorite: boolean };
9
+
10
+ let toastError: Mock<typeof toast.error>;
11
+ beforeEach(() => {
12
+ toastError = spyOn(toast, 'error').mockImplementation(() => 0);
13
+ });
14
+ afterEach(() => toastError.mockRestore());
15
+
16
+ const setup = (request: (rom: Rom) => Promise<void>) => {
17
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
18
+ queryClient.setQueryData<Rom[]>(['roms'], [{ id: 'r1', favorite: false }]);
19
+ const appToaster = { success: () => undefined, error: () => undefined };
20
+ const errorSpy = spyOn(appToaster, 'error');
21
+ const { useApiMutation } = createApiHooks({ useClient: () => null, useToaster: () => appToaster });
22
+ const wrapper = ({ children }: { children: ReactNode }) => (
23
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
24
+ );
25
+ const { result } = renderHook(
26
+ () =>
27
+ useApiMutation<Rom, void>({
28
+ mutationFn: (_client, rom) => request(rom),
29
+ optimistic: {
30
+ label: (rom) => `favorite ${rom.id}`,
31
+ edits: (rom) => [
32
+ {
33
+ queryKey: ['roms'],
34
+ apply: (roms: Rom[]) => roms.map((r) => (r.id === rom.id ? { ...r, favorite: !r.favorite } : r)),
35
+ },
36
+ ],
37
+ },
38
+ }),
39
+ { wrapper },
40
+ );
41
+ return { queryClient, result, errorSpy };
42
+ };
43
+
44
+ describe('useApiMutation — optimistic', () => {
45
+ test('the cache changes before the request settles', async () => {
46
+ let finish!: () => void;
47
+ const { queryClient, result } = setup(() => new Promise<void>((resolve) => (finish = resolve)));
48
+ let done!: Promise<void>;
49
+ act(() => {
50
+ done = result.current.mutateAsync({ id: 'r1', favorite: false });
51
+ });
52
+ await act(async () => {
53
+ await Promise.resolve();
54
+ await Promise.resolve();
55
+ });
56
+ expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(true);
57
+ await act(async () => {
58
+ finish();
59
+ await done;
60
+ });
61
+ });
62
+
63
+ test('a refusal reverts the cache, shows ONE Retry toast, and rejects with the cause', async () => {
64
+ const { queryClient, result, errorSpy } = setup(() => Promise.reject(new Error('nope')));
65
+ let caught: unknown;
66
+ await act(async () => {
67
+ await result.current.mutateAsync({ id: 'r1', favorite: false }).catch((e: unknown) => {
68
+ caught = e;
69
+ });
70
+ });
71
+ expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(false);
72
+ expect(caught).toBeInstanceOf(OptimisticFailure);
73
+ expect((caught as Error).cause).toBeInstanceOf(Error);
74
+ expect(toastError).toHaveBeenCalledTimes(1);
75
+ expect(errorSpy).not.toHaveBeenCalled();
76
+ });
77
+ });
@@ -6,6 +6,8 @@ import {
6
6
  useQueryClient,
7
7
  } from '@tanstack/react-query';
8
8
  import type { ReactNode } from 'react';
9
+ import { type OptimisticQueryClient, optimistic as runOptimistic, queryTarget } from './optimistic/optimistic.js';
10
+ import type { IdOf } from './optimistic/revert.js';
9
11
 
10
12
  // IMPORTANT: this factory's PUBLIC surface is deliberately free of @tanstack's
11
13
  // class-shaped types (UseQueryResult/UseQueryOptions/QueryClient). Those carry
@@ -121,6 +123,32 @@ export interface ApiMutationConfig<Client, TArgs, TData> {
121
123
  onSuccess?: (data: TData, args: TArgs) => void;
122
124
  /** Extra error handling, in addition to the automatic error toast. */
123
125
  onError?: (error: unknown, args: TArgs) => void;
126
+ /**
127
+ * Show the change before the server answers (STA-10). Each edit is a cache entry and the
128
+ * forward change to its data; the rollback is derived, and a refusal reverts only what this
129
+ * call touched and toasts "Couldn't <label>" with Retry — in place of the plain error toast.
130
+ * Every edited key is refetched once the request settles.
131
+ *
132
+ * optimistic: {
133
+ * label: (rom) => `favorite “${rom.title}”`,
134
+ * edits: (rom) => [{ queryKey: ['roms'], apply: (roms: Rom[]) => roms.map(...) }],
135
+ * }
136
+ */
137
+ optimistic?: ApiOptimistic<TArgs>;
138
+ }
139
+
140
+ export interface ApiOptimistic<TArgs> {
141
+ /** The verb phrase the failure toast completes: "Couldn't " + label. */
142
+ label: string | ((args: TArgs) => string);
143
+ /** The cache entries to change at once, each with its forward edit. */
144
+ edits: (args: TArgs) => ReadonlyArray<{
145
+ queryKey: QueryKey;
146
+ apply: (data: any) => any;
147
+ /** How the entry's array items are told apart when they carry neither `id` nor `key`. */
148
+ idOf?: IdOf;
149
+ }>;
150
+ /** Serialize requests that share a key — e.g. the record's id. */
151
+ key?: (args: TArgs) => string;
124
152
  }
125
153
 
126
154
  export interface ApiHooks<Client> {
@@ -130,6 +158,14 @@ export interface ApiHooks<Client> {
130
158
  ) => ApiMutationResult<TArgs, TData>;
131
159
  }
132
160
 
161
+ /** The rejection an optimistic mutation's `mutateAsync` gives: the server's own error is `cause`. */
162
+ export class OptimisticFailure extends Error {
163
+ constructor(cause: unknown) {
164
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
165
+ this.name = 'OptimisticFailure';
166
+ }
167
+ }
168
+
133
169
  /**
134
170
  * Build a pair of TanStack-Query hooks bound to your app's request client + toaster.
135
171
  * Standardizes the data-fetching boilerplate every app reinvents: apply a fallback
@@ -182,12 +218,28 @@ export const createApiHooks = <Client>({
182
218
  invalidate,
183
219
  onSuccess,
184
220
  onError,
221
+ optimistic,
185
222
  }: ApiMutationConfig<Client, TArgs, TData>): ApiMutationResult<TArgs, TData> => {
186
223
  const client = useClient();
187
224
  const queryClient = useQueryClient();
188
225
  const toaster = useToaster?.();
189
226
  return useMutation<TData, unknown, TArgs>({
190
- mutationFn: (args) => mutationFn(client, args),
227
+ mutationFn: optimistic
228
+ ? async (args) => {
229
+ const outcome = await runOptimistic({
230
+ label: typeof optimistic.label === 'function' ? optimistic.label(args) : optimistic.label,
231
+ key: optimistic.key?.(args),
232
+ targets: optimistic.edits(args).map(({ queryKey, apply, idOf }) => ({
233
+ ...queryTarget(queryClient as unknown as OptimisticQueryClient, queryKey),
234
+ apply,
235
+ ...(idOf ? { idOf } : {}),
236
+ })),
237
+ request: () => mutationFn(client, args),
238
+ });
239
+ if (!outcome.ok) throw new OptimisticFailure(outcome.error);
240
+ return outcome.value;
241
+ }
242
+ : (args) => mutationFn(client, args),
191
243
  onSuccess: (data, args) => {
192
244
  if (successToast && toaster) {
193
245
  toaster.success(
@@ -201,6 +253,11 @@ export const createApiHooks = <Client>({
201
253
  onSuccess?.(data, args);
202
254
  },
203
255
  onError: (error, args) => {
256
+ // An optimistic call has already reverted and shown its own Retry toast.
257
+ if (error instanceof OptimisticFailure) {
258
+ onError?.(error.cause, args);
259
+ return;
260
+ }
204
261
  toaster?.error(error);
205
262
  onError?.(error, args);
206
263
  },
@@ -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 ──────────────────────────────────────────────────────────────────────────