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
@@ -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
  }
@@ -61,6 +61,56 @@ describe('bindLongPressContextMenu', () => {
61
61
  expect(menus).toEqual([]);
62
62
  });
63
63
 
64
+ // The owner, 2026-10-27 (music): a tap that OPENED a folder popped the heading's view menu
65
+ // half a second later — the lift never bubbled through the bound element.
66
+ test('🔴 a lift heard anywhere ends the press — a node re-rendered away, a capture elsewhere', () => {
67
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
68
+ fireEvent.pointerUp(document.body, touch);
69
+ jest.advanceTimersByTime(LONG_PRESS_MS * 2);
70
+
71
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
72
+ fireEvent.pointerCancel(window, touch);
73
+ jest.advanceTimersByTime(LONG_PRESS_MS * 2);
74
+ expect(menus).toEqual([]);
75
+
76
+ // A lift of ANOTHER finger is not this press ending.
77
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
78
+ fireEvent.pointerUp(document.body, { ...touch, pointerId: 99 });
79
+ jest.advanceTimersByTime(LONG_PRESS_MS);
80
+ expect(menus.length).toBe(1);
81
+ });
82
+
83
+ test('🔴 a tap’s click, or a scroll, during the press is not a hold', () => {
84
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
85
+ fireEvent.click(document.body);
86
+ jest.advanceTimersByTime(LONG_PRESS_MS * 2);
87
+
88
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
89
+ fireEvent.scroll(document.body);
90
+ jest.advanceTimersByTime(LONG_PRESS_MS * 2);
91
+ expect(menus).toEqual([]);
92
+ });
93
+
94
+ test('🔴 when what was pressed has moved out from under the finger, it is not a hold on it', () => {
95
+ const elsewhere = document.createElement('p');
96
+ document.body.append(elsewhere);
97
+ const was = document.elementFromPoint;
98
+ try {
99
+ document.elementFromPoint = () => elsewhere;
100
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
101
+ jest.advanceTimersByTime(LONG_PRESS_MS);
102
+ expect(menus).toEqual([]);
103
+
104
+ document.elementFromPoint = () => child;
105
+ fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
106
+ jest.advanceTimersByTime(LONG_PRESS_MS);
107
+ expect(menus.length).toBe(1);
108
+ } finally {
109
+ document.elementFromPoint = was;
110
+ elsewhere.remove();
111
+ }
112
+ });
113
+
64
114
  test('the lift after a hold does not click what it was held on', () => {
65
115
  let clicks = 0;
66
116
  child.addEventListener('click', () => clicks++);
@@ -21,7 +21,16 @@
21
21
  * document listener can read it as a press outside the menu that just opened and shut it;
22
22
  * · `-webkit-touch-callout: none` while bound, so iOS does not put its own callout on top;
23
23
  * · a press inside `ignore` (a selector) is not a hold — the file tree's grip, where a finger
24
- * that stays down is about to drag.
24
+ * that stays down is about to drag;
25
+ * · 🔴 a press that ENDED is never a hold, wherever its end was heard. The owner, 2026-10-27, on
26
+ * music: *"clicking open folder is blocking the view with a popup that says 'Show hidden
27
+ * folders' and 'Collapse all folders' … clicking a folder to open or close it should not
28
+ * popup a menu."* A tap that opens a folder re-renders what was under the finger, and a lift
29
+ * can then reach a node that is gone, or an element that captured the pointer, without ever
30
+ * bubbling through the bound one — so the timer outlived the tap and fired the heading's view
31
+ * menu half a second later. So while a hold is pending the lift, the cancel, a scroll and the
32
+ * tap's own `click` are heard at the WINDOW, and the hold fires only if what was pressed is
33
+ * still under the finger (a layout that moved under a resting finger is not a hold on it).
25
34
  */
26
35
  /*
27
36
  * 🔴 No runtime import, React's included: a rail heading loads this module LAZILY (forge task
@@ -50,11 +59,36 @@ export function bindLongPressContextMenu(
50
59
  let firedUntil = 0;
51
60
  let dispatching = false;
52
61
 
62
+ /** The window-wide ears of a pending hold — bound at the press, dropped with the timer. */
63
+ const onWindowEnd = (event: Event) => {
64
+ // Another finger lifting is not this press ending.
65
+ const id = (event as PointerEvent).pointerId;
66
+ if (id !== undefined && id !== pointerId) return;
67
+ cancel();
68
+ };
69
+ const onWindowTap = () => cancel();
70
+ const listen = (on: boolean) => {
71
+ const method = on ? 'addEventListener' : 'removeEventListener';
72
+ window[method]('pointerup', onWindowEnd, true);
73
+ window[method]('pointercancel', onWindowEnd, true);
74
+ window[method]('click', onWindowTap, true);
75
+ window[method]('scroll', onWindowTap, true);
76
+ };
77
+
53
78
  const cancel = () => {
54
- if (timer !== undefined) clearTimeout(timer);
79
+ if (timer !== undefined) {
80
+ clearTimeout(timer);
81
+ listen(false);
82
+ }
55
83
  timer = undefined;
56
84
  };
57
85
 
86
+ /** Is what was pressed still what the finger rests on? `null` (no layout to ask) is a yes. */
87
+ const stillUnderFinger = (pressed: Element): boolean => {
88
+ const at = globalThis.document?.elementFromPoint?.(startX, startY);
89
+ return !at || pressed.contains(at) || at.contains(pressed);
90
+ };
91
+
58
92
  const swallowClick = (event: MouseEvent) => {
59
93
  window.removeEventListener('click', swallowClick, true);
60
94
  if (Date.now() > firedUntil) return;
@@ -64,7 +98,8 @@ export function bindLongPressContextMenu(
64
98
 
65
99
  const fire = () => {
66
100
  timer = undefined;
67
- if (!(target instanceof Element) || !target.isConnected) return;
101
+ listen(false);
102
+ if (!(target instanceof Element) || !target.isConnected || !stillUnderFinger(target)) return;
68
103
  firedUntil = Date.now() + AFTERMATH_MS;
69
104
  window.addEventListener('click', swallowClick, true);
70
105
  setTimeout(() => window.removeEventListener('click', swallowClick, true), AFTERMATH_MS);
@@ -94,6 +129,7 @@ export function bindLongPressContextMenu(
94
129
  startY = event.clientY;
95
130
  target = event.target;
96
131
  timer = setTimeout(fire, delayMs);
132
+ listen(true);
97
133
  };
98
134
  const onMove = (event: PointerEvent) => {
99
135
  if (timer === undefined || event.pointerId !== pointerId) return;
@@ -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 {
@@ -736,6 +737,9 @@ export {
736
737
  } from './components/sharing/types.js';
737
738
  export { type TabItem, Tabs, type TabsProps } from './components/Tabs.js';
738
739
  export { TagsInput, type TagsInputProps } from './components/TagsInput.js';
740
+ export { TagsEditor, type TagsEditorProps } from './components/TagsEditor.js';
741
+ export { TagsPromptDialog, type TagsPromptDialogProps } from './components/TagsPromptDialog.js';
742
+ export { mergeTagLists, parseTagList, tagListText } from './lib/tagList.js';
739
743
  export { TagStrip, type TagStripProps, type TagStripTag } from './components/TagStrip.js';
740
744
  export {
741
745
  Tooltip,
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { reachSize, stepSize } from './paneReach.js';
3
+
4
+ // The owner, 2026-10-27: every divider goes as far as the hand takes it, shutting a pane included.
5
+ describe('reachSize — where a pointer drag lands', () => {
6
+ test('has no maximum: the first pane can take the whole axis', () => {
7
+ expect(reachSize(1000, 160, 1000)).toBe(1000);
8
+ expect(reachSize(1400, 160, 1000)).toBe(1000);
9
+ expect(reachSize(0.99, 0.12, 1)).toBe(1);
10
+ });
11
+
12
+ test('shuts a pane dragged below half its minimum, either side of the seam', () => {
13
+ expect(reachSize(79, 160, 1000)).toBe(0);
14
+ expect(reachSize(-40, 160, 1000)).toBe(0);
15
+ expect(reachSize(921, 160, 1000)).toBe(1000);
16
+ });
17
+
18
+ test('holds at the minimum between half and all of it, so no pane is a sliver', () => {
19
+ expect(reachSize(100, 160, 1000)).toBe(160);
20
+ expect(reachSize(900, 160, 1000)).toBe(840);
21
+ });
22
+
23
+ test('passes every size between the two minimums through untouched', () => {
24
+ expect(reachSize(500, 160, 1000)).toBe(500);
25
+ expect(reachSize(160, 160, 1000)).toBe(160);
26
+ });
27
+
28
+ test('a minimum larger than half the axis shrinks to half, so both panes still fit', () => {
29
+ expect(reachSize(300, 800, 1000)).toBe(500);
30
+ });
31
+
32
+ test('a zero-width axis gives nothing rather than NaN or Infinity', () => {
33
+ expect(reachSize(10, 160, 0)).toBe(0);
34
+ expect(reachSize(Number.NaN, 160, 1000)).toBe(0);
35
+ });
36
+ });
37
+
38
+ describe('stepSize — where an arrow key lands', () => {
39
+ test('a step that would cross the minimum stops on it, and the next one shuts', () => {
40
+ expect(stepSize(170, -16, 160, 1000)).toBe(160);
41
+ expect(stepSize(160, -16, 160, 1000)).toBe(0);
42
+ });
43
+
44
+ test('the first step out of shut opens at the minimum, not a sliver', () => {
45
+ expect(stepSize(0, 16, 160, 1000)).toBe(160);
46
+ });
47
+
48
+ test('the far end shuts the other pane the same way, and opens it the same way', () => {
49
+ expect(stepSize(830, 16, 160, 1000)).toBe(840);
50
+ expect(stepSize(840, 16, 160, 1000)).toBe(1000);
51
+ expect(stepSize(1000, -16, 160, 1000)).toBe(840);
52
+ });
53
+
54
+ test('works in shares as well as pixels, through float noise', () => {
55
+ expect(stepSize(0.12000000001, -0.02, 0.12, 1)).toBe(0);
56
+ expect(stepSize(0.5, 0.02, 0.12, 1)).toBeCloseTo(0.52);
57
+ });
58
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * How far a divider may be dragged — the ONE rule every resizable split in cursedbelt obeys.
3
+ *
4
+ * The owner, 2026-10-27: *"For all dividers … that I can drag to size the panes, let me drag them
5
+ * as far as I want including shutting a pane completely … I don't want any panes to block me."*
6
+ * Station's Queue was the worked example: its task list stopped at 36% of the page, so the titles
7
+ * it was opened to read stayed cut off however hard the divider was pulled.
8
+ *
9
+ * So a pane has no MAXIMUM, ever — a pane grows until its neighbor is shut — and its minimum is
10
+ * not a wall but a snap, VS Code's sash and `react-resizable-panels`' collapsible panel alike:
11
+ *
12
+ * - dragged below HALF its minimum, a pane SHUTS (size 0);
13
+ * - between half and the whole minimum it HOLDS at the minimum, so a pane is never drawn as an
14
+ * unreadable sliver and a shut is a deliberate pull, not a twitch;
15
+ * - the same at the far end, for the pane on the other side of the seam.
16
+ *
17
+ * A shut pane opens again the way it closed: drag the seam back out (it is still there, at the
18
+ * edge), or step it with an arrow key. `PanelGrid`/`SplitPane` get the same rule from the library
19
+ * (every leaf is `collapsible`); `PaneSplit` and `useRailResize` draw their own seams and call
20
+ * these. An app that draws a seam of its own calls them too — guardrails' `no-hand-rolled-divider`
21
+ * refuses one that does not.
22
+ *
23
+ * Units are the caller's — pixels or a share of 1 — as long as all three agree.
24
+ */
25
+
26
+ /** Below this share of `total`, two sizes are the same size (a float's last bits are not a gap). */
27
+ const EPSILON = 1e-4;
28
+
29
+ /** The snap a pane gets, never more than half the axis — two minimums must fit side by side. */
30
+ function snapOf(min: number, total: number): number {
31
+ return Math.max(0, Math.min(min, total / 2));
32
+ }
33
+
34
+ /**
35
+ * Where a POINTER drag lands: the size the pointer asks for, with the snap applied at both ends.
36
+ * `size` is the first pane's (the one before the seam); the second pane is `total - size`.
37
+ */
38
+ export function reachSize(size: number, min: number, total: number): number {
39
+ if (!(total > 0) || !Number.isFinite(size)) return 0;
40
+ const snap = snapOf(min, total);
41
+ if (size < snap / 2) return 0;
42
+ if (size < snap) return snap;
43
+ if (size > total - snap / 2) return total;
44
+ if (size > total - snap) return total - snap;
45
+ return size;
46
+ }
47
+
48
+ /**
49
+ * Where a KEYBOARD step lands. A step that would cross a minimum stops ON it, and the next step
50
+ * the same way shuts the pane — so every size, including shut, is reachable from the keys, and
51
+ * a shut pane's first step out opens it at its minimum, not a sliver.
52
+ */
53
+ export function stepSize(size: number, delta: number, min: number, total: number): number {
54
+ if (!(total > 0) || !Number.isFinite(size) || delta === 0) return size;
55
+ const snap = snapOf(min, total);
56
+ const near = (a: number, b: number) => Math.abs(a - b) <= total * EPSILON;
57
+ if (delta < 0) {
58
+ if (near(size, total)) return total - snap;
59
+ if (size <= snap || near(size, snap)) return 0;
60
+ return Math.max(snap, size + delta);
61
+ }
62
+ if (near(size, 0)) return snap;
63
+ if (size >= total - snap || near(size, total - snap)) return total;
64
+ return Math.min(total - snap, size + delta);
65
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mergeTagLists, parseTagList, tagListText } from './tagList.js';
3
+
4
+ describe('tags on the clipboard', () => {
5
+ test('out as a comma line, back from commas, semicolons or lines — trimmed, each once', () => {
6
+ expect(tagListText(['beach', 'dog', 'golden hour'])).toBe('beach, dog, golden hour');
7
+ expect(parseTagList('beach, dog, golden hour')).toEqual(['beach', 'dog', 'golden hour']);
8
+ expect(parseTagList('Beach\ndog; Beach \r\n\n')).toEqual(['Beach', 'dog']);
9
+ expect(parseTagList(' ')).toEqual([]);
10
+ });
11
+
12
+ test("the app's own spelling rule decides what counts as the same tag", () => {
13
+ expect(parseTagList('Beach\ndogs; beach', (raw) => raw.trim().toLowerCase().replace(/s$/, ''))).toEqual(['beach', 'dog']);
14
+ });
15
+
16
+ test('a paste adds what the item does not have yet, in the pasted order', () => {
17
+ expect(mergeTagLists(['dog'], ['beach', 'dog', 'sunset'])).toEqual(['dog', 'beach', 'sunset']);
18
+ });
19
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Tags on the clipboard — one spelling for both directions (forge task 3773 in photos; lifted for
3
+ * music's "copying tags like photos app does", forge task 3812). Tags go out as `a, b, c` and come
4
+ * back from that, from one per line, or from a mix, so what one item's tags copied as pastes onto
5
+ * another, and so does a list typed in a note.
6
+ */
7
+
8
+ /** The tags as a line a person can paste anywhere. */
9
+ export const tagListText = (tags: readonly string[]): string => tags.join(', ');
10
+
11
+ /**
12
+ * The tags in a pasted text — split on commas, semicolons and line breaks, each passed through
13
+ * `normalize` (the app's own spelling rule; trimming by default), empties dropped, each once, in order.
14
+ */
15
+ export function parseTagList(text: string, normalize: (raw: string) => string = (raw) => raw.trim()): string[] {
16
+ const out: string[] = [];
17
+ for (const part of text.split(/[,;\n\r]+/)) {
18
+ const tag = normalize(part);
19
+ if (tag && !out.includes(tag)) out.push(tag);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ /** `pasted` merged after `current`, without what is already there. */
25
+ export function mergeTagLists(current: readonly string[], pasted: readonly string[]): string[] {
26
+ const next = [...current];
27
+ for (const tag of pasted) if (!next.includes(tag)) next.push(tag);
28
+ return next;
29
+ }
@@ -85,13 +85,17 @@ export interface MasterDetailProps {
85
85
  /** Distinct per page — SplitPane persists pane widths under it. */
86
86
  storageKey?: string;
87
87
  /**
88
- * The LIST pane's share of the width — default 30% between 16rem and 44%. A page whose detail is
88
+ * The LIST pane's share of the width — default 30%, snapping at 16rem. A page whose detail is
89
89
  * the thing being read (a task's text, an app's charts) gives the list less: station's Queue at
90
90
  * 1440 drew a 30% list of one-line titles beside a detail pane of four facts, so the text the
91
91
  * owner opened the page for had nowhere to go (forge task station-evolution, 2026-10-20). Any
92
- * CSS length or percentage; the reader can still drag the divider inside `min`/`max`.
92
+ * CSS length or percentage.
93
+ *
94
+ * 🔴 There is no `max`: the reader drags the divider as far as they like, shutting either pane
95
+ * (the owner, 2026-10-27 — station's Queue capped its list at 36% and cut its titles off). `min`
96
+ * is the snap `SplitPane` gives it, not a floor.
93
97
  */
94
- masterSize?: { default?: string; min?: string; max?: string };
98
+ masterSize?: { default?: string; min?: string };
95
99
  /** Rendered above the filters (a "New…" button, a count, a refresh). */
96
100
  listHeader?: ReactNode;
97
101
  className?: string;
@@ -320,8 +324,9 @@ export function MasterDetail({
320
324
  {...(storageKey ? { storageKey } : {})}
321
325
  separatorVariant="line"
322
326
  panels={[
323
- { id: "master", content: list, defaultSize: masterSize?.default ?? "30%", minSize: masterSize?.min ?? "16rem", maxSize: masterSize?.max ?? "44%" },
324
- { id: "detail", content: pane },
327
+ { id: "master", content: list, defaultSize: masterSize?.default ?? "30%", minSize: masterSize?.min ?? "16rem" },
328
+ // The detail snaps too, so the list can take the whole width without a sliver beside it.
329
+ { id: "detail", content: pane, minSize: "16rem" },
325
330
  ]}
326
331
  />
327
332
  </div>
@@ -32,3 +32,22 @@ describe('FocusViewer — which source the picture comes from', () => {
32
32
  }
33
33
  });
34
34
  });
35
+
36
+ /**
37
+ * 🔴 A host's "Make a GIF" command opens the viewer ON the GIF — owner, 2026-10-27: *"it is
38
+ * unclear how to choose part of a video to make into a gif"*, when the panel was one button
39
+ * inside a viewer reached by "Focus and snapshot".
40
+ */
41
+ describe('openGif', () => {
42
+ test('opens with the GIF section showing, from where the host was', () => {
43
+ render(
44
+ <FocusViewer onClose={() => {}} videoUrl="https://media.test/film.web.mp4" durationSeconds={60} startAt={12} openGif />,
45
+ );
46
+ expect(document.body.querySelector('section[aria-label="Make a GIF"]')).not.toBeNull();
47
+ });
48
+
49
+ test('without it, the viewer opens on the film and the GIF waits for its button', () => {
50
+ render(<FocusViewer onClose={() => {}} videoUrl="https://media.test/film.web.mp4" durationSeconds={60} startAt={12} />);
51
+ expect(document.body.querySelector('section[aria-label="Make a GIF"]')).toBeNull();
52
+ });
53
+ });
@@ -169,6 +169,13 @@ export interface FocusViewerProps {
169
169
  title?: string;
170
170
  /** Where the host's own player was when this opened. */
171
171
  startAt?: number;
172
+ /**
173
+ * Open with the GIF panel already showing, its section starting at {@link startAt}.
174
+ *
175
+ * 🔴 For a host's own "Make a GIF" command. Behind "Focus and snapshot" alone, the owner could
176
+ * not find it: *"it is unclear how to choose part of a video to make into a gif"* (2026-10-27).
177
+ */
178
+ openGif?: boolean;
172
179
  /** Duration the host already knows, so the surface is not blank until metadata lands. */
173
180
  durationSeconds?: number | null;
174
181
  /** Stills already kept beside this video, newest wherever the host wants them. */
@@ -228,6 +235,7 @@ export function FocusViewer({
228
235
  hlsUrl,
229
236
  title,
230
237
  startAt = 0,
238
+ openGif = false,
231
239
  durationSeconds,
232
240
  snapshots = [],
233
241
  onSaveSnapshot,
@@ -452,6 +460,15 @@ export function FocusViewer({
452
460
  }, [gif, loopSection, gifProgress]);
453
461
 
454
462
  /** Close the GIF panel, stopping a GIF still being made. */
463
+ // `openGif`: the section opens once the length is known, so it is clamped to the film.
464
+ const openedGif = useRef(false);
465
+ useEffect(() => {
466
+ if (!openGif || openedGif.current || duration <= 0) return;
467
+ openedGif.current = true;
468
+ void loadGifRecorder().catch(() => undefined);
469
+ setGif(gifSectionAt(startAt, duration));
470
+ }, [openGif, duration, startAt]);
471
+
455
472
  const closeGif = (): void => {
456
473
  gifAbort.current?.abort();
457
474
  setGif(null);
@@ -1,5 +1,6 @@
1
1
  import './pictureEditor.css';
2
2
  import React from 'react';
3
+ import { useEditorVideoSource } from './hlsSource.js';
3
4
  import { ActionBar } from '../components/ActionBar.js';
4
5
  import { Button } from '../components/Button.js';
5
6
  import { Modal } from '../components/Modal.js';
@@ -42,6 +43,8 @@ export interface FrameGrabberProps {
42
43
  * *"I see a black screen"* wearing a different dialog's clothes.
43
44
  */
44
45
  src: string;
46
+ /** The adaptive ladder's master playlist — a source on its own (`useEditorVideoSource`). */
47
+ hlsUrl?: string | undefined;
45
48
  /**
46
49
  * True while the app is still finding the source (a signed URL being minted, bytes being
47
50
  * fetched). It is the difference between "on its way" and "cannot be shown", and only the
@@ -143,6 +146,7 @@ type Grab = 'thumbnail' | 'picture';
143
146
  */
144
147
  export function FrameGrabber({
145
148
  src,
149
+ hlsUrl,
146
150
  resolving = false,
147
151
  name,
148
152
  startAt = 0,
@@ -179,11 +183,12 @@ export function FrameGrabber({
179
183
  */
180
184
  const cannotShow: string | null = resolving
181
185
  ? 'Finding this film…'
182
- : !src
186
+ : !src && !hlsUrl
183
187
  ? 'This file has no video source to play here, so there are no frames to pick from.'
184
- : failedSrc === src
188
+ : failedSrc === (hlsUrl || src)
185
189
  ? 'The film could not be loaded, so there are no frames to pick from.'
186
190
  : null;
191
+ const source = useEditorVideoSource(videoRef, { src, hlsUrl }, cannotShow === null);
187
192
 
188
193
  const canThumb = Boolean(onSaveThumbnail);
189
194
  const canSnap = Boolean(onSaveSnapshot);
@@ -482,8 +487,8 @@ export function FrameGrabber({
482
487
  <video
483
488
  ref={videoRef}
484
489
  // A new source is a new attempt — see `failedSrc`.
485
- key={src}
486
- src={src}
490
+ key={source.key}
491
+ src={source.src}
487
492
  crossOrigin='anonymous'
488
493
  playsInline
489
494
  preload='metadata'
@@ -506,7 +511,9 @@ export function FrameGrabber({
506
511
  if (startAt > 0) el.currentTime = startAt;
507
512
  }}
508
513
  onTimeUpdate={(event) => setAt(event.currentTarget.currentTime)}
509
- onError={() => setFailedSrc(src)}
514
+ onError={(event) => {
515
+ if (source.exhausted(event.currentTarget)) setFailedSrc(source.key);
516
+ }}
510
517
  />
511
518
  {grab === 'thumbnail' ? (
512
519
  <canvas
@@ -1,5 +1,6 @@
1
1
  import './videoChapterEditor.css';
2
2
  import React from 'react';
3
+ import { useEditorVideoSource } from './hlsSource.js';
3
4
  import { Bookmark, Trash2 } from 'lucide-react';
4
5
  import { cutTimecode } from 'cursedbelt-core/media/video-cuts';
5
6
  import { ActionBar } from '../components/ActionBar.js';
@@ -59,6 +60,8 @@ export interface VideoChapterEditorProps {
59
60
  * and the sections themselves stay editable — they are metadata beside the film.
60
61
  */
61
62
  src: string;
63
+ /** The adaptive ladder's master playlist — a source on its own (`useEditorVideoSource`). */
64
+ hlsUrl?: string | undefined;
62
65
  /** Shown until the first frame decodes — a black box reads as a broken player. */
63
66
  posterUrl?: string;
64
67
  /**
@@ -96,6 +99,7 @@ export interface VideoChapterEditorProps {
96
99
 
97
100
  export function VideoChapterEditor({
98
101
  src,
102
+ hlsUrl,
99
103
  posterUrl,
100
104
  resolving = false,
101
105
  name,
@@ -141,11 +145,12 @@ export function VideoChapterEditor({
141
145
  */
142
146
  const cannotShow: string | null = resolving
143
147
  ? 'Finding this film…'
144
- : !src
148
+ : !src && !hlsUrl
145
149
  ? 'This file has no video source to play here, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
146
- : failedSrc === src
150
+ : failedSrc === (hlsUrl || src)
147
151
  ? 'The film could not be loaded, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
148
152
  : null;
153
+ const source = useEditorVideoSource(videoRef, { src, hlsUrl }, cannotShow === null);
149
154
 
150
155
  const spans = React.useMemo(() => chapterSpans(chapters, duration), [chapters, duration]);
151
156
  /** What Save would send. Also what `isDirty` compares, so the two can never disagree. */
@@ -310,8 +315,8 @@ export function VideoChapterEditor({
310
315
  ref={videoRef}
311
316
  className='cbch-video'
312
317
  // A new source is a new attempt — see `failedSrc`.
313
- key={src}
314
- src={src}
318
+ key={source.key}
319
+ src={source.src}
315
320
  poster={posterUrl}
316
321
  controls
317
322
  playsInline
@@ -323,7 +328,9 @@ export function VideoChapterEditor({
323
328
  }}
324
329
  onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
325
330
  onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
326
- onError={() => setFailedSrc(src)}
331
+ onError={(event) => {
332
+ if (source.exhausted(event.currentTarget)) setFailedSrc(source.key);
333
+ }}
327
334
  />
328
335
  ) : (
329
336
  /* In the film's own place and at its own size — a sentence under a black rectangle
@@ -426,7 +433,10 @@ export function VideoChapterEditor({
426
433
  ? spans.length === 0
427
434
  ? `${cutTimecode(duration)} of video, in one unnamed part`
428
435
  : `${spans.length} section${spans.length === 1 ? '' : 's'} across ${cutTimecode(duration)}`
429
- : 'Reading the clip…'}
436
+ : // 🔴 Only a mounted film is being read — see the same line in `VideoCutEditor`.
437
+ cannotShow === null
438
+ ? 'Reading the clip…'
439
+ : 'Its length is not known.'}
430
440
  </p>
431
441
 
432
442
  {chapters.length === 0 ? (