cursedbelt 5.78.0 → 5.79.1

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 (143) 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/FileDropZone.d.ts.map +1 -1
  13. package/dist/react/components/FileDropZone.js +4 -1
  14. package/dist/react/components/FileDropZone.js.map +1 -1
  15. package/dist/react/components/KanbanBoard.d.ts +55 -0
  16. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  17. package/dist/react/components/KanbanBoard.js +89 -0
  18. package/dist/react/components/KanbanBoard.js.map +1 -0
  19. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  20. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  21. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  22. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  23. package/dist/react/components/SelectField.d.ts +18 -48
  24. package/dist/react/components/SelectField.d.ts.map +1 -1
  25. package/dist/react/components/SelectField.js +88 -11
  26. package/dist/react/components/SelectField.js.map +1 -1
  27. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  28. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  29. package/dist/react/components/SelectFieldImpl.js +26 -0
  30. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  31. package/dist/react/components/filterChip.d.ts +8 -0
  32. package/dist/react/components/filterChip.d.ts.map +1 -0
  33. package/dist/react/components/filterChip.js +20 -0
  34. package/dist/react/components/filterChip.js.map +1 -0
  35. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  36. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  37. package/dist/react/hooks/pointerDnd.js +101 -9
  38. package/dist/react/hooks/pointerDnd.js.map +1 -1
  39. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  40. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  41. package/dist/react/hooks/pragmaticDnd.js +9 -3
  42. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  43. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  44. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  45. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  46. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  47. package/dist/react/index.d.ts +3 -0
  48. package/dist/react/index.d.ts.map +1 -1
  49. package/dist/react/index.js +3 -0
  50. package/dist/react/index.js.map +1 -1
  51. package/dist/react/lib/selectField.d.ts +72 -0
  52. package/dist/react/lib/selectField.d.ts.map +1 -0
  53. package/dist/react/lib/selectField.js +23 -0
  54. package/dist/react/lib/selectField.js.map +1 -0
  55. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoChapterEditor.js +2 -1
  57. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  58. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  59. package/dist/react/media/VideoCutEditor.js +2 -1
  60. package/dist/react/media/VideoCutEditor.js.map +1 -1
  61. package/dist/react/media/denseGallery.css +2 -0
  62. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaGallery.js +6 -7
  64. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  65. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  66. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  67. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  68. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  69. package/dist/react/nav/CommandBar.js +2 -1
  70. package/dist/react/nav/CommandBar.js.map +1 -1
  71. package/dist/react/palette/CommandPalette.d.ts +15 -1
  72. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  73. package/dist/react/palette/CommandPalette.js +58 -8
  74. package/dist/react/palette/CommandPalette.js.map +1 -1
  75. package/dist/react/palette/Kbd.d.ts +15 -0
  76. package/dist/react/palette/Kbd.d.ts.map +1 -0
  77. package/dist/react/palette/Kbd.js +17 -0
  78. package/dist/react/palette/Kbd.js.map +1 -0
  79. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  80. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  81. package/dist/react/palette/ShortcutSheet.js +15 -0
  82. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  83. package/dist/react/palette/commandSources.d.ts +14 -0
  84. package/dist/react/palette/commandSources.d.ts.map +1 -1
  85. package/dist/react/palette/commandSources.js +11 -1
  86. package/dist/react/palette/commandSources.js.map +1 -1
  87. package/dist/react/palette/index.d.ts +4 -2
  88. package/dist/react/palette/index.d.ts.map +1 -1
  89. package/dist/react/palette/index.js +2 -0
  90. package/dist/react/palette/index.js.map +1 -1
  91. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  92. package/dist/styles-areas/canvas-hud.css +2 -2
  93. package/dist/styles-areas/kanban.css +31 -0
  94. package/dist/styles-areas/palette.css +3 -3
  95. package/dist/styles-areas/workbench.css +15 -13
  96. package/dist/styles-utilities.css +42 -0
  97. package/package.json +4 -3
  98. package/scripts/guardrails-baseline.json +2 -1
  99. package/scripts/guardrailsEnforce.spec.ts +62 -0
  100. package/scripts/guardrailsEnforce.ts +77 -6
  101. package/scripts/styleAreas.ts +2 -0
  102. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  103. package/src/react/canvas-hud/HudControls.tsx +33 -9
  104. package/src/react/components/AppShell.spec.tsx +40 -0
  105. package/src/react/components/AppShell.tsx +25 -27
  106. package/src/react/components/ChipFilterStrip.tsx +2 -38
  107. package/src/react/components/FileDropZone.spec.tsx +16 -0
  108. package/src/react/components/FileDropZone.tsx +3 -1
  109. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  110. package/src/react/components/KanbanBoard.tsx +270 -0
  111. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  112. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  113. package/src/react/components/SelectField.spec.tsx +117 -0
  114. package/src/react/components/SelectField.tsx +131 -166
  115. package/src/react/components/SelectFieldImpl.tsx +140 -0
  116. package/src/react/components/filterChip.tsx +43 -0
  117. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  118. package/src/react/hooks/pointerDnd.ts +103 -8
  119. package/src/react/hooks/pragmaticDnd.ts +13 -2
  120. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  121. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  122. package/src/react/index.ts +3 -0
  123. package/src/react/lib/floatingStyles.spec.ts +1 -1
  124. package/src/react/lib/selectField.ts +96 -0
  125. package/src/react/media/VideoChapterEditor.tsx +3 -2
  126. package/src/react/media/VideoCutEditor.tsx +3 -2
  127. package/src/react/media/denseGallery.css +2 -0
  128. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  129. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  130. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  131. package/src/react/nav/CommandBar.tsx +2 -3
  132. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  133. package/src/react/palette/CommandPalette.tsx +153 -13
  134. package/src/react/palette/Kbd.tsx +46 -0
  135. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  136. package/src/react/palette/ShortcutSheet.tsx +110 -0
  137. package/src/react/palette/commandSources.tsx +30 -1
  138. package/src/react/palette/index.ts +9 -0
  139. package/src/styles-areas/canvas-hud.css +2 -2
  140. package/src/styles-areas/kanban.css +31 -0
  141. package/src/styles-areas/palette.css +3 -3
  142. package/src/styles-areas/workbench.css +15 -13
  143. package/src/styles-utilities.css +42 -0
@@ -0,0 +1,140 @@
1
+ import * as RSelect from '@radix-ui/react-select';
2
+ import { Check, ChevronDown, ChevronUp } from 'lucide-react';
3
+ import { useEffect, useRef } from 'react';
4
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
5
+ import { useFloatingContainer } from '../lib/floatingContainer.js';
6
+ import {
7
+ FLOATING_ANIMATION,
8
+ FLOATING_COLLISION_PADDING,
9
+ FLOATING_CONTENT_BASE,
10
+ FLOATING_ITEM_BASE,
11
+ } from '../lib/floatingStyles.js';
12
+ import {
13
+ SELECT_VALUE_CLASS,
14
+ type SelectFieldImplProps,
15
+ selectGroupsOf,
16
+ selectTriggerClass,
17
+ } from '../lib/selectField.js';
18
+
19
+ /**
20
+ * The Radix Select behind {@link SelectField} — loaded as its own chunk by `SelectField.tsx`, which
21
+ * says why. Not exported from the package: render `SelectField`.
22
+ */
23
+ export default function SelectFieldImpl({
24
+ value,
25
+ onValueChange,
26
+ options = [],
27
+ groups = [],
28
+ placeholder = 'Select…',
29
+ disabled,
30
+ fieldStyle = true,
31
+ size = 'md',
32
+ id,
33
+ name,
34
+ required,
35
+ className,
36
+ emptyLabel,
37
+ emptyHint,
38
+ 'aria-label': ariaLabel,
39
+ 'aria-labelledby': ariaLabelledBy,
40
+ 'aria-invalid': ariaInvalid,
41
+ 'aria-describedby': ariaDescribedBy,
42
+ initialOpen = false,
43
+ initialFocus = false,
44
+ }: SelectFieldImplProps) {
45
+ const allGroups = selectGroupsOf({ options, groups });
46
+ const empty = allGroups.every((group) => group.options.length === 0);
47
+ const container = useFloatingContainer();
48
+ const triggerRef = useRef<HTMLButtonElement>(null);
49
+
50
+ // The light trigger this replaced had the keyboard focus: keep it here, or it drops to <body>.
51
+ // An `initialOpen` select moves focus into its list itself, and hands it back on close.
52
+ useEffect(() => {
53
+ if (initialFocus && !initialOpen) triggerRef.current?.focus();
54
+ }, [initialFocus, initialOpen]);
55
+
56
+ return (
57
+ <RSelect.Root
58
+ value={value}
59
+ onValueChange={onValueChange}
60
+ disabled={disabled}
61
+ name={name}
62
+ required={required}
63
+ defaultOpen={initialOpen}
64
+ >
65
+ <RSelect.Trigger
66
+ ref={triggerRef}
67
+ id={id}
68
+ aria-label={ariaLabel}
69
+ aria-labelledby={ariaLabelledBy}
70
+ aria-invalid={ariaInvalid}
71
+ aria-describedby={ariaDescribedBy}
72
+ className={cn(RAW_BUTTON, selectTriggerClass({ fieldStyle, size, className }))}
73
+ >
74
+ <span className={SELECT_VALUE_CLASS}>
75
+ <RSelect.Value placeholder={placeholder} />
76
+ </span>
77
+ <RSelect.Icon asChild>
78
+ <ChevronDown className='size-4 shrink-0 opacity-50' />
79
+ </RSelect.Icon>
80
+ </RSelect.Trigger>
81
+ <RSelect.Portal container={container}>
82
+ <RSelect.Content
83
+ position='popper'
84
+ sideOffset={4}
85
+ collisionPadding={FLOATING_COLLISION_PADDING}
86
+ className={cn(
87
+ FLOATING_CONTENT_BASE,
88
+ FLOATING_ANIMATION,
89
+ 'max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] p-1',
90
+ )}
91
+ >
92
+ <RSelect.ScrollUpButton className='flex cursor-default items-center justify-center py-1'>
93
+ <ChevronUp className='size-4' />
94
+ </RSelect.ScrollUpButton>
95
+ <RSelect.Viewport>
96
+ {empty && (
97
+ // Not a `RSelect.Item`: an empty state is a statement, not a choice, and
98
+ // Radix would make it focusable and type-ahead-matchable.
99
+ <div className='flex flex-col gap-0.5 px-2 py-2.5 text-left'>
100
+ <span className='text-sm font-medium'>
101
+ {emptyLabel ?? 'Nothing to choose from yet'}
102
+ </span>
103
+ {emptyHint && <span className='text-xs text-muted-foreground'>{emptyHint}</span>}
104
+ </div>
105
+ )}
106
+ {allGroups.map((group, gi) => (
107
+ <RSelect.Group key={group.label ?? gi}>
108
+ {group.label && (
109
+ <RSelect.Label className='px-2 py-1.5 text-xs font-semibold text-muted-foreground'>
110
+ {group.label}
111
+ </RSelect.Label>
112
+ )}
113
+ {group.options.map((opt) => (
114
+ <RSelect.Item
115
+ key={opt.value}
116
+ value={opt.value}
117
+ disabled={opt.disabled}
118
+ className={cn(FLOATING_ITEM_BASE, 'pr-8')}
119
+ >
120
+ <RSelect.ItemIndicator className='absolute right-2 flex size-4 items-center justify-center'>
121
+ <Check className='size-3' />
122
+ </RSelect.ItemIndicator>
123
+ <RSelect.ItemText>{opt.label}</RSelect.ItemText>
124
+ {opt.description && (
125
+ <span className='text-xs text-muted-foreground'>{opt.description}</span>
126
+ )}
127
+ </RSelect.Item>
128
+ ))}
129
+ {gi < allGroups.length - 1 && <RSelect.Separator className='my-1 h-px bg-border' />}
130
+ </RSelect.Group>
131
+ ))}
132
+ </RSelect.Viewport>
133
+ <RSelect.ScrollDownButton className='flex cursor-default items-center justify-center py-1'>
134
+ <ChevronDown className='size-4' />
135
+ </RSelect.ScrollDownButton>
136
+ </RSelect.Content>
137
+ </RSelect.Portal>
138
+ </RSelect.Root>
139
+ );
140
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
3
+ import { PRESS } from '../lib/press.js';
4
+
5
+ /*
6
+ * The pill `ChipFilterStrip` draws, and `KanbanBoard`'s column tray on a phone. Its own module,
7
+ * off every barrel and subpath: it is a part, not a component an app reaches for.
8
+ */
9
+
10
+ export interface ChipProps {
11
+ active: boolean;
12
+ onClick: () => void;
13
+ children: ReactNode;
14
+ }
15
+
16
+ export function Chip({ active, onClick, children }: ChipProps) {
17
+ return (
18
+ <button
19
+ // guardrails-ignore no-raw-action-button: presentational filter-chip toggle —
20
+ // reflects local filter state (aria-pressed), no destructive/mutating intent.
21
+ type='button'
22
+ onClick={onClick}
23
+ aria-pressed={active}
24
+ className={cn(
25
+ RAW_BUTTON,
26
+ // 44 px min tap target height via py + min-h (STANDARDS §4a). px-4 keeps every
27
+ // pill's internal padding identical regardless of label length.
28
+ 'inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-full px-4 text-sm font-medium',
29
+ PRESS,
30
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
31
+ // Soft active state — an accent tint + accent text (the standardized active
32
+ // treatment shared with nav items and the `accent` Badge tone), not a stark
33
+ // solid accent block. `text-accent-strong` (not plain `text-accent`) — the
34
+ // /15 tint only clears 4.43:1 with the base accent hue, under AA (cbux-101).
35
+ active
36
+ ? 'bg-accent/15 text-accent-strong ring-1 ring-inset ring-accent/30'
37
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
38
+ )}
39
+ >
40
+ {children}
41
+ </button>
42
+ );
43
+ }
@@ -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
+ }