cursedbelt 5.96.0 → 5.97.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 (150) hide show
  1. package/dist/react/components/AppShell.js +6 -1
  2. package/dist/react/components/AppShell.js.map +1 -1
  3. package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
  4. package/dist/react/components/ConfirmDialog.js +1 -1
  5. package/dist/react/components/ConfirmDialog.js.map +1 -1
  6. package/dist/react/components/PaneSplit.d.ts +11 -2
  7. package/dist/react/components/PaneSplit.d.ts.map +1 -1
  8. package/dist/react/components/PaneSplit.js +12 -11
  9. package/dist/react/components/PaneSplit.js.map +1 -1
  10. package/dist/react/components/PanelGrid.d.ts +14 -8
  11. package/dist/react/components/PanelGrid.d.ts.map +1 -1
  12. package/dist/react/components/PanelGrid.js +10 -3
  13. package/dist/react/components/PanelGrid.js.map +1 -1
  14. package/dist/react/components/SplitPane.d.ts +3 -9
  15. package/dist/react/components/SplitPane.d.ts.map +1 -1
  16. package/dist/react/components/SplitPane.js +0 -3
  17. package/dist/react/components/SplitPane.js.map +1 -1
  18. package/dist/react/components/TagsEditor.d.ts +15 -0
  19. package/dist/react/components/TagsEditor.d.ts.map +1 -0
  20. package/dist/react/components/TagsEditor.js +93 -0
  21. package/dist/react/components/TagsEditor.js.map +1 -0
  22. package/dist/react/components/TagsPromptDialog.d.ts +25 -0
  23. package/dist/react/components/TagsPromptDialog.d.ts.map +1 -0
  24. package/dist/react/components/TagsPromptDialog.js +38 -0
  25. package/dist/react/components/TagsPromptDialog.js.map +1 -0
  26. package/dist/react/components/paneSplit.css +8 -0
  27. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
  28. package/dist/react/file-tree/FolderViewMenus.js +2 -0
  29. package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
  30. package/dist/react/file-tree/useFolderView.d.ts +11 -1
  31. package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
  32. package/dist/react/file-tree/useFolderView.js +3 -2
  33. package/dist/react/file-tree/useFolderView.js.map +1 -1
  34. package/dist/react/filter-rail/railResize.d.ts +16 -5
  35. package/dist/react/filter-rail/railResize.d.ts.map +1 -1
  36. package/dist/react/filter-rail/railResize.js +27 -9
  37. package/dist/react/filter-rail/railResize.js.map +1 -1
  38. package/dist/react/filter-rail/railWidth.d.ts +10 -5
  39. package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
  40. package/dist/react/filter-rail/railWidth.js +13 -8
  41. package/dist/react/filter-rail/railWidth.js.map +1 -1
  42. package/dist/react/hooks/longPressContextMenu.d.ts +10 -1
  43. package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -1
  44. package/dist/react/hooks/longPressContextMenu.js +37 -3
  45. package/dist/react/hooks/longPressContextMenu.js.map +1 -1
  46. package/dist/react/index.d.ts +4 -0
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +4 -0
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/lib/paneReach.d.ts +36 -0
  51. package/dist/react/lib/paneReach.d.ts.map +1 -0
  52. package/dist/react/lib/paneReach.js +72 -0
  53. package/dist/react/lib/paneReach.js.map +1 -0
  54. package/dist/react/lib/tagList.d.ts +16 -0
  55. package/dist/react/lib/tagList.d.ts.map +1 -0
  56. package/dist/react/lib/tagList.js +30 -0
  57. package/dist/react/lib/tagList.js.map +1 -0
  58. package/dist/react/master-detail/MasterDetail.d.ts +6 -3
  59. package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
  60. package/dist/react/master-detail/MasterDetail.js +3 -2
  61. package/dist/react/master-detail/MasterDetail.js.map +1 -1
  62. package/dist/react/media/FocusViewer.d.ts +8 -1
  63. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  64. package/dist/react/media/FocusViewer.js +10 -1
  65. package/dist/react/media/FocusViewer.js.map +1 -1
  66. package/dist/react/media/FrameGrabber.d.ts +3 -1
  67. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  68. package/dist/react/media/FrameGrabber.js +10 -5
  69. package/dist/react/media/FrameGrabber.js.map +1 -1
  70. package/dist/react/media/VideoChapterEditor.d.ts +3 -1
  71. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  72. package/dist/react/media/VideoChapterEditor.js +14 -6
  73. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  74. package/dist/react/media/VideoCutEditor.d.ts +3 -1
  75. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  76. package/dist/react/media/VideoCutEditor.js +14 -6
  77. package/dist/react/media/VideoCutEditor.js.map +1 -1
  78. package/dist/react/media/VideoTurner.d.ts +3 -1
  79. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  80. package/dist/react/media/VideoTurner.js +13 -5
  81. package/dist/react/media/VideoTurner.js.map +1 -1
  82. package/dist/react/media/hlsSource.d.ts +27 -0
  83. package/dist/react/media/hlsSource.d.ts.map +1 -1
  84. package/dist/react/media/hlsSource.js +39 -0
  85. package/dist/react/media/hlsSource.js.map +1 -1
  86. package/dist/react/media/videoEnhance.d.ts +1 -1
  87. package/dist/react/media/videoEnhance.js +1 -1
  88. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  89. package/dist/react/media-gallery/MediaGallery.js +4 -4
  90. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  91. package/dist/react/tagsPrompt.d.ts +15 -0
  92. package/dist/react/tagsPrompt.d.ts.map +1 -0
  93. package/dist/react/tagsPrompt.js +30 -0
  94. package/dist/react/tagsPrompt.js.map +1 -0
  95. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  96. package/dist/styles-areas/fields.css +4 -4
  97. package/dist/styles-areas/overlays.css +3 -2
  98. package/dist/styles-utilities.css +7 -0
  99. package/dist/testing/phoneRules.js +3 -3
  100. package/dist/testing/phoneRules.js.map +1 -1
  101. package/dist/theme.css +13 -0
  102. package/package.json +8 -1
  103. package/scripts/guardrailsEnforce.spec.ts +25 -0
  104. package/scripts/guardrailsEnforce.ts +61 -0
  105. package/scripts/styleAreas.ts +5 -0
  106. package/src/looks.spec.ts +34 -0
  107. package/src/react/components/AppShell.spec.tsx +23 -0
  108. package/src/react/components/AppShell.tsx +6 -1
  109. package/src/react/components/ConfirmDialog.spec.tsx +41 -0
  110. package/src/react/components/ConfirmDialog.tsx +12 -2
  111. package/src/react/components/PaneSplit.spec.tsx +26 -3
  112. package/src/react/components/PaneSplit.tsx +26 -13
  113. package/src/react/components/PanelGrid.tsx +16 -11
  114. package/src/react/components/SplitPane.spec.tsx +10 -5
  115. package/src/react/components/SplitPane.tsx +3 -12
  116. package/src/react/components/TagsEditor.tsx +140 -0
  117. package/src/react/components/TagsPromptDialog.spec.tsx +74 -0
  118. package/src/react/components/TagsPromptDialog.tsx +108 -0
  119. package/src/react/components/paneSplit.css +8 -0
  120. package/src/react/file-tree/FolderViewMenus.tsx +1 -0
  121. package/src/react/file-tree/folderViewTree.spec.tsx +15 -0
  122. package/src/react/file-tree/useFolderView.ts +13 -2
  123. package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
  124. package/src/react/filter-rail/railResize.spec.tsx +27 -5
  125. package/src/react/filter-rail/railResize.tsx +46 -15
  126. package/src/react/filter-rail/railWidth.ts +13 -8
  127. package/src/react/hooks/longPressContextMenu.spec.ts +50 -0
  128. package/src/react/hooks/longPressContextMenu.ts +39 -3
  129. package/src/react/index.ts +4 -0
  130. package/src/react/lib/paneReach.spec.ts +58 -0
  131. package/src/react/lib/paneReach.ts +65 -0
  132. package/src/react/lib/tagList.spec.ts +19 -0
  133. package/src/react/lib/tagList.ts +29 -0
  134. package/src/react/master-detail/MasterDetail.tsx +10 -5
  135. package/src/react/media/FocusViewer.spec.tsx +19 -0
  136. package/src/react/media/FocusViewer.tsx +17 -0
  137. package/src/react/media/FrameGrabber.tsx +12 -5
  138. package/src/react/media/VideoChapterEditor.tsx +16 -6
  139. package/src/react/media/VideoCutEditor.tsx +17 -6
  140. package/src/react/media/VideoTurner.tsx +14 -5
  141. package/src/react/media/editorVideoSource.spec.tsx +71 -0
  142. package/src/react/media/hlsSource.ts +43 -0
  143. package/src/react/media/videoEnhance.ts +1 -1
  144. package/src/react/media-gallery/MediaGallery.tsx +4 -0
  145. package/src/react/tagsPrompt.tsx +33 -0
  146. package/src/styles-areas/fields.css +4 -4
  147. package/src/styles-areas/overlays.css +3 -2
  148. package/src/styles-utilities.css +7 -0
  149. package/src/testing/phoneRules.ts +2 -2
  150. package/src/theme.css +13 -0
@@ -163,6 +163,11 @@
163
163
  // <Name,…>`; with no list the rule is inert rather than guessing. Near spellings count
164
164
  // too (task 2381): `CheckBox`, `StatusBadge`, `Stat`, `ToastStack`.
165
165
  //
166
+ // no-hand-rolled-divider — owner, 2026-10-27: every divider drags as far as the hand takes
167
+ // it, shutting a pane included. An app imports `react-resizable-panels` only through
168
+ // cursedbelt's `SplitPane`/`PanelGrid`, and a seam it draws itself (a `separator` role
169
+ // with a pointer handler) takes its geometry from `reachSize`/`stepSize`. See its block.
170
+ //
166
171
  // no-inline-style — DESIGN.md §3/§4 (task 2381): `style={{…}}` may carry only a CSS
167
172
  // custom property or COMPUTED geometry; a literal size, color or font is a violation.
168
173
  //
@@ -3101,6 +3106,61 @@ const noArbitraryLength: Rule = {
3101
3106
  },
3102
3107
  };
3103
3108
 
3109
+ // ── no-hand-rolled-divider (forge task 3839, 2026-10-27) ──────────────────────────
3110
+ // The owner: *"For all dividers … that I can drag to size the panes, let me drag them as far as I
3111
+ // want including shutting a pane completely … I don't want any panes to block me."* station's
3112
+ // Queue capped its task list at 36% (`MasterDetail`'s `max`), and desk's own seams each kept a
3113
+ // min and a max of their own, so every app's divider stopped somewhere different and none could
3114
+ // be shut.
3115
+ //
3116
+ // cursedbelt's dividers obey one rule (`src/react/lib/paneReach.ts`): no maximum, and a minimum
3117
+ // that is a SNAP — below half of it the pane shuts. `SplitPane`/`PanelGrid` cannot be capped (the
3118
+ // props are gone), `PaneSplit` and `useRailResize` call the rule. So in app source:
3119
+ // - importing `react-resizable-panels` directly is a violation — it is how a `maxSize` comes back;
3120
+ // - a seam drawn by hand (`role="separator"` in a file with a pointer or mouse-down handler) is
3121
+ // a violation unless the file imports `reachSize` or `stepSize` from cursedbelt, which is the
3122
+ // whole of what makes a hand-drawn seam obey the rule.
3123
+ // A drag that is not a pane divider (a spreadsheet's column-width grip) opts out on the block
3124
+ // above with `guardrails-ignore no-hand-rolled-divider: <why>`.
3125
+ const RAW_PANELS_IMPORT_RE = /from\s+["']react-resizable-panels["']/g;
3126
+ const SEPARATOR_ROLE_RE = /role\s*[=:]\s*\{?\s*["']separator["']/g;
3127
+ const SEAM_DRAG_RE = /\bon(?:Pointer|Mouse)Down\b|["'](?:pointer|mouse)down["']/;
3128
+ const REACH_IMPORT_RE = /import\s*\{[^}]*\b(?:reachSize|stepSize)\b[^}]*\}\s*from\s*["']cursedbelt\//;
3129
+ const DIVIDER_IGNORE_RE = /guardrails-ignore\s+no-hand-rolled-divider\s*:/;
3130
+
3131
+ const noHandRolledDivider: Rule = {
3132
+ name: "no-hand-rolled-divider",
3133
+ description:
3134
+ "owner, 2026-10-27 (forge task 3839): every divider drags as far as the hand takes it, shutting a pane included. Split panes are cursedbelt's SplitPane / PanelGrid / MasterDetail / PaneSplit / useRailResize — never react-resizable-panels directly (that is how a maxSize comes back) — and a seam an app draws itself takes its geometry from cursedbelt's reachSize/stepSize (cursedbelt/react/pane-split), so no pane has a maximum and any pane can be shut.",
3135
+ check(filePath, source) {
3136
+ const normalized = filePath.replace(/\\/g, "/");
3137
+ if (extname(normalized) === ".css" || isTestSource(normalized)) return [];
3138
+ const scannable = blankComments(source);
3139
+ const lines = source.split("\n");
3140
+ const optedOut = (line: number) => {
3141
+ const block: string[] = [];
3142
+ for (let i = line - 2; i >= 0 && block.length < 12; i--) {
3143
+ const raw = lines[i] ?? "";
3144
+ if (raw.trim() === "") break;
3145
+ block.push(raw);
3146
+ }
3147
+ return DIVIDER_IGNORE_RE.test(block.join("\n"));
3148
+ };
3149
+ const violations: Violation[] = [];
3150
+ for (const match of scannable.matchAll(RAW_PANELS_IMPORT_RE)) {
3151
+ const line = lineOf(source, match.index ?? 0);
3152
+ if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
3153
+ }
3154
+ if (SEAM_DRAG_RE.test(scannable) && !REACH_IMPORT_RE.test(scannable)) {
3155
+ for (const match of scannable.matchAll(SEPARATOR_ROLE_RE)) {
3156
+ const line = lineOf(source, match.index ?? 0);
3157
+ if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
3158
+ }
3159
+ }
3160
+ return violations;
3161
+ },
3162
+ };
3163
+
3104
3164
  export const RULES: Record<string, Rule> = {
3105
3165
  [noRawActionButton.name]: noRawActionButton,
3106
3166
  [rawButtonResetsFace.name]: rawButtonResetsFace,
@@ -3114,6 +3174,7 @@ export const RULES: Record<string, Rule> = {
3114
3174
  [noRawDataTable.name]: noRawDataTable,
3115
3175
  [noHandRolledPropertyList.name]: noHandRolledPropertyList,
3116
3176
  [noRawKbd.name]: noRawKbd,
3177
+ [noHandRolledDivider.name]: noHandRolledDivider,
3117
3178
  [noTailwindPalette.name]: noTailwindPalette,
3118
3179
  [themeColoursResolve.name]: themeColoursResolve,
3119
3180
  [noRawTitleAttr.name]: noRawTitleAttr,
@@ -288,6 +288,8 @@ export const AREAS: readonly Area[] = [
288
288
  'components/SwatchPicker.tsx',
289
289
  'components/TagStrip.tsx',
290
290
  'components/TagsInput.tsx',
291
+ // TagsInput's chips with the copy button and ⌘A/⌘C/⌘V (forge task 3812, lifted from photos).
292
+ 'components/TagsEditor.tsx',
291
293
  ],
292
294
  },
293
295
  { name: 'file-tree', why: 'the flat (non-nested) file list and its row chrome', roots: ['file-tree'] },
@@ -355,6 +357,9 @@ export const AREAS: readonly Area[] = [
355
357
  'components/DiscardConfirmDialog.tsx',
356
358
  'components/ImpactDialog.tsx',
357
359
  'components/PromptDialog.tsx',
360
+ // PromptDialog's twin for a list of tags, and its imperative `promptTags` (forge task 3812).
361
+ 'components/TagsPromptDialog.tsx',
362
+ 'tagsPrompt.tsx',
358
363
  'components/ElsewhereNotice.tsx',
359
364
  'components/AddItemsMenu.tsx',
360
365
  ],
package/src/looks.spec.ts CHANGED
@@ -126,6 +126,40 @@ describe('looks.css', () => {
126
126
  }
127
127
  expect(new Set(LOOKS.map((l) => l.id)).size).toBe(LOOKS.length);
128
128
  });
129
+
130
+ test('highlighted text reads in every look and scheme, and the highlight can be seen on the card', () => {
131
+ // theme.css's `::selection`: the accent mixed into the card, the text in the foreground. Without
132
+ // it the browser's light highlight sat under a dark scheme's light text (desk, obsidian, 2026-10-27).
133
+ const plain = THEME.replace(/\/\*[\s\S]*?\*\//g, '');
134
+ const rule = /(?:^|\})\s*::selection\s*\{([^{}]*)\}/.exec(plain)?.[1] ?? '';
135
+ const mix = /background-color:\s*color-mix\(in srgb, var\(--accent\) (\d+)%, var\(--card\)\)/.exec(rule);
136
+ expect(mix, 'theme.css has no `::selection` mixing the accent into the card — the browser highlight is back').not.toBeNull();
137
+ expect(rule).toMatch(/(?:^|[\s;])color:\s*var\(--foreground\)/);
138
+ const share = Number(mix?.[1]) / 100;
139
+ // The belt's own accent in each scheme; an app that rebrands it answers for its own.
140
+ const accent = { light: declarationsOf(THEME, ':root')?.get('--accent'), dark: declarationsOf(THEME, '.dark')?.get('--accent') };
141
+ const rgb = (hex: string) => [16, 8, 0].map((shift) => (Number.parseInt(hex.slice(1), 16) >> shift) & 255);
142
+ const failures: string[] = [];
143
+ for (const look of LOOKS) {
144
+ for (const scheme of ['light', 'dark'] as const) {
145
+ const decls =
146
+ look.id === 'aurora'
147
+ ? scheme === 'light'
148
+ ? auroraLight
149
+ : auroraDark
150
+ : (declarationsOf(LOOKS_CSS, scheme === 'light' ? lightSelector(look.id) : darkSelector(look.id)) as Decls);
151
+ const card = decls.get('--card') as string;
152
+ const painted = `#${rgb(accent[scheme] as string)
153
+ .map((c, i) => Math.round(c * share + (rgb(card)[i] as number) * (1 - share)).toString(16).padStart(2, '0'))
154
+ .join('')}`;
155
+ const text = contrast(decls.get('--foreground') as string, painted);
156
+ if (text < 4.5) failures.push(`${look.id}/${scheme}: selected text is ${text.toFixed(2)}:1 (< 4.5)`);
157
+ const seen = contrast(painted, card);
158
+ if (seen < 1.4) failures.push(`${look.id}/${scheme}: the highlight is ${seen.toFixed(2)}:1 on the card (< 1.4, invisible)`);
159
+ }
160
+ }
161
+ expect(failures).toEqual([]);
162
+ });
129
163
  });
130
164
 
131
165
  /**
@@ -135,6 +135,29 @@ describe('AppShell', () => {
135
135
  expect(q(icon as HTMLElement, '[data-cb-rail-head] [data-cb-command-trigger]')).not.toBeNull();
136
136
  });
137
137
 
138
+ test('🔴 a worded capture on the sidebar ⌘K row makes the trigger the square search button — the field had ~50px left (photos, 2026-10-27)', () => {
139
+ const head = (capture: boolean) => {
140
+ const { container } = render(
141
+ <AppShell nav={station} navHeader={<span data-brand=''>Photos</span>} onOpenCommand={() => {}} commandLabel='Search the archive…' {...(capture ? { onCapture: () => {}, captureLabel: 'Upload' } : {})}>
142
+ x
143
+ </AppShell>,
144
+ );
145
+ const full = qa(container, '[data-cb-nav-rail]')[1] as HTMLElement;
146
+ const trigger = q(full, '[data-cb-rail-head] [data-cb-command-trigger]');
147
+ cleanup();
148
+ return trigger;
149
+ };
150
+ // Alone on its row, the trigger is the worded field…
151
+ const field = head(false);
152
+ expect(field?.getAttribute('aria-label')).toBeNull();
153
+ expect(field?.className).toContain('flex-1');
154
+ // …beside "+ Upload" it is the square, named by its words, so the row fits the 240px rail.
155
+ const square = head(true);
156
+ expect(square?.getAttribute('aria-label')).toBe('Search the archive…');
157
+ expect(square?.className).toContain('w-10');
158
+ expect(square?.className).not.toContain('flex-1');
159
+ });
160
+
138
161
  test('a 2–5-place shell has no sidebar, so its header stays at every width', () => {
139
162
  const { container } = render(<AppShell nav={nav}>x</AppShell>);
140
163
  expect(q(container, '[data-cb-appheader]')?.className).not.toContain('md:hidden');
@@ -392,12 +392,17 @@ function Shell({
392
392
  </IconButton>
393
393
  ) : null;
394
394
  const hasCommandRow = (collapsed: boolean) => Boolean(openCommand || (!collapsed && (onCapture || commandBarExtra)));
395
+ // A worded capture on the ⌘K row takes the field's room: in a 240px sidebar the fold, the field
396
+ // and "+ Upload" left the field ~50px, its placeholder cut to a sliver under the button (photos,
397
+ // 1440, 2026-10-27). With capture there the trigger is the square search button, Linear's sidebar
398
+ // head (a search icon beside the worded compose); the palette and ⌘K are unchanged.
399
+ const squareCommand = (collapsed: boolean) => collapsed || Boolean(onCapture);
395
400
  const railCommand = (collapsed: boolean) => {
396
401
  if (!hasCommandRow(collapsed)) return null;
397
402
  const bar = (
398
403
  <CommandBar
399
404
  className={collapsed ? 'flex-col' : 'w-full min-w-0 flex-wrap'}
400
- compact={collapsed}
405
+ compact={squareCommand(collapsed)}
401
406
  onCapture={collapsed ? undefined : onCapture}
402
407
  captureLabel={captureLabel}
403
408
  onOpenCommand={openCommand}
@@ -0,0 +1,41 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, render, screen } from '@testing-library/react';
3
+ import { ConfirmDialog } from './ConfirmDialog.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const noop = () => {};
8
+
9
+ describe('ConfirmDialog message', () => {
10
+ test('a sentence is a <p>, and it describes the dialog', () => {
11
+ render(<ConfirmDialog message='Gone for good.' onCancel={noop} onConfirm={noop} open title='Delete?' />);
12
+ const message = screen.getByText('Gone for good.');
13
+ expect(message.tagName).toBe('P');
14
+ expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBe(message.id);
15
+ });
16
+
17
+ test('a list (or a field) is a <div> — never block content inside a <p>', () => {
18
+ render(
19
+ <ConfirmDialog
20
+ message={
21
+ <div data-testid='body'>
22
+ <strong>Why it needs a better copy</strong>
23
+ <ul>
24
+ <li>Poor sound</li>
25
+ </ul>
26
+ </div>
27
+ }
28
+ onCancel={noop}
29
+ onConfirm={noop}
30
+ open
31
+ title='Mark it?'
32
+ />,
33
+ );
34
+ const body = screen.getByTestId('body');
35
+ expect(body.closest('p')).toBeNull();
36
+ // Still the dialog's description, so a screen reader reads it with the title.
37
+ const description = body.parentElement as HTMLElement;
38
+ expect(description.tagName).toBe('DIV');
39
+ expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBe(description.id);
40
+ });
41
+ });
@@ -77,8 +77,18 @@ export function ConfirmDialog({
77
77
  {title}
78
78
  </DialogTitle>
79
79
  {message ? (
80
- <DialogDescription className='text-sm text-muted-foreground'>
81
- {message}
80
+ // Text is a sentence, so a <p>. Anything else — a field, a checkbox, a list — is a
81
+ // <div>: a <p> may hold phrasing content only, and React warns at the first <div> a
82
+ // caller's message draws inside one (music's bad-copy reason field, forge task 3811).
83
+ <DialogDescription
84
+ asChild={typeof message !== 'string' && typeof message !== 'number'}
85
+ className='text-sm text-muted-foreground'
86
+ >
87
+ {typeof message === 'string' || typeof message === 'number' ? (
88
+ message
89
+ ) : (
90
+ <div>{message}</div>
91
+ )}
82
92
  </DialogDescription>
83
93
  ) : null}
84
94
  </div>
@@ -48,13 +48,36 @@ describe('PaneSplit', () => {
48
48
  expect(sizes[1]).toBeCloseTo(0.48);
49
49
  });
50
50
 
51
- test('Home and End stop at the smallest share a pane may have', () => {
51
+ // The owner, 2026-10-27: no divider is a wall — a pane can be shut, from either side.
52
+ test('Home and End shut the pane on either side of the seam', () => {
52
53
  const onResize = draw();
53
54
  const [, outer] = screen.getAllByRole('separator');
55
+ expect([outer!.getAttribute('aria-valuemin'), outer!.getAttribute('aria-valuemax')]).toEqual(['0', '100']);
54
56
  fireEvent.keyDown(outer!, { key: 'Home' });
55
57
  expect(onResize.mock.calls[0]![0]).toEqual([]);
56
- expect(onResize.mock.calls[0]![1][0]).toBeCloseTo(0.12);
58
+ expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
57
59
  fireEvent.keyDown(outer!, { key: 'End' });
58
- expect(onResize.mock.calls[1]![1][0]).toBeCloseTo(0.88);
60
+ expect(onResize.mock.calls[1]![1]).toEqual([1, 0]);
61
+ });
62
+
63
+ test('an arrow from the snap shuts the pane, and the next one opens it at the snap', () => {
64
+ const onResize = mock();
65
+ const at = (share: number): PaneSplitNode<{ id: string }> => ({
66
+ kind: 'split',
67
+ direction: 'row',
68
+ sizes: [share, 1 - share],
69
+ children: [
70
+ { kind: 'leaf', pane: { id: 'a' } },
71
+ { kind: 'leaf', pane: { id: 'b' } },
72
+ ],
73
+ });
74
+ const { rerender } = render(<PaneSplit node={at(0.12)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
75
+ fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowLeft' });
76
+ expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
77
+ rerender(<PaneSplit node={at(0)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
78
+ // A shut pane draws nothing, but its seam is still there to bring it back.
79
+ expect(screen.getByLabelText('a').parentElement!.hasAttribute('data-shut')).toBe(true);
80
+ fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowRight' });
81
+ expect(onResize.mock.calls[1]![1][0]).toBeCloseTo(0.12);
59
82
  });
60
83
  });
@@ -11,7 +11,11 @@
11
11
  * and the arithmetic that turns a pointer into a pair of fractions. Unlike `PanelGrid` it owns no
12
12
  * sizes and persists nothing, which is what an app that stores its layout as a setting needs.
13
13
  *
14
- * 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End take it to the bounds.
14
+ * 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End shut the pane either side.
15
+ *
16
+ * 🔴 **No seam is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want including
17
+ * shutting a pane completely"*). `minFraction` is a snap, not a floor: `lib/paneReach.ts` shuts a pane
18
+ * dragged below half of it, and a shut pane's seam stays at the edge to drag it open again.
15
19
  *
16
20
  * 🔴 **The hit strip lives inside the seam's own track** — a real flex item, never a bar hung over
17
21
  * the boundary. An invisible strip centered on the seam sits over the first pixels of the pane beside
@@ -25,8 +29,12 @@
25
29
 
26
30
  import type { CSSProperties, JSX, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react';
27
31
  import { useCallback, useRef } from 'react';
32
+ import { reachSize, stepSize } from '../lib/paneReach.js';
28
33
  import './paneSplit.css';
29
34
 
35
+ /** The rule every divider obeys — re-exported for a seam an app draws itself (guardrails' `no-hand-rolled-divider`). */
36
+ export { reachSize, stepSize } from '../lib/paneReach.js';
37
+
30
38
  export type PaneSplitDirection = 'row' | 'column';
31
39
 
32
40
  export type PaneSplitNode<P> =
@@ -43,7 +51,10 @@ export interface PaneSplitProps<P> {
43
51
  renderPane: (pane: P) => JSX.Element;
44
52
  /** A seam moved: the split at `path` now has these fractions. */
45
53
  onResize: (path: PaneSplitPath, sizes: number[]) => void;
46
- /** The smallest share of an axis a pane can be dragged down to. Default 0.12. */
54
+ /**
55
+ * The snap: a pane dragged below half of this share shuts, and between half and all of it holds
56
+ * here (`lib/paneReach.ts`). Never a floor — any pane can be shut. Default 0.12.
57
+ */
47
58
  minFraction?: number;
48
59
  /** Where `node` sits — only for a tree drawn from below its root. */
49
60
  path?: PaneSplitPath;
@@ -79,16 +90,15 @@ function SplitNode<P>({
79
90
 
80
91
  /** Set the share of the pair either side of seam `index`; only those two panes move. */
81
92
  const setShare = useCallback(
82
- (index: number, share: number) => {
93
+ (index: number, bounded: number) => {
83
94
  const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
84
95
  if (pair <= 0) return;
85
- const bounded = Math.min(Math.max(share, minFraction), 1 - minFraction);
86
96
  const sizes = [...node.sizes];
87
97
  sizes[index] = pair * bounded;
88
98
  sizes[index + 1] = pair * (1 - bounded);
89
99
  onResize(path, sizes);
90
100
  },
91
- [node.sizes, minFraction, onResize, path],
101
+ [node.sizes, onResize, path],
92
102
  );
93
103
 
94
104
  const moveSeam = (index: number, position: number) => {
@@ -100,13 +110,13 @@ function SplitNode<P>({
100
110
  const before = node.sizes.slice(0, index).reduce((sum, size) => sum + size, 0);
101
111
  const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
102
112
  if (pair <= 0) return;
103
- setShare(index, (offset - before) / pair);
113
+ setShare(index, reachSize((offset - before) / pair, minFraction, 1));
104
114
  };
105
115
 
106
116
  const nudgeSeam = (index: number, delta: number) => {
107
117
  const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
108
118
  if (pair <= 0) return;
109
- setShare(index, (node.sizes[index] ?? 0) / pair + delta);
119
+ setShare(index, stepSize((node.sizes[index] ?? 0) / pair, delta, minFraction, 1));
110
120
  };
111
121
 
112
122
  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
@@ -114,8 +124,8 @@ function SplitNode<P>({
114
124
  const forward = node.direction === 'row' ? 'ArrowRight' : 'ArrowDown';
115
125
  if (event.key === back) nudgeSeam(index, -0.02);
116
126
  else if (event.key === forward) nudgeSeam(index, 0.02);
117
- else if (event.key === 'Home') nudgeSeam(index, -1);
118
- else if (event.key === 'End') nudgeSeam(index, 1);
127
+ else if (event.key === 'Home') setShare(index, 0);
128
+ else if (event.key === 'End') setShare(index, 1);
119
129
  else return;
120
130
  event.preventDefault();
121
131
  };
@@ -138,7 +148,6 @@ function SplitNode<P>({
138
148
  if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
139
149
  };
140
150
 
141
- const minPercent = Math.round(minFraction * 100);
142
151
  return (
143
152
  <div className="cb-panesplit" data-direction={node.direction} ref={frame}>
144
153
  {node.children.map((child, index) => (
@@ -149,8 +158,8 @@ function SplitNode<P>({
149
158
  seam={{
150
159
  direction: node.direction,
151
160
  percent: Math.round((node.sizes[index] ?? 0) * 100),
152
- min: minPercent,
153
- max: 100 - minPercent,
161
+ min: 0,
162
+ max: 100,
154
163
  onKeyDown: (event) => onKeyDown(event, index),
155
164
  onPointerDown: (event) => onPointerDown(event, index),
156
165
  onPointerMove,
@@ -178,7 +187,11 @@ interface SeamProps {
178
187
  function ChildAndSeam({ last, size, seam, children }: { last: boolean; size: number; seam: SeamProps; children: JSX.Element }): JSX.Element {
179
188
  return (
180
189
  <>
181
- <div className="cb-panesplit__cell" style={{ '--cb-panesplit-share': size } as CSSProperties}>
190
+ <div
191
+ className="cb-panesplit__cell"
192
+ data-shut={size <= 0 ? '' : undefined}
193
+ style={{ '--cb-panesplit-share': size } as CSSProperties}
194
+ >
182
195
  {children}
183
196
  </div>
184
197
  {!last && (
@@ -18,11 +18,11 @@ export interface PanelGridLeaf {
18
18
  * this exact confusion collapsed a contact list to ~34px).
19
19
  */
20
20
  defaultSize?: number | string;
21
+ /**
22
+ * The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
23
+ * it holds here. Same rule as `lib/paneReach.ts`, applied by the library's collapsible panel.
24
+ */
21
25
  minSize?: number | string;
22
- maxSize?: number | string;
23
- /** Panel collapses to `collapsedSize` when dragged below `minSize`. */
24
- collapsible?: boolean;
25
- collapsedSize?: number | string;
26
26
  className?: string;
27
27
  }
28
28
 
@@ -38,10 +38,9 @@ export interface PanelGridSplit {
38
38
  resizable?: boolean;
39
39
  /** Divider look for THIS split's dividers (overrides the grid-level setting). */
40
40
  separatorVariant?: PanelSeparatorVariant;
41
- /** Sizing of this split when it is itself a cell of a parent split. */
41
+ /** Sizing of this split when it is itself a cell of a parent split (`minSize` is a snap, as on a leaf). */
42
42
  defaultSize?: number | string;
43
43
  minSize?: number | string;
44
- maxSize?: number | string;
45
44
  className?: string;
46
45
  }
47
46
 
@@ -97,7 +96,7 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
97
96
  * root={{
98
97
  * type: 'split', id: 'row', direction: 'horizontal',
99
98
  * children: [
100
- * { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px', collapsible: true },
99
+ * { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px' },
101
100
  * {
102
101
  * type: 'split', id: 'main', direction: 'vertical',
103
102
  * children: [
@@ -110,7 +109,14 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
110
109
  * />
111
110
  * ```
112
111
  *
113
- * Keyboard resizing, double-click-to-collapse, hit-target enlargement, and
112
+ * 🔴 **No divider is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want
113
+ * including shutting a pane completely … I don't want any panes to block me"*). So there is no
114
+ * `maxSize` and no `collapsible` to turn off: EVERY cell is collapsible to 0, and its `minSize`
115
+ * is the snap `lib/paneReach.ts` describes — below half of it the cell shuts, so the cell beside it
116
+ * can take the whole split. station's Queue capped its task list at 36% and cut off the titles it
117
+ * was opened to read; that is the bug this removes the means to write.
118
+ *
119
+ * Keyboard resizing (Enter shuts/opens a pane), hit-target enlargement, and
114
120
  * aria roles come from react-resizable-panels; the divider look comes from
115
121
  * `PanelSeparator` (single hairline + invisible wide hotspot — panel content
116
122
  * must not draw its own border against a divider).
@@ -295,9 +301,8 @@ function GridCell({
295
301
  id={child.id}
296
302
  defaultSize={child.defaultSize}
297
303
  minSize={child.minSize}
298
- maxSize={child.maxSize}
299
- collapsible={isSplit(child) ? undefined : child.collapsible}
300
- collapsedSize={isSplit(child) ? undefined : child.collapsedSize}
304
+ collapsible
305
+ collapsedSize={0}
301
306
  className={cn('min-h-0 min-w-0', child.className)}
302
307
  >
303
308
  {isSplit(child) ? (
@@ -105,15 +105,20 @@ describe('SplitPane', () => {
105
105
  window.matchMedia = original;
106
106
  });
107
107
 
108
- test('collapsible panel: both panels still render', () => {
108
+ // The owner, 2026-10-27: no divider is a wall. There is no way to ASK for one — a cap or an
109
+ // un-collapsible pane does not type-check — and the drag reaching both edges is proven in a real
110
+ // browser (`e2e/pane-reach.spec.ts`).
111
+ test('a pane cannot be capped or made un-shuttable', () => {
109
112
  const panels: SplitPaneItem[] = [
110
- { id: 'main', content: <div>Main</div> },
111
- { id: 'side', content: <div>Side</div>, collapsible: true, collapsedSize: 0 },
113
+ { id: 'main', content: <div>Main</div>, minSize: '16rem' },
114
+ // @ts-expect-error — `maxSize` is gone: a pane grows until its neighbor shuts.
115
+ { id: 'side', content: <div>Side</div>, maxSize: '40%' },
116
+ // @ts-expect-error — `collapsible` is gone: every pane can be shut.
117
+ { id: 'third', content: <div>Third</div>, collapsible: false },
112
118
  ];
113
119
  const { getByText, container } = render(<SplitPane panels={panels} />);
114
120
  expect(getByText('Main')).toBeTruthy();
115
121
  expect(getByText('Side')).toBeTruthy();
116
- // Both panels are in the DOM
117
- expect(container.querySelectorAll('[data-panel]').length).toBe(2);
122
+ expect(container.querySelectorAll('[data-panel]').length).toBe(3);
118
123
  });
119
124
  });
@@ -12,17 +12,11 @@ export interface SplitPaneItem {
12
12
  * number is NOT treated as a percentage (it silently becomes a pixel size).
13
13
  */
14
14
  defaultSize?: number | string;
15
- /** Minimum size constraint. */
16
- minSize?: number | string;
17
- /** Maximum size constraint. */
18
- maxSize?: number | string;
19
15
  /**
20
- * Panel collapses to `collapsedSize` when dragged below `minSize`.
21
- * Collapse-to-edge: set `collapsedSize` to 0 (or omit for the default).
16
+ * The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
17
+ * it holds here (`lib/paneReach.ts`). There is no maximum and no opting out — see `PanelGrid`.
22
18
  */
23
- collapsible?: boolean;
24
- /** Size when collapsed; defaults to 0. */
25
- collapsedSize?: number | string;
19
+ minSize?: number | string;
26
20
  className?: string;
27
21
  }
28
22
 
@@ -70,9 +64,6 @@ export function SplitPane({
70
64
  content: p.content,
71
65
  defaultSize: p.defaultSize,
72
66
  minSize: p.minSize,
73
- maxSize: p.maxSize,
74
- collapsible: p.collapsible,
75
- collapsedSize: p.collapsedSize,
76
67
  className: p.className,
77
68
  }));
78
69
  return (