cursedbelt 5.96.1 → 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 (57) hide show
  1. package/dist/react/components/PaneSplit.d.ts +11 -2
  2. package/dist/react/components/PaneSplit.d.ts.map +1 -1
  3. package/dist/react/components/PaneSplit.js +12 -11
  4. package/dist/react/components/PaneSplit.js.map +1 -1
  5. package/dist/react/components/PanelGrid.d.ts +14 -8
  6. package/dist/react/components/PanelGrid.d.ts.map +1 -1
  7. package/dist/react/components/PanelGrid.js +10 -3
  8. package/dist/react/components/PanelGrid.js.map +1 -1
  9. package/dist/react/components/SplitPane.d.ts +3 -9
  10. package/dist/react/components/SplitPane.d.ts.map +1 -1
  11. package/dist/react/components/SplitPane.js +0 -3
  12. package/dist/react/components/SplitPane.js.map +1 -1
  13. package/dist/react/components/paneSplit.css +8 -0
  14. package/dist/react/filter-rail/railResize.d.ts +16 -5
  15. package/dist/react/filter-rail/railResize.d.ts.map +1 -1
  16. package/dist/react/filter-rail/railResize.js +27 -9
  17. package/dist/react/filter-rail/railResize.js.map +1 -1
  18. package/dist/react/filter-rail/railWidth.d.ts +10 -5
  19. package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
  20. package/dist/react/filter-rail/railWidth.js +13 -8
  21. package/dist/react/filter-rail/railWidth.js.map +1 -1
  22. package/dist/react/index.d.ts +1 -0
  23. package/dist/react/index.d.ts.map +1 -1
  24. package/dist/react/index.js +1 -0
  25. package/dist/react/index.js.map +1 -1
  26. package/dist/react/lib/paneReach.d.ts +36 -0
  27. package/dist/react/lib/paneReach.d.ts.map +1 -0
  28. package/dist/react/lib/paneReach.js +72 -0
  29. package/dist/react/lib/paneReach.js.map +1 -0
  30. package/dist/react/master-detail/MasterDetail.d.ts +6 -3
  31. package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
  32. package/dist/react/master-detail/MasterDetail.js +3 -2
  33. package/dist/react/master-detail/MasterDetail.js.map +1 -1
  34. package/dist/react/media/videoEnhance.d.ts +1 -1
  35. package/dist/react/media/videoEnhance.js +1 -1
  36. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  37. package/dist/testing/phoneRules.js +3 -3
  38. package/dist/testing/phoneRules.js.map +1 -1
  39. package/package.json +2 -1
  40. package/scripts/guardrailsEnforce.spec.ts +25 -0
  41. package/scripts/guardrailsEnforce.ts +61 -0
  42. package/src/react/components/PaneSplit.spec.tsx +26 -3
  43. package/src/react/components/PaneSplit.tsx +26 -13
  44. package/src/react/components/PanelGrid.tsx +16 -11
  45. package/src/react/components/SplitPane.spec.tsx +10 -5
  46. package/src/react/components/SplitPane.tsx +3 -12
  47. package/src/react/components/paneSplit.css +8 -0
  48. package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
  49. package/src/react/filter-rail/railResize.spec.tsx +27 -5
  50. package/src/react/filter-rail/railResize.tsx +46 -15
  51. package/src/react/filter-rail/railWidth.ts +13 -8
  52. package/src/react/index.ts +1 -0
  53. package/src/react/lib/paneReach.spec.ts +58 -0
  54. package/src/react/lib/paneReach.ts +65 -0
  55. package/src/react/master-detail/MasterDetail.tsx +10 -5
  56. package/src/react/media/videoEnhance.ts +1 -1
  57. package/src/testing/phoneRules.ts +2 -2
@@ -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,
@@ -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 (
@@ -40,6 +40,14 @@
40
40
  min-height: 0;
41
41
  }
42
42
 
43
+ /* A shut pane (dragged to nothing — `lib/paneReach.ts`) draws nothing and takes no focus; its
44
+ content would otherwise spill out of a zero-wide track. The seam beside it is a sibling, so it
45
+ stays to drag the pane open again. */
46
+ .cb-panesplit__cell[data-shut] {
47
+ visibility: hidden;
48
+ overflow: hidden;
49
+ }
50
+
43
51
  .cb-panesplit__seam {
44
52
  flex: 0 0 auto;
45
53
  position: relative;
@@ -580,10 +580,14 @@ describe('FilterRail — resizeKey', () => {
580
580
  expect(globalThis.localStorage?.getItem('test:rail')).toBeNull();
581
581
  });
582
582
 
583
- test('the width is clamped — it cannot be dragged into a sliver', () => {
584
- globalThis.localStorage?.setItem('test:rail', '20');
585
- const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
583
+ test('a stored sliver is never drawn — it opens at the snap, or shut below half of it', () => {
584
+ globalThis.localStorage?.setItem('test:rail', '120');
585
+ const { container, unmount } = render(<FilterRail {...props} resizeKey='test:rail' />);
586
586
  expect((container.querySelector('nav') as HTMLElement).style.width).toBe('160px');
587
+ unmount();
588
+ globalThis.localStorage?.setItem('test:rail', '20');
589
+ const shut = render(<FilterRail {...props} resizeKey='test:rail' />);
590
+ expect((shut.container.querySelector('nav') as HTMLElement).style.width).toBe('0px');
587
591
  });
588
592
 
589
593
  test('no handle while collapsed', () => {
@@ -49,13 +49,35 @@ describe('useRailResize + RailResizeHandle', () => {
49
49
  expect(aside.style.width).toBe('250px');
50
50
  });
51
51
 
52
- test('a drag past either bound stops at it', () => {
52
+ // The owner, 2026-10-27: "let me drag them as far as I want including shutting a pane completely".
53
+ test('no wall: a drag reaches the whole row one way and shuts the rail the other', () => {
53
54
  const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
54
- fireEvent.pointerDown(handle as HTMLElement, { button: 0, clientX: 0, pointerId: 1 });
55
- fireEvent.pointerMove(handle as HTMLElement, { clientX: 5000, pointerId: 1 });
56
- expect(aside.style.width).toBe('640px');
57
- fireEvent.pointerMove(handle as HTMLElement, { clientX: 10, pointerId: 1 });
55
+ const h = handle as HTMLElement;
56
+ // happy-dom lays nothing out, so the row is the window's width.
57
+ const row = globalThis.innerWidth;
58
+ fireEvent.pointerDown(h, { button: 0, clientX: 0, pointerId: 1 });
59
+ fireEvent.pointerMove(h, { clientX: 5000, pointerId: 1 });
60
+ expect(aside.style.width).toBe(`${row}px`);
61
+ expect(aside.style.maxWidth).toBe('100%');
62
+ // Between half the snap and the snap it holds — no sliver of a rail.
63
+ fireEvent.pointerMove(h, { clientX: 100, pointerId: 1 });
64
+ expect(aside.style.width).toBe('160px');
65
+ fireEvent.pointerMove(h, { clientX: 60, pointerId: 1 });
66
+ fireEvent.pointerUp(h, { clientX: 60, pointerId: 1 });
67
+ expect(aside.style.width).toBe('0px');
68
+ expect(aside.style.visibility).toBe('hidden');
69
+ expect(globalThis.localStorage?.getItem('t:rail')).toBe('0');
70
+ // Shut, the handle is still there to drag it back, and an arrow opens it at the snap.
71
+ expect(h.style.visibility).toBe('visible');
72
+ fireEvent.keyDown(h, { key: 'ArrowRight' });
58
73
  expect(aside.style.width).toBe('160px');
74
+ expect(aside.style.visibility).toBe('');
75
+ });
76
+
77
+ test('a shut rail is remembered shut', () => {
78
+ globalThis.localStorage?.setItem('t:rail', '0');
79
+ const { aside } = parts(render(<Sidebar resizeKey='t:rail' />).container);
80
+ expect(aside.style.width).toBe('0px');
59
81
  });
60
82
 
61
83
  test('a right-button press is not a drag', () => {
@@ -13,15 +13,19 @@
13
13
  * reach;
14
14
  * - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
15
15
  * - **double-click resets** to the rail's own default width, and forgets the stored one;
16
- * - remembered on RELEASE — a drag is one decision, not sixty writes a second.
16
+ * - remembered on RELEASE — a drag is one decision, not sixty writes a second;
17
+ * - 🔴 **no wall** (the owner, 2026-10-27): the drag reaches the far side of the row the rail
18
+ * shares, and below half of `MIN_RAIL_PX` the rail SHUTS — `lib/paneReach.ts`, the rule every
19
+ * cursedbelt divider obeys. A shut rail draws nothing but its handle, at the edge, to drag back.
17
20
  *
18
- * Clamp and storage are `railWidth.ts`, so the width is announced through `preferenceSync`
19
- * like every other viewer preference.
21
+ * Storage is `railWidth.ts`, so the width is announced through `preferenceSync` like every other
22
+ * viewer preference.
20
23
  */
21
24
  import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
22
25
  import { useCallback, useRef, useState } from 'react';
23
26
  import { cn } from '../lib/cn.js';
24
- import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
27
+ import { reachSize, stepSize } from '../lib/paneReach.js';
28
+ import { MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
25
29
 
26
30
  export interface UseRailResizeOptions {
27
31
  /** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
@@ -37,9 +41,15 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
37
41
  railRef: RefObject<E | null>;
38
42
  /** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
39
43
  active: boolean;
40
- /** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
44
+ /** The dragged width in px (0 = shut), or `null` for "the rail's own default width". Null while inactive. */
41
45
  width: number | null;
42
- /** `{ width }` while there is a dragged width, else `undefined` — spread onto the rail's `style`. */
46
+ /** Dragged all the way shut. The rail must then draw nothing but the handle — `style` does it. */
47
+ shut: boolean;
48
+ /**
49
+ * Spread onto the rail's `style` — `undefined` at the default width. 🔴 Spread ALL of it, not
50
+ * just `width`: a shut rail is also borderless and `visibility: hidden` (its content would
51
+ * otherwise spill out of a zero-wide box), and a wide one is held to its row by `maxWidth`.
52
+ */
43
53
  style: CSSProperties | undefined;
44
54
  /** Forget the dragged width (what a double-click on the handle does). */
45
55
  reset: () => void;
@@ -50,6 +60,8 @@ export interface RailResize<E extends HTMLElement = HTMLElement> {
50
60
  onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
51
61
  /** The rail's current rendered width, for `aria-valuenow` before any drag. */
52
62
  measure: () => number;
63
+ /** The width of the row the rail shares — how far it can grow. */
64
+ room: () => number;
53
65
  };
54
66
  }
55
67
 
@@ -62,6 +74,12 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
62
74
  const [dragged, setDragged] = useState<number | null>(() => (resizeKey ? readRailWidth(resizeKey) : null));
63
75
  const active = Boolean(resizeKey) && enabled;
64
76
  const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
77
+ // The row the rail shares with the page it navigates. A box with no layout (a test DOM, a hidden
78
+ // tab) measures 0, so the window stands in — never 0, which would make every width "shut".
79
+ const room = useCallback(() => {
80
+ const row = railRef.current?.parentElement?.getBoundingClientRect().width ?? 0;
81
+ return row > 0 ? row : globalThis.innerWidth || MIN_RAIL_PX * 4;
82
+ }, []);
65
83
 
66
84
  const onPointerDown = useCallback(
67
85
  (event: ReactPointerEvent<HTMLElement>) => {
@@ -69,10 +87,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
69
87
  const handle = event.currentTarget;
70
88
  const startX = event.clientX;
71
89
  const startWidth = measure();
90
+ const total = room();
72
91
  let latest = startWidth;
73
92
  handle.setPointerCapture?.(event.pointerId);
74
93
  const move = (moveEvent: PointerEvent) => {
75
- latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
94
+ latest = Math.round(reachSize(startWidth + moveEvent.clientX - startX, MIN_RAIL_PX, total));
76
95
  setDragged(latest);
77
96
  };
78
97
  const end = () => {
@@ -87,7 +106,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
87
106
  handle.addEventListener('pointercancel', end);
88
107
  event.preventDefault();
89
108
  },
90
- [resizeKey, measure],
109
+ [resizeKey, measure, room],
91
110
  );
92
111
 
93
112
  const onKeyDown = useCallback(
@@ -97,11 +116,11 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
97
116
  const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
98
117
  if (!delta) return;
99
118
  event.preventDefault();
100
- const next = clampRailWidth((dragged ?? measure()) + delta);
119
+ const next = Math.round(stepSize(dragged ?? measure(), delta, MIN_RAIL_PX, room()));
101
120
  setDragged(next);
102
121
  writeRailWidth(resizeKey, next);
103
122
  },
104
- [resizeKey, dragged, measure],
123
+ [resizeKey, dragged, measure, room],
105
124
  );
106
125
 
107
126
  const reset = useCallback(() => {
@@ -110,16 +129,27 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
110
129
  }, [resizeKey]);
111
130
 
112
131
  const width = active ? dragged : null;
132
+ const shut = width === 0;
113
133
  return {
114
134
  railRef,
115
135
  active,
116
136
  width,
117
- style: width === null ? undefined : { width },
137
+ shut,
138
+ style: width === null ? undefined : shut ? SHUT_STYLE : { width, maxWidth: '100%' },
118
139
  reset,
119
- handle: { label, onPointerDown, onKeyDown, measure },
140
+ handle: { label, onPointerDown, onKeyDown, measure, room },
120
141
  };
121
142
  }
122
143
 
144
+ /** A shut rail: no width, no padding (a padded box is never narrower than its padding), no border,
145
+ * and nothing drawn but the handle, which opts back in. `overflow: visible`, because a rail that
146
+ * scrolls itself (`overflow-y-auto`) would clip its handle out of a zero-wide box — still in the
147
+ * DOM, never under the pointer; its content is hidden by `visibility` instead. */
148
+ const SHUT_STYLE: CSSProperties = { width: 0, minWidth: 0, padding: 0, borderRightWidth: 0, overflow: 'visible', visibility: 'hidden' };
149
+ /** The shut rail's handle: visible again, and wholly on the page side of the edge, so all 12px of
150
+ * it can be grabbed even when the rail sits against the window's edge. */
151
+ const SHUT_HANDLE_STYLE: CSSProperties = { visibility: 'visible', right: -12 };
152
+
123
153
  /**
124
154
  * The handle itself — a 12px hotspot centered on the rail's right border. The RAIL must be
125
155
  * `position: relative` (the handle is absolutely positioned against it). Renders nothing while
@@ -130,7 +160,7 @@ export function useRailResize<E extends HTMLElement = HTMLElement>({
130
160
  */
131
161
  export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResize; className?: string }) {
132
162
  if (!rail.active) return null;
133
- const { label, onPointerDown, onKeyDown, measure } = rail.handle;
163
+ const { label, onPointerDown, onKeyDown, measure, room } = rail.handle;
134
164
  return (
135
165
  // biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
136
166
  <div
@@ -138,9 +168,10 @@ export function RailResizeHandle({ rail, className, ...rest }: { rail: RailResiz
138
168
  role='separator'
139
169
  aria-orientation='vertical'
140
170
  aria-label={`Resize ${label}`}
141
- aria-valuemin={MIN_RAIL_PX}
142
- aria-valuemax={MAX_RAIL_PX}
171
+ aria-valuemin={0}
172
+ aria-valuemax={Math.round(room())}
143
173
  aria-valuenow={Math.round(rail.width ?? measure())}
174
+ {...(rail.shut ? { style: SHUT_HANDLE_STYLE } : {})}
144
175
  tabIndex={0}
145
176
  data-cb-rail-resize=''
146
177
  onPointerDown={onPointerDown}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * How wide the person dragged a sidebar — the pure half, so the clamp is testable without a
2
+ * How wide the person dragged a sidebar — the pure half, so the storage is testable without a
3
3
  * pointer.
4
4
  *
5
5
  * Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
@@ -13,22 +13,27 @@
13
13
  */
14
14
  import { announcePreference } from '../media-gallery/preferenceBus.js';
15
15
 
16
- /** Narrower than this and a row is an icon and an ellipsis. */
16
+ /**
17
+ * Narrower than this and a row is an icon and an ellipsis — so it is the SNAP, not a floor:
18
+ * dragged below half of it the rail shuts (width 0), between half and all of it it holds here
19
+ * (`lib/paneReach.ts`). There is no maximum: the owner, 2026-10-27, *"let me drag them as far as
20
+ * I want including shutting a pane completely"* — a rail grows until the page beside it is shut.
21
+ */
17
22
  export const MIN_RAIL_PX = 160;
18
- /** Wider than this and the rail is eating the page it navigates. */
19
- export const MAX_RAIL_PX = 640;
20
23
 
24
+ /** A width as stored: whole pixels, and snapped at the bottom like a drag (0 is a shut rail), so a
25
+ * sliver written by an older build or by hand is never drawn. No top — `maxWidth` holds it to its row. */
21
26
  export const clampRailWidth = (px: number): number =>
22
- Math.round(Math.min(MAX_RAIL_PX, Math.max(MIN_RAIL_PX, px)));
27
+ px < MIN_RAIL_PX / 2 ? 0 : Math.round(Math.max(MIN_RAIL_PX, px));
23
28
 
24
- /** The remembered width, or null for "the rail's default". Wrapped: `localStorage` throws in
25
- * a sandboxed iframe and in Safari's private mode. */
29
+ /** The remembered width (0 = shut), or null for "the rail's default". Wrapped: `localStorage`
30
+ * throws in a sandboxed iframe and in Safari's private mode. */
26
31
  export function readRailWidth(key: string): number | null {
27
32
  try {
28
33
  const raw = globalThis.localStorage?.getItem(key);
29
34
  if (raw == null) return null;
30
35
  const stored = Number(raw);
31
- return Number.isFinite(stored) && stored > 0 ? clampRailWidth(stored) : null;
36
+ return raw.trim() !== '' && Number.isFinite(stored) && stored >= 0 ? clampRailWidth(stored) : null;
32
37
  } catch {
33
38
  return null;
34
39
  }
@@ -674,6 +674,7 @@ export {
674
674
  type SplitPaneItem,
675
675
  type SplitPaneProps,
676
676
  } from './components/SplitPane.js';
677
+ export { reachSize, stepSize } from './lib/paneReach.js';
677
678
  export { Sparkline, type SparklineProps } from './components/Sparkline.js';
678
679
  export { StatStrip, type StatStripItem, type StatStripProps } from './components/StatStrip.js';
679
680
  export {