cursedbelt 5.78.0 → 5.79.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 (138) hide show
  1. package/dist/react/canvas-hud/HudControls.d.ts +8 -2
  2. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
  3. package/dist/react/canvas-hud/HudControls.js +6 -3
  4. package/dist/react/canvas-hud/HudControls.js.map +1 -1
  5. package/dist/react/components/AppShell.d.ts +14 -0
  6. package/dist/react/components/AppShell.d.ts.map +1 -1
  7. package/dist/react/components/AppShell.js +7 -31
  8. package/dist/react/components/AppShell.js.map +1 -1
  9. package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
  10. package/dist/react/components/ChipFilterStrip.js +2 -18
  11. package/dist/react/components/ChipFilterStrip.js.map +1 -1
  12. package/dist/react/components/KanbanBoard.d.ts +55 -0
  13. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  14. package/dist/react/components/KanbanBoard.js +89 -0
  15. package/dist/react/components/KanbanBoard.js.map +1 -0
  16. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  17. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  18. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  19. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  20. package/dist/react/components/SelectField.d.ts +18 -48
  21. package/dist/react/components/SelectField.d.ts.map +1 -1
  22. package/dist/react/components/SelectField.js +88 -11
  23. package/dist/react/components/SelectField.js.map +1 -1
  24. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  25. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  26. package/dist/react/components/SelectFieldImpl.js +26 -0
  27. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  28. package/dist/react/components/filterChip.d.ts +8 -0
  29. package/dist/react/components/filterChip.d.ts.map +1 -0
  30. package/dist/react/components/filterChip.js +20 -0
  31. package/dist/react/components/filterChip.js.map +1 -0
  32. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  33. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  34. package/dist/react/hooks/pointerDnd.js +101 -9
  35. package/dist/react/hooks/pointerDnd.js.map +1 -1
  36. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  37. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  38. package/dist/react/hooks/pragmaticDnd.js +9 -3
  39. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  40. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  41. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  42. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  43. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  44. package/dist/react/index.d.ts +3 -0
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +3 -0
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/lib/selectField.d.ts +72 -0
  49. package/dist/react/lib/selectField.d.ts.map +1 -0
  50. package/dist/react/lib/selectField.js +23 -0
  51. package/dist/react/lib/selectField.js.map +1 -0
  52. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  53. package/dist/react/media/VideoChapterEditor.js +2 -1
  54. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  55. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoCutEditor.js +2 -1
  57. package/dist/react/media/VideoCutEditor.js.map +1 -1
  58. package/dist/react/media/denseGallery.css +2 -0
  59. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  60. package/dist/react/media-gallery/MediaGallery.js +6 -7
  61. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  62. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  64. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  65. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  66. package/dist/react/nav/CommandBar.js +2 -1
  67. package/dist/react/nav/CommandBar.js.map +1 -1
  68. package/dist/react/palette/CommandPalette.d.ts +15 -1
  69. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  70. package/dist/react/palette/CommandPalette.js +58 -8
  71. package/dist/react/palette/CommandPalette.js.map +1 -1
  72. package/dist/react/palette/Kbd.d.ts +15 -0
  73. package/dist/react/palette/Kbd.d.ts.map +1 -0
  74. package/dist/react/palette/Kbd.js +17 -0
  75. package/dist/react/palette/Kbd.js.map +1 -0
  76. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  77. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  78. package/dist/react/palette/ShortcutSheet.js +15 -0
  79. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  80. package/dist/react/palette/commandSources.d.ts +14 -0
  81. package/dist/react/palette/commandSources.d.ts.map +1 -1
  82. package/dist/react/palette/commandSources.js +11 -1
  83. package/dist/react/palette/commandSources.js.map +1 -1
  84. package/dist/react/palette/index.d.ts +4 -2
  85. package/dist/react/palette/index.d.ts.map +1 -1
  86. package/dist/react/palette/index.js +2 -0
  87. package/dist/react/palette/index.js.map +1 -1
  88. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  89. package/dist/styles-areas/canvas-hud.css +2 -2
  90. package/dist/styles-areas/kanban.css +31 -0
  91. package/dist/styles-areas/palette.css +3 -3
  92. package/dist/styles-areas/workbench.css +15 -13
  93. package/dist/styles-utilities.css +42 -0
  94. package/package.json +3 -2
  95. package/scripts/guardrails-baseline.json +2 -1
  96. package/scripts/guardrailsEnforce.spec.ts +62 -0
  97. package/scripts/guardrailsEnforce.ts +77 -6
  98. package/scripts/styleAreas.ts +2 -0
  99. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  100. package/src/react/canvas-hud/HudControls.tsx +33 -9
  101. package/src/react/components/AppShell.spec.tsx +40 -0
  102. package/src/react/components/AppShell.tsx +25 -27
  103. package/src/react/components/ChipFilterStrip.tsx +2 -38
  104. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  105. package/src/react/components/KanbanBoard.tsx +270 -0
  106. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  107. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  108. package/src/react/components/SelectField.spec.tsx +117 -0
  109. package/src/react/components/SelectField.tsx +131 -166
  110. package/src/react/components/SelectFieldImpl.tsx +140 -0
  111. package/src/react/components/filterChip.tsx +43 -0
  112. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  113. package/src/react/hooks/pointerDnd.ts +103 -8
  114. package/src/react/hooks/pragmaticDnd.ts +13 -2
  115. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  116. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  117. package/src/react/index.ts +3 -0
  118. package/src/react/lib/floatingStyles.spec.ts +1 -1
  119. package/src/react/lib/selectField.ts +96 -0
  120. package/src/react/media/VideoChapterEditor.tsx +3 -2
  121. package/src/react/media/VideoCutEditor.tsx +3 -2
  122. package/src/react/media/denseGallery.css +2 -0
  123. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  124. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  125. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  126. package/src/react/nav/CommandBar.tsx +2 -3
  127. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  128. package/src/react/palette/CommandPalette.tsx +153 -13
  129. package/src/react/palette/Kbd.tsx +46 -0
  130. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  131. package/src/react/palette/ShortcutSheet.tsx +110 -0
  132. package/src/react/palette/commandSources.tsx +30 -1
  133. package/src/react/palette/index.ts +9 -0
  134. package/src/styles-areas/canvas-hud.css +2 -2
  135. package/src/styles-areas/kanban.css +31 -0
  136. package/src/styles-areas/palette.css +3 -3
  137. package/src/styles-areas/workbench.css +15 -13
  138. package/src/styles-utilities.css +42 -0
@@ -12,6 +12,9 @@ import {
12
12
  subscribePointerDragMonitor,
13
13
  TOUCH_ACTIVATION_DELAY_MS,
14
14
  TOUCH_ACTIVATION_TOLERANCE_PX,
15
+ registerTouchEdgeScroll,
16
+ TOUCH_EDGE_MAX_STEP_PX,
17
+ touchEdgeStep,
15
18
  wireTouchDragSource,
16
19
  withinTolerance,
17
20
  } from './pointerDnd.js';
@@ -239,17 +242,41 @@ describe('createTouchDragSession', () => {
239
242
  });
240
243
 
241
244
  describe('wireTouchDragSource (DOM glue)', () => {
242
- test('sets touch-action none while wired and restores it on cleanup', () => {
245
+ test('leaves the element pannable: a move before the long press is a scroll, one after it is the drag', async () => {
243
246
  const element = document.createElement('div');
244
247
  element.style.touchAction = 'pan-y';
248
+ document.body.append(element);
245
249
  const cleanup = wireTouchDragSource({
246
250
  element,
247
251
  getInitialData: () => ({}),
248
252
  onDragStateChange: () => {},
253
+ delayMs: 5,
249
254
  });
250
- expect(element.style.touchAction).toBe('none');
251
- cleanup();
255
+ // The page keeps its own touch-action — `none` here made a phone board unscrollable from a card.
252
256
  expect(element.style.touchAction).toBe('pan-y');
257
+
258
+ const down = new Event('pointerdown', { bubbles: true, cancelable: true });
259
+ Object.assign(down, { pointerType: 'touch', isPrimary: true, pointerId: 7, clientX: 10, clientY: 10 });
260
+ const touchMove = () => {
261
+ const e = new Event('touchmove', { bubbles: true, cancelable: true });
262
+ element.dispatchEvent(e);
263
+ return e.defaultPrevented;
264
+ };
265
+ const menu = () => {
266
+ const e = new Event('contextmenu', { bubbles: true, cancelable: true });
267
+ element.dispatchEvent(e);
268
+ return e.defaultPrevented;
269
+ };
270
+
271
+ expect(menu()).toBe(false); // no press, no interference
272
+ element.dispatchEvent(down);
273
+ expect(touchMove()).toBe(false); // still waiting: the browser may scroll
274
+ expect(menu()).toBe(true); // the long press is ours, not Android's menu
275
+ await Bun.sleep(20);
276
+ expect(touchMove()).toBe(true); // the drag is live: the pan is cancelled
277
+ cleanup();
278
+ expect(touchMove()).toBe(false);
279
+ element.remove();
253
280
  });
254
281
 
255
282
  test('a long-press touch drag reaches the shared monitor and toggles the target', async () => {
@@ -309,3 +336,62 @@ describe('wireTouchDragSource (DOM glue)', () => {
309
336
  target.remove();
310
337
  });
311
338
  });
339
+
340
+ describe('touch edge auto-scroll', () => {
341
+ const box = { left: 0, right: 400, top: 100, bottom: 700 };
342
+
343
+ test('nothing in the middle; faster the nearer the edge; full speed past it, while level with the box', () => {
344
+ expect(touchEdgeStep(box, 200, 400)).toEqual({ dx: 0, dy: 0 });
345
+ expect(touchEdgeStep(box, 399, 400).dx).toBe(TOUCH_EDGE_MAX_STEP_PX);
346
+ expect(touchEdgeStep(box, 376, 400).dx).toBeGreaterThan(0);
347
+ expect(touchEdgeStep(box, 376, 400).dx).toBeLessThan(TOUCH_EDGE_MAX_STEP_PX);
348
+ expect(touchEdgeStep(box, 2, 400).dx).toBeLessThan(0);
349
+ expect(touchEdgeStep(box, 200, 690).dy).toBeGreaterThan(0);
350
+ // Past the right side (a thumb at the screen edge, the scroller inset by a gutter): full speed.
351
+ expect(touchEdgeStep(box, 420, 400)).toEqual({ dx: TOUCH_EDGE_MAX_STEP_PX, dy: 0 });
352
+ // Above the box and past its side: level with neither, so it scrolls only toward the top.
353
+ expect(touchEdgeStep(box, 420, 50)).toEqual({ dx: 0, dy: 0 });
354
+ expect(touchEdgeStep(box, 200, 50)).toEqual({ dx: 0, dy: -TOUCH_EDGE_MAX_STEP_PX });
355
+ });
356
+
357
+ test("a touch drag held at a scroller's edge scrolls it, snapping off until the drop", async () => {
358
+ const container = document.createElement('div');
359
+ container.style.scrollSnapType = 'x mandatory';
360
+ const card = document.createElement('div');
361
+ container.append(card);
362
+ document.body.append(container);
363
+ container.getBoundingClientRect = () => ({ ...box, x: 0, y: 100, width: 400, height: 600, toJSON: () => ({}) }) as DOMRect;
364
+ const scrolled: number[] = [];
365
+ container.scrollBy = ((x: number) => scrolled.push(x)) as typeof container.scrollBy;
366
+ const originalFromPoint = document.elementsFromPoint;
367
+ document.elementsFromPoint = () => [];
368
+
369
+ const off = registerTouchEdgeScroll(container);
370
+ const unwire = wireTouchDragSource({ element: card, getInitialData: () => ({}), onDragStateChange: () => {}, delayMs: 5 });
371
+ const pointer = (type: string, x: number, y: number) => {
372
+ const e = new Event(type, { bubbles: true, cancelable: true });
373
+ Object.assign(e, { pointerType: 'touch', isPrimary: true, pointerId: 3, clientX: x, clientY: y });
374
+ return e;
375
+ };
376
+ try {
377
+ card.dispatchEvent(pointer('pointerdown', 200, 400));
378
+ await Bun.sleep(20);
379
+ window.dispatchEvent(pointer('pointermove', 395, 400)); // carried to the right edge, then held
380
+ await Bun.sleep(120);
381
+ expect(scrolled.length).toBeGreaterThan(1);
382
+ expect(scrolled.every((dx) => dx > 0)).toBe(true);
383
+ expect(container.style.scrollSnapType).toBe('none');
384
+ window.dispatchEvent(pointer('pointerup', 395, 400));
385
+ const after = scrolled.length;
386
+ await Bun.sleep(60);
387
+ expect(scrolled.length).toBe(after); // the drop stops it
388
+ expect(container.style.scrollSnapType).toBe('x mandatory');
389
+ } finally {
390
+ unwire();
391
+ off();
392
+ document.elementsFromPoint = originalFromPoint;
393
+ container.remove();
394
+ }
395
+ });
396
+ });
397
+
@@ -324,9 +324,15 @@ function elementsFromPointSafe(x: number, y: number): Element[] {
324
324
  /**
325
325
  * Wire touch-drag onto one draggable element, using the shared registry + monitor bus.
326
326
  * Only `touch` pointers are handled — mouse/pen still drive pragmatic's native adapter,
327
- * so the two engines never fight over the same gesture. Sets `touch-action: none` on the
328
- * element while wired (restored on cleanup) so a long-press isn't stolen by the browser's
329
- * scroll/gesture handling — the same guard the `@dnd-kit` sensors relied on.
327
+ * so the two engines never fight over the same gesture.
328
+ *
329
+ * 🔴 The element stays PANNABLE. A finger that moves before the long press fires is a scroll,
330
+ * and the browser runs it; only once the drag is live does a non-passive `touchmove` on the
331
+ * element cancel the browser's pan, so the finger drives the drag instead — the way
332
+ * `@dnd-kit`'s TouchSensor does it. This used to set `touch-action: none` on the element, and
333
+ * on a phone a board whose columns are full of cards could not be swiped or scrolled from any
334
+ * card (forge task 3033, seen on vault-stage at 390). While a press is in flight the
335
+ * long-press `contextmenu` (Android's menu) is cancelled too.
330
336
  *
331
337
  * The `pointerdown` listener lives on the element; the `pointermove`/`up`/`cancel`/
332
338
  * `keydown` listeners are only attached on the `window` for the life of one interaction
@@ -358,8 +364,6 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
358
364
 
359
365
  const onPointerMove = (event: PointerEvent): void => {
360
366
  if (event.pointerId !== activePointerId) return;
361
- // Once dragging, suppress the browser's scroll so the finger drives the drag.
362
- if (session.isActive() && event.cancelable) event.preventDefault();
363
367
  session.pointerMove({ x: event.clientX, y: event.clientY });
364
368
  if (session.status() === 'idle') detach(); // a pre-activation move was a scroll
365
369
  };
@@ -406,14 +410,105 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
406
410
  session.pointerDown({ x: event.clientX, y: event.clientY });
407
411
  };
408
412
 
409
- const prevTouchAction = element.style.touchAction;
410
- element.style.touchAction = 'none';
413
+ // Touch events go to the element the finger came down on for the whole gesture, so the
414
+ // element's own listener sees every move. Non-passive: it must be able to cancel the pan.
415
+ const onTouchMove = (event: TouchEvent): void => {
416
+ if (session.isActive() && event.cancelable) event.preventDefault();
417
+ };
418
+ const onContextMenu = (event: Event): void => {
419
+ if (session.status() !== 'idle') event.preventDefault();
420
+ };
411
421
  element.addEventListener('pointerdown', onPointerDown);
422
+ element.addEventListener('touchmove', onTouchMove, { passive: false });
423
+ element.addEventListener('contextmenu', onContextMenu);
412
424
 
413
425
  return () => {
414
426
  session.cancel();
415
427
  detach();
416
- element.style.touchAction = prevTouchAction;
417
428
  element.removeEventListener('pointerdown', onPointerDown);
429
+ element.removeEventListener('touchmove', onTouchMove);
430
+ element.removeEventListener('contextmenu', onContextMenu);
431
+ };
432
+ }
433
+
434
+ // --- Edge auto-scroll for touch drags -------------------------------------------------
435
+
436
+ /** How near a scroller's edge (px) a touch drag must come before it scrolls, and its top speed. */
437
+ export const TOUCH_EDGE_PX = 48;
438
+ export const TOUCH_EDGE_MAX_STEP_PX = 14;
439
+
440
+ interface EdgeRect {
441
+ left: number;
442
+ right: number;
443
+ top: number;
444
+ bottom: number;
445
+ }
446
+
447
+ /**
448
+ * How far one frame scrolls a scroller whose box is `rect` while a finger drags at (x, y):
449
+ * nothing in the middle, faster the nearer the edge, full speed at or PAST it — a thumb at the
450
+ * screen's edge is often outside a scroller inset by the page's gutter (vault-stage at 390: the
451
+ * strip ends at 374, the thumb sat at 385). An axis only moves while the finger is level with the
452
+ * box on the other one. Pure, so the feel is tested without a layout.
453
+ */
454
+ export function touchEdgeStep(rect: EdgeRect, x: number, y: number): { dx: number; dy: number } {
455
+ const ramp = (into: number) => Math.round(TOUCH_EDGE_MAX_STEP_PX * Math.min(1, 1 - into / TOUCH_EDGE_PX));
456
+ let dx = 0;
457
+ let dy = 0;
458
+ if (y >= rect.top && y <= rect.bottom) {
459
+ if (x < rect.left + TOUCH_EDGE_PX) dx = -ramp(x - rect.left);
460
+ else if (x > rect.right - TOUCH_EDGE_PX) dx = ramp(rect.right - x);
461
+ }
462
+ if (x >= rect.left && x <= rect.right) {
463
+ if (y < rect.top + TOUCH_EDGE_PX) dy = -ramp(y - rect.top);
464
+ else if (y > rect.bottom - TOUCH_EDGE_PX) dy = ramp(rect.bottom - y);
465
+ }
466
+ return { dx: dx || 0, dy: dy || 0 };
467
+ }
468
+
469
+ /**
470
+ * Scroll `container` while a TOUCH drag holds near its edge — what pragmatic's
471
+ * `autoScrollForElements` does for a mouse, which the touch bridge never reaches. Without it a
472
+ * phone board whose columns are each a screen wide could not carry a card to any other column
473
+ * (forge task 3033). A finger held still keeps scrolling (one step a frame); scroll snapping is
474
+ * suspended while it does, and the container settles back onto its snap points on the drop.
475
+ */
476
+ export function registerTouchEdgeScroll(container: HTMLElement): CleanupFn {
477
+ let point: PointerPoint | null = null;
478
+ let frame = 0;
479
+ let snapWas: string | null = null;
480
+ const tick = (): void => {
481
+ frame = 0;
482
+ if (!point) return;
483
+ const { dx, dy } = touchEdgeStep(container.getBoundingClientRect(), point.x, point.y);
484
+ if (dx !== 0 || dy !== 0) {
485
+ if (snapWas === null) {
486
+ snapWas = container.style.scrollSnapType;
487
+ container.style.scrollSnapType = 'none';
488
+ }
489
+ container.scrollBy(dx, dy);
490
+ }
491
+ frame = requestAnimationFrame(tick);
492
+ };
493
+ const stop = (): void => {
494
+ point = null;
495
+ if (frame) cancelAnimationFrame(frame);
496
+ frame = 0;
497
+ if (snapWas !== null) {
498
+ container.style.scrollSnapType = snapWas;
499
+ snapWas = null;
500
+ }
501
+ };
502
+ const unsubscribe = subscribePointerDragMonitor((event) => {
503
+ if (event.phase === 'drop') {
504
+ stop();
505
+ return;
506
+ }
507
+ point = { x: event.current.input.clientX, y: event.current.input.clientY };
508
+ if (!frame) frame = requestAnimationFrame(tick);
509
+ });
510
+ return () => {
511
+ unsubscribe();
512
+ stop();
418
513
  };
419
514
  }
@@ -23,6 +23,7 @@ import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hi
23
23
  import { useEffect, useRef, useState } from 'react';
24
24
  import {
25
25
  registerPointerDropTarget,
26
+ registerTouchEdgeScroll,
26
27
  subscribePointerDragMonitor,
27
28
  wireTouchDragSource,
28
29
  } from './pointerDnd.js';
@@ -105,11 +106,21 @@ export function findScrollContainer(start: Element | null): Element | null {
105
106
  // auto-scroll exactly ONCE (double-registering would compound the scroll velocity).
106
107
  const autoScrollRegistry = new Map<Element, { count: number; cleanup: CleanupFn }>();
107
108
 
108
- /** Register `element` as an auto-scroll container for the drag engine (ref-counted). */
109
+ /**
110
+ * Register `element` as an auto-scroll container for the drag engine (ref-counted) — for a
111
+ * mouse (pragmatic's own) and for a touch drag (`registerTouchEdgeScroll`, forge task 3033).
112
+ */
109
113
  export function registerAutoScroll(element: Element): CleanupFn {
110
114
  const existing = autoScrollRegistry.get(element);
111
115
  if (existing) existing.count += 1;
112
- else autoScrollRegistry.set(element, { count: 1, cleanup: autoScrollForElements({ element }) });
116
+ else
117
+ autoScrollRegistry.set(element, {
118
+ count: 1,
119
+ cleanup: combine(
120
+ autoScrollForElements({ element }),
121
+ element instanceof HTMLElement ? registerTouchEdgeScroll(element) : () => {},
122
+ ),
123
+ });
113
124
  let released = false;
114
125
  return () => {
115
126
  if (released) return;
@@ -0,0 +1,30 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
5
+ * `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
6
+ * (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
7
+ * measures 0, so the desktop stack drops back to the corner by itself.
8
+ *
9
+ * `AppShell` uses it for its dock and `PlaceBar`; an app that draws its own bottom bar without
10
+ * the shell (the vault's phone `PlaceBar`, forge task 3033) puts the returned ref on the bar's
11
+ * wrapper. Its own module so that app does not pull the shell into its entry chunk.
12
+ */
13
+ export function usePublishedBottomChrome(shown: boolean) {
14
+ const ref = useRef<HTMLDivElement | null>(null);
15
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
16
+ useLayoutEffect(() => {
17
+ const el = ref.current;
18
+ const root = document.documentElement;
19
+ if (!el) return;
20
+ const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
21
+ publish();
22
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
23
+ observer?.observe(el);
24
+ return () => {
25
+ observer?.disconnect();
26
+ root.style.removeProperty('--cb-bottom-chrome-h');
27
+ };
28
+ }, [shown]);
29
+ return ref;
30
+ }
@@ -110,17 +110,19 @@ describe('useScrollMemory', () => {
110
110
  */
111
111
  test('a list growing page by page is followed past the deadline, as long as each page lands within it', async () => {
112
112
  const s = fakeScroller(20_000);
113
- const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={60} />);
113
+ const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={150} />);
114
114
  s.scrollTo(15_000);
115
115
  const list = { state: window.history.state, path: '/' };
116
116
  push('/item/7');
117
- rerender(<Shell at='/item/7' el={s.el} deadlineMs={60} />);
117
+ rerender(<Shell at='/item/7' el={s.el} deadlineMs={150} />);
118
118
 
119
119
  s.grow(3_000);
120
120
  back(list);
121
- rerender(<Shell at='/' el={s.el} deadlineMs={60} />);
122
- // Five pages, 40ms apart: 200ms in all — well past 60ms from the start, never 60ms without a page.
123
- for (const height of [6_000, 9_000, 12_000, 15_000, 20_000]) {
121
+ rerender(<Shell at='/' el={s.el} deadlineMs={150} />);
122
+ // Eight pages, 40ms apart: 320ms in all — over twice the 150ms deadline from the start, and
123
+ // never near 150ms without a page. (A 60ms deadline left a 40ms sleep 1.5x headroom, and it
124
+ // went red under a busy machine's full gate, 2026-10-13.)
125
+ for (const height of [5_000, 7_000, 9_000, 11_000, 13_000, 15_000, 17_000, 20_000]) {
124
126
  await act(() => Bun.sleep(40));
125
127
  s.grow(height);
126
128
  }
@@ -127,6 +127,7 @@ export {
127
127
  // state, task 2275) must not have to import the media gallery — and its optional spreadsheet
128
128
  // peer — to hear a `RailSection` toggle.
129
129
  export { onPreferenceSeed, type PreferenceSeedListener } from './media-gallery/preferenceBus.js';
130
+ export { useRememberedPreference } from './media-gallery/rememberedPreference.js';
130
131
  export { onPreferenceWrite, type PreferenceListener, seedPreferences } from './media-gallery/preferenceSync.js';
131
132
  export {
132
133
  type ScrollMemoryOptions,
@@ -139,6 +140,7 @@ export {
139
140
  type AmbientIntensity,
140
141
  } from './components/AmbientBackdrop.js';
141
142
  export { AppShell, type AppShellProps } from './components/AppShell.js';
143
+ export { usePublishedBottomChrome } from './hooks/usePublishedBottomChrome.js';
142
144
  export { BannerLane, type BannerLaneProps, type ShellBanner } from './components/BannerLane.js';
143
145
  export { AuthGate, type AuthGateProps, notifyUnauthorized } from './AuthGate.js';
144
146
  export { AutocompleteField, type AutocompleteProps } from './components/Autocomplete.js';
@@ -155,6 +157,7 @@ export {
155
157
  } from './components/ChartTablePanel.js';
156
158
  export * from './companion-link/index.js';
157
159
  export { CardTapTarget, type CardTapTargetProps } from './components/CardTapTarget.js';
160
+ export { KanbanBoard, type KanbanBoardProps, type KanbanColumn } from './components/KanbanBoard.js';
158
161
  export { EntityCard, type EntityCardProps } from './components/EntityCard.js';
159
162
  export { CardDisplayControls, type CardDisplayControlsProps } from './components/CardDisplayControls.js';
160
163
  export {
@@ -27,7 +27,7 @@ describe('the floating layer — DESIGN.md §5 containment', () => {
27
27
  'components/Popover.tsx',
28
28
  'components/DropdownMenu.tsx',
29
29
  'components/HoverCard.tsx',
30
- 'components/SelectField.tsx',
30
+ 'components/SelectFieldImpl.tsx',
31
31
  'components/ComboboxField.tsx',
32
32
  'components/Autocomplete.tsx',
33
33
  'components/TooltipImpl.tsx',
@@ -0,0 +1,96 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from './cn.js';
3
+ import { type ControlSize, fieldControlClass } from './field.js';
4
+
5
+ /** Shared by `components/SelectField.tsx` (the light trigger) and `components/SelectFieldImpl.tsx`
6
+ * (the lazily loaded Radix select) — here so the two draw the same box and neither widens the
7
+ * public surface. */
8
+
9
+ export interface SelectOption {
10
+ value: string;
11
+ label: ReactNode;
12
+ description?: ReactNode;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export interface SelectGroup {
17
+ label?: string;
18
+ options: SelectOption[];
19
+ }
20
+
21
+ export interface SelectFieldProps {
22
+ value?: string;
23
+ onValueChange?: (value: string) => void;
24
+ options?: SelectOption[];
25
+ groups?: SelectGroup[];
26
+ placeholder?: string;
27
+ disabled?: boolean;
28
+ /** Use the full field style (16px, rounded-md, border, focus ring). Default true. */
29
+ fieldStyle?: boolean;
30
+ /** Trigger height on the shared control-height scale (`--control-h-*`, task
31
+ * cbux-41). Default `md`. No-op when `fieldStyle={false}`. */
32
+ size?: ControlSize;
33
+ /** Forwarded to the trigger so a visible `<label htmlFor>` can target it. */
34
+ id?: string;
35
+ name?: string;
36
+ required?: boolean;
37
+ className?: string;
38
+ 'aria-label'?: string;
39
+ 'aria-labelledby'?: string;
40
+ /**
41
+ * What to say when there is nothing to choose — *"No lists yet"*.
42
+ *
43
+ * 🔴 Required in practice for any control whose options come from DATA rather than
44
+ * from a constant: an empty popup is a dead end that reads as a broken control, and
45
+ * the `choice-needs-empty-state` guardrail reports one. Rendered as plain text, not
46
+ * as an item, so it can never be picked or keyboard-focused.
47
+ */
48
+ emptyLabel?: ReactNode;
49
+ /** The second line — what belongs in this list, or how to get one. */
50
+ emptyHint?: ReactNode;
51
+ /** Set by {@link Field} for inline validation — paints the field-token invalid
52
+ * border (via `aria-invalid`) and links the description/error to the trigger. */
53
+ 'aria-invalid'?: boolean | 'true' | 'false';
54
+ 'aria-describedby'?: string;
55
+ }
56
+
57
+ /** What the light trigger hands the real select when it replaces it. Internal. */
58
+ export interface SelectFieldImplProps extends SelectFieldProps {
59
+ /** The trigger was pressed (or Enter/Space/an arrow) before the implementation arrived. */
60
+ initialOpen?: boolean;
61
+ /** The trigger had keyboard focus: keep it on the real one. */
62
+ initialFocus?: boolean;
63
+ }
64
+
65
+ /** The keys Radix Select opens on from its trigger (`OPEN_KEYS` in `@radix-ui/react-select`). */
66
+ export const SELECT_OPEN_KEYS: readonly string[] = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
67
+
68
+ /** The trigger's classes — one function, so the light trigger and Radix's are the same box and the
69
+ * swap between them moves nothing. */
70
+ export function selectTriggerClass({
71
+ fieldStyle = true,
72
+ size = 'md',
73
+ className,
74
+ }: Pick<SelectFieldProps, 'fieldStyle' | 'size' | 'className'>): string {
75
+ return cn(
76
+ fieldStyle && fieldControlClass(size),
77
+ 'flex items-center justify-between gap-2 data-[placeholder]:text-muted-foreground',
78
+ className,
79
+ );
80
+ }
81
+
82
+ /** The value's text wrapper. One line, ellipsized, left-aligned: a long value (a deep path) used to
83
+ * wrap to three centred lines and spill out of a 36px trigger at 390px (Radix Themes' Select
84
+ * truncates the same way, forge task 2594). Radix's Value drops `className`, so the truncation
85
+ * lives on a wrapper. */
86
+ export const SELECT_VALUE_CLASS = 'block min-w-0 flex-1 truncate text-left';
87
+
88
+ /** `groups` when given, else `options` as one unlabelled group. */
89
+ export function selectGroupsOf({ options = [], groups = [] }: Pick<SelectFieldProps, 'options' | 'groups'>): SelectGroup[] {
90
+ return groups.length > 0 ? groups : options.length > 0 ? [{ options }] : [];
91
+ }
92
+
93
+ /** Radix Select shows the placeholder for exactly these values (`shouldShowPlaceholder`). */
94
+ export function selectShowsPlaceholder(value: string | undefined): boolean {
95
+ return value === undefined || value === '';
96
+ }
@@ -7,6 +7,7 @@ import { Button } from '../components/Button.js';
7
7
  import { InfoHint } from '../components/InfoHint.js';
8
8
  import { Input } from '../components/Input.js';
9
9
  import { Modal } from '../components/Modal.js';
10
+ import { Kbd } from '../palette/Kbd.js';
10
11
  import {
11
12
  chapterProblem,
12
13
  chapterSpans,
@@ -360,11 +361,11 @@ export function VideoChapterEditor({
360
361
  <InfoHint title='Naming the parts of a video' align='right'>
361
362
  <p>
362
363
  Play or scrub to where a part begins, type what it is about, and press{' '}
363
- <strong>Mark here</strong> (or <kbd>Enter</kbd> in the box).
364
+ <strong>Mark here</strong> (or <Kbd>Enter</Kbd> in the box).
364
365
  </p>
365
366
  <p>
366
367
  Each section runs until the next one starts. Click a section's time to jump there,
367
- edit a name in place, and <kbd>Space</kbd> plays and pauses.
368
+ edit a name in place, and <Kbd>Space</Kbd> plays and pauses.
368
369
  </p>
369
370
  <p>
370
371
  Nothing here changes your video — these are names the player shows on the scrub
@@ -21,6 +21,7 @@ import {
21
21
  type VideoCut,
22
22
  } from 'cursedbelt-core/media/video-cuts';
23
23
  import { cn, RAW_BUTTON } from '../lib/cn.js';
24
+ import { Kbd } from '../palette/Kbd.js';
24
25
 
25
26
  /**
26
27
  * Mark the parts of a video to cut out, and send the marks off to be executed.
@@ -324,12 +325,12 @@ export function VideoCutEditor({
324
325
  */}
325
326
  <InfoHint title='Marking a video to trim' align='right'>
326
327
  <p>
327
- Press <strong>Cut here</strong> (or <kbd>C</kbd>) to divide the clip at the
328
+ Press <strong>Cut here</strong> (or <Kbd>C</Kbd>) to divide the clip at the
328
329
  playhead. Every piece between two cuts is a section.
329
330
  </p>
330
331
  <p>
331
332
  Click <strong>✗</strong> on a section to remove it — click again to keep it after
332
- all. Click a section's timecode to jump there, and <kbd>Space</kbd> plays and
333
+ all. Click a section's timecode to jump there, and <Kbd>Space</Kbd> plays and
333
334
  pauses.
334
335
  </p>
335
336
  <p>
@@ -148,6 +148,8 @@
148
148
  display: block;
149
149
  }
150
150
  .cbgd-dwell-select {
151
+ /* A SelectField trigger (forge task 3097): its value and chevron sit in one row. */
152
+ gap: 2px;
151
153
  min-height: 24px;
152
154
  padding: 0 4px;
153
155
  border: 0;
@@ -779,6 +779,16 @@ describe('MediaGallery — the playback run', () => {
779
779
  throw new Error(`the playback button never reached ${label}`);
780
780
  }
781
781
 
782
+ /** Pick a slideshow speed the way a keyboard does: open the dwell field, take the option. */
783
+ function pickDwell(
784
+ getByLabelText: ReturnType<typeof render>['getByLabelText'],
785
+ getByRole: ReturnType<typeof render>['getByRole'],
786
+ label: string,
787
+ ) {
788
+ fireEvent.keyDown(getByLabelText('How long each picture holds the screen'), { key: 'Enter' });
789
+ fireEvent.click(getByRole('option', { name: label }));
790
+ }
791
+
782
792
  test('🔴 the button cycles all FIVE settings, including from a picture', () => {
783
793
  // Gated on `category === 'video'` before this, so a folder of photographs — the item most
784
794
  // albums open on — had no way to start a slideshow at all.
@@ -814,9 +824,7 @@ describe('MediaGallery — the playback run', () => {
814
824
  fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
815
825
  cycleTo(getByRole, /Play all/);
816
826
  // The shortest rung on the ladder, so the test waits two seconds rather than five.
817
- fireEvent.change(getByLabelText('How long each picture holds the screen'), {
818
- target: { value: '2' },
819
- });
827
+ pickDwell(getByLabelText, getByRole, '2s');
820
828
  // `beach.jpg` is the newest, so the default sort puts `clip.mp4` next.
821
829
  await waitFor(
822
830
  () => expect(container.querySelector('.cbgd-watch-title')?.textContent).toBe('clip.mp4'),
@@ -830,9 +838,7 @@ describe('MediaGallery — the playback run', () => {
830
838
  const { getByRole, getByLabelText, container } = render(<MediaGallery items={ITEMS} />);
831
839
  fireEvent.click(getByRole('button', { name: 'Open clip.mp4' }));
832
840
  cycleTo(getByRole, /Play all/);
833
- fireEvent.change(getByLabelText('How long each picture holds the screen'), {
834
- target: { value: '2' },
835
- });
841
+ pickDwell(getByLabelText, getByRole, '2s');
836
842
  await new Promise((resolve) => setTimeout(resolve, 2600));
837
843
  expect(container.querySelector('.cbgd-watch-title')?.textContent).toBe('clip.mp4');
838
844
  }, 8000);
@@ -841,16 +847,14 @@ describe('MediaGallery — the playback run', () => {
841
847
  const first = render(<MediaGallery items={ITEMS} />);
842
848
  fireEvent.click(first.getByRole('button', { name: 'Open beach.jpg' }));
843
849
  cycleTo(first.getByRole, /Play all/);
844
- fireEvent.change(first.getByLabelText('How long each picture holds the screen'), {
845
- target: { value: '30' },
846
- });
850
+ pickDwell(first.getByLabelText, first.getByRole, '30s');
847
851
  first.unmount();
848
852
 
849
853
  const second = render(<MediaGallery items={ITEMS} />);
850
854
  fireEvent.click(second.getByRole('button', { name: 'Open beach.jpg' }));
851
855
  expect(
852
- (second.getByLabelText('How long each picture holds the screen') as HTMLSelectElement).value,
853
- ).toBe('30');
856
+ second.getByLabelText('How long each picture holds the screen').textContent,
857
+ ).toBe('30s');
854
858
  });
855
859
  });
856
860
 
@@ -138,6 +138,7 @@ import { GalleryTable, durationLabel } from './GalleryTable.js';
138
138
  import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun.js';
139
139
  import type { MediaGalleryItem, MediaGalleryItemMedia, MediaGalleryOrder, MediaPending } from './types.js';
140
140
  import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
141
+ import { SelectField } from '../components/SelectField.js';
141
142
  import { Tooltip } from '../components/Tooltip.js';
142
143
 
143
144
  import { TruncatedText } from '../components/TruncatedText.js';
@@ -151,6 +152,9 @@ import { cn, RAW_BUTTON } from '../lib/cn.js';
151
152
  // `MediaGalleryItem` and keep auth/signing/deletion on their side.
152
153
 
153
154
  // Heavy viewers stay off the critical path — each loads only when a matching file opens.
155
+ /** The slideshow speeds, as the dwell field offers them. */
156
+ const DWELL_CHOICES = IMAGE_DWELL_OPTIONS.map((seconds) => ({ value: String(seconds), label: `${seconds}s` }));
157
+
154
158
  const LazyVideoPlayer = lazyWithReload(() =>
155
159
  import('../media/VideoPlayer.js').then((m) => ({ default: m.VideoPlayer })),
156
160
  );
@@ -2901,34 +2905,28 @@ export function MediaGallery({
2901
2905
  happen. Owner: "The time should be configurable for slow to fast slideshows." */}
2902
2906
  {runAdvances(playbackEnd) ? (
2903
2907
  <Tooltip content='How long each picture holds the screen before the next item'>
2904
- <label className='cbgd-dwell'>
2908
+ <span className='cbgd-dwell'>
2905
2909
  {/* The word `Hold` was the control's NAME and is now the hourglass; the
2906
2910
  `5s` beside it is its VALUE and stays printed, because a duration you
2907
- have to hover to read is a duration you cannot compare. The select
2908
- keeps its own `aria-label`, so the glyph is decoration. */}
2911
+ have to hover to read is a duration you cannot compare. The field
2912
+ keeps its own `aria-label`, so the glyph is decoration. A SelectField, not
2913
+ a `<select>`: the OS drew that list, outside the kit (forge task 3097). */}
2909
2914
  <span className='cbgd-dwell-label'>
2910
2915
  <Timer aria-hidden />
2911
2916
  </span>
2912
- <select
2913
- // guardrails-ignore no-raw-inputs: `.cbgd-dwell` is this module's own
2914
- // self-contained plain-CSS control, for the same reason `.cbgd-search`
2915
- // states — cursedbelt's Tailwind field layer would fight it on cascade.
2917
+ <SelectField
2918
+ fieldStyle={false}
2916
2919
  className='cbgd-dwell-select'
2917
- value={imageDwell}
2918
- onChange={(event) => {
2919
- const next = Number(event.target.value);
2920
+ value={String(imageDwell)}
2921
+ onValueChange={(value) => {
2922
+ const next = Number(value);
2920
2923
  setImageDwell(next);
2921
2924
  writeImageDwell(next);
2922
2925
  }}
2926
+ options={DWELL_CHOICES}
2923
2927
  aria-label='How long each picture holds the screen'
2924
- >
2925
- {IMAGE_DWELL_OPTIONS.map((seconds) => (
2926
- <option key={seconds} value={seconds}>
2927
- {seconds}s
2928
- </option>
2929
- ))}
2930
- </select>
2931
- </label>
2928
+ />
2929
+ </span>
2932
2930
  </Tooltip>
2933
2931
  ) : null}
2934
2932
  {/*