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
@@ -1,6 +1,5 @@
1
- import type { ReactNode } from 'react';
2
- import { cn, RAW_BUTTON } from '../lib/cn.js';
3
- import { PRESS } from '../lib/press.js';
1
+ import { cn } from '../lib/cn.js';
2
+ import { Chip } from './filterChip.js';
4
3
  import { ScrollArea } from './ScrollArea.js';
5
4
 
6
5
  export interface ChipOption {
@@ -104,38 +103,3 @@ export function ChipFilterStrip({
104
103
  </fieldset>
105
104
  );
106
105
  }
107
-
108
- interface ChipProps {
109
- active: boolean;
110
- onClick: () => void;
111
- children: ReactNode;
112
- }
113
-
114
- function Chip({ active, onClick, children }: ChipProps) {
115
- return (
116
- <button
117
- // guardrails-ignore no-raw-action-button: presentational filter-chip toggle —
118
- // reflects local filter state (aria-pressed), no destructive/mutating intent.
119
- type='button'
120
- onClick={onClick}
121
- aria-pressed={active}
122
- className={cn(
123
- RAW_BUTTON,
124
- // 44 px min tap target height via py + min-h (STANDARDS §4a). px-4 keeps every
125
- // pill's internal padding identical regardless of label length.
126
- 'inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-full px-4 text-sm font-medium',
127
- PRESS,
128
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
129
- // Soft active state — an accent tint + accent text (the standardized active
130
- // treatment shared with nav items and the `accent` Badge tone), not a stark
131
- // solid accent block. `text-accent-strong` (not plain `text-accent`) — the
132
- // /15 tint only clears 4.43:1 with the base accent hue, under AA (cbux-101).
133
- active
134
- ? 'bg-accent/15 text-accent-strong ring-1 ring-inset ring-accent/30'
135
- : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
136
- )}
137
- >
138
- {children}
139
- </button>
140
- );
141
- }
@@ -0,0 +1,89 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { KanbanBoard } from './KanbanBoard.js';
4
+
5
+ afterEach(() => cleanup());
6
+
7
+ interface Card {
8
+ id: string;
9
+ title: string;
10
+ }
11
+ const COLUMNS = [
12
+ { id: 'todo', title: 'To do', items: [{ id: 'a', title: 'Spec the API' }, { id: 'b', title: 'Pick colors' }] },
13
+ { id: 'done', title: 'Done', items: [] as Card[] },
14
+ ];
15
+
16
+ function board(over: Partial<Parameters<typeof KanbanBoard<Card>>[0]> = {}) {
17
+ const opened: string[] = [];
18
+ const added: string[] = [];
19
+ render(
20
+ <KanbanBoard<Card>
21
+ aria-label='Work'
22
+ columns={COLUMNS}
23
+ getKey={(c) => c.id}
24
+ cardLabel={(c) => c.title}
25
+ renderCard={(c) => <span>{c.title}</span>}
26
+ onOpen={(c) => opened.push(c.id)}
27
+ onAdd={(id) => added.push(id)}
28
+ addLabel='Add task'
29
+ {...over}
30
+ />,
31
+ );
32
+ return { opened, added };
33
+ }
34
+
35
+ describe('KanbanBoard', () => {
36
+ it('draws each column with its name and count, and says so for an empty one', () => {
37
+ board();
38
+ expect(screen.getByRole('region', { name: 'To do, 2 cards' })).toBeTruthy();
39
+ expect(screen.getByRole('region', { name: 'Done, 0 cards' })).toBeTruthy();
40
+ expect(screen.getByText('Nothing here yet')).toBeTruthy();
41
+ });
42
+
43
+ it('a card is the tap target: click and Enter open it', () => {
44
+ const { opened } = board();
45
+ fireEvent.click(screen.getByRole('button', { name: 'Spec the API' }));
46
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Pick colors' }), { key: 'Enter' });
47
+ expect(opened).toEqual(['a', 'b']);
48
+ });
49
+
50
+ it("the column's + starts a card IN that column, and names the column", () => {
51
+ const { added } = board();
52
+ fireEvent.click(screen.getByRole('button', { name: 'Add task to Done' }));
53
+ expect(added).toEqual(['done']);
54
+ });
55
+
56
+ it('marks the open card, and offers New column only when asked', () => {
57
+ board({ selectedKey: 'b' });
58
+ expect(document.querySelector('[data-cb-kanban-card="b"]')?.getAttribute('data-selected')).toBe('true');
59
+ expect(document.querySelector('[data-cb-kanban-card="a"]')?.hasAttribute('data-selected')).toBe(false);
60
+ expect(screen.queryByRole('button', { name: /New column/ })).toBeNull();
61
+ cleanup();
62
+ let made = 0;
63
+ board({ onNewColumn: () => made++ });
64
+ fireEvent.click(screen.getByRole('button', { name: /New column/ }));
65
+ expect(made).toBe(1);
66
+ });
67
+
68
+ it("a phone's tray names every column with its count; a pill glides to its column and lights", () => {
69
+ const glided: Element[] = [];
70
+ const original = Element.prototype.scrollIntoView;
71
+ Element.prototype.scrollIntoView = function (this: Element) {
72
+ glided.push(this);
73
+ };
74
+ try {
75
+ board();
76
+ const tray = screen.getByRole('navigation', { name: 'Work: columns' });
77
+ const done = Array.from(tray.querySelectorAll('button')).find((b) => b.textContent === 'Done0');
78
+ expect(Array.from(tray.querySelectorAll('button')).map((b) => [b.textContent, b.getAttribute('aria-pressed')])).toEqual([
79
+ ['To do2', 'true'],
80
+ ['Done0', 'false'],
81
+ ]);
82
+ fireEvent.click(done as HTMLButtonElement);
83
+ expect(glided.map((el) => el.getAttribute('data-cb-kanban-column'))).toEqual(['done']);
84
+ expect(done?.getAttribute('aria-pressed')).toBe('true');
85
+ } finally {
86
+ Element.prototype.scrollIntoView = original;
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,270 @@
1
+ import { Plus } from 'lucide-react';
2
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { registerAutoScroll } from '../hooks/pragmaticDnd.js';
4
+ import { useCrossContainerDrag } from '../hooks/useCrossContainerDrag.js';
5
+ import { cn } from '../lib/cn.js';
6
+ import { Badge } from './Badge.js';
7
+ import { Button } from './Button.js';
8
+ import { CardTapTarget } from './CardTapTarget.js';
9
+ import { Chip } from './filterChip.js';
10
+ import { IconButton } from './IconButton.js';
11
+ import { ScrollArea } from './ScrollArea.js';
12
+
13
+ /** One column of a {@link KanbanBoard}: what it is called, and the cards in it, in order. */
14
+ export interface KanbanColumn<T> {
15
+ id: string;
16
+ title: string;
17
+ items: readonly T[];
18
+ /** A glyph before the title. */
19
+ icon?: ReactNode;
20
+ }
21
+
22
+ export interface KanbanBoardProps<T> {
23
+ columns: readonly KanbanColumn<T>[];
24
+ /** A stable key per card, unique across every column. */
25
+ getKey: (item: T) => string;
26
+ /** What is ON the card — a glyph, a title, one detail line. The board draws the card around it. */
27
+ renderCard: (item: T) => ReactNode;
28
+ /** The card's accessible name, and the text of the ghost that follows a drag. */
29
+ cardLabel: (item: T) => string;
30
+ /** A card was clicked, tapped, or Enter'd. */
31
+ onOpen: (item: T) => void;
32
+ /** The card that is open — drawn as the active place (NAV-6). */
33
+ selectedKey?: string | null;
34
+ /**
35
+ * A card was dropped in ANOTHER column. Omit it and the board is read-only: nothing drags.
36
+ * Order within a column is the consumer's (the board never reorders), so a drop back into
37
+ * its own column is not a move.
38
+ */
39
+ onMove?: (itemKey: string, fromColumn: string, toColumn: string) => void;
40
+ /** The column's `+`: start a new card in it. */
41
+ onAdd?: (columnId: string) => void;
42
+ /** The `+`'s verb — "Add entry", "Add task". */
43
+ addLabel?: string;
44
+ /** A trailing "New column" button after the last column. */
45
+ onNewColumn?: () => void;
46
+ newColumnLabel?: string;
47
+ /** What an empty column says. */
48
+ emptyText?: string;
49
+ 'aria-label': string;
50
+ className?: string;
51
+ }
52
+
53
+ /**
54
+ * Cards in columns, dragged between them — the board Linear and Trello settle on (forge task
55
+ * 3030, the vault's Kanban canvas). The columns scroll sideways as a strip and each column
56
+ * scrolls on its own; a card is the whole tap target (`CardTapTarget`) and drags through
57
+ * {@link useCrossContainerDrag} (pointer, touch, the edge indicator, auto-scroll).
58
+ *
59
+ * Under 768px a phone sees ONE column at a time: the strip snaps column by column, and the
60
+ * column titles become a pill tray above it — tap one to glide there, and the one in view is
61
+ * lit (forge task 3033, the redesign's "Pill Segmenter"). Under 1024px the strip snaps too.
62
+ *
63
+ * 🔴 Dragging is a PLUS. Every move a drag makes must also be reachable from the card's own
64
+ * detail (a folder or status picker): the hook's keyboard lift does not yet carry a card across
65
+ * columns, so the board never relies on it.
66
+ */
67
+ export function KanbanBoard<T>({
68
+ columns,
69
+ getKey,
70
+ renderCard,
71
+ cardLabel,
72
+ onOpen,
73
+ selectedKey = null,
74
+ onMove,
75
+ onAdd,
76
+ addLabel = 'Add',
77
+ onNewColumn,
78
+ newColumnLabel = 'New column',
79
+ emptyText = 'Nothing here yet',
80
+ 'aria-label': ariaLabel,
81
+ className,
82
+ }: KanbanBoardProps<T>) {
83
+ const containers = useMemo(() => columns.map((c) => ({ id: c.id, items: [...c.items] })), [columns]);
84
+ const onDrop = useCallback(
85
+ (from: string, to: string, fromIndex: number) => {
86
+ if (from === to || !onMove) return;
87
+ const item = containers.find((c) => c.id === from)?.items[fromIndex];
88
+ if (item !== undefined) onMove(getKey(item), from, to);
89
+ },
90
+ [containers, getKey, onMove],
91
+ );
92
+ const { ContainerContext, Container, Item } = useCrossContainerDrag<T>({
93
+ containers,
94
+ getKey,
95
+ onMove: onDrop,
96
+ disabled: !onMove,
97
+ renderOverlay: (item) => (
98
+ <div className='max-w-64 truncate rounded-md bg-surface px-3 py-2 text-sm font-medium text-foreground shadow-2xl ring-1 ring-accent'>
99
+ {cardLabel(item)}
100
+ </div>
101
+ ),
102
+ });
103
+
104
+ // The phone's tray follows the column in view. Columns are the strip's full width there, so
105
+ // at most one is 60 % visible; wider, the tray is not drawn and this is unused.
106
+ const stripRef = useRef<HTMLDivElement | null>(null);
107
+ const columnEls = useRef(new Map<string, HTMLElement>());
108
+ const [inView, setInView] = useState<string | null>(null);
109
+ const columnIds = columns.map((c) => c.id).join('\n');
110
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `columnIds` is the trigger — a column added or gone re-observes the set held in the ref.
111
+ useEffect(() => {
112
+ const root = stripRef.current;
113
+ if (!root || typeof IntersectionObserver === 'undefined') return;
114
+ const io = new IntersectionObserver(
115
+ (records) => {
116
+ for (const r of records) if (r.isIntersecting) setInView((r.target as HTMLElement).dataset.cbKanbanColumn ?? null);
117
+ },
118
+ { root, threshold: 0.6 },
119
+ );
120
+ for (const el of columnEls.current.values()) io.observe(el);
121
+ return () => io.disconnect();
122
+ }, [columnIds]);
123
+ // A card carried to the strip's edge scrolls the strip, for a mouse and a finger: the hook
124
+ // only registers each card's NEAREST scroller, which is its column.
125
+ useEffect(() => {
126
+ const strip = stripRef.current;
127
+ return strip && onMove ? registerAutoScroll(strip) : undefined;
128
+ }, [onMove]);
129
+ const current = columns.some((c) => c.id === inView) ? inView : (columns[0]?.id ?? null);
130
+ const showColumn = (id: string) => {
131
+ columnEls.current.get(id)?.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
132
+ setInView(id);
133
+ };
134
+
135
+ return (
136
+ <ContainerContext>
137
+ <div className={cn('flex min-h-0 min-w-0 flex-1 flex-col gap-2', className)} data-cb-kanban-frame>
138
+ <nav aria-label={`${ariaLabel}: columns`} data-cb-kanban-tray className='min-w-0 md:hidden'>
139
+ <ScrollArea
140
+ axis='x'
141
+ space='none'
142
+ safeArea={false}
143
+ className='flex items-center gap-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
144
+ >
145
+ {columns.map((column) => (
146
+ <Chip key={column.id} active={column.id === current} onClick={() => showColumn(column.id)}>
147
+ {column.title}
148
+ <span className='ml-1 text-xs tabular-nums'>{column.items.length}</span>
149
+ </Chip>
150
+ ))}
151
+ </ScrollArea>
152
+ </nav>
153
+ <ScrollArea
154
+ axis='x'
155
+ fade
156
+ space='compact'
157
+ safeArea={false}
158
+ role='group'
159
+ aria-label={ariaLabel}
160
+ data-cb-kanban
161
+ scrollRef={stripRef}
162
+ className='min-h-0 flex-1 max-lg:snap-x max-lg:snap-mandatory'
163
+ >
164
+ <div className='flex h-full items-start gap-3'>
165
+ {columns.map((column) => (
166
+ <Container key={column.id} containerId={column.id}>
167
+ {({ setNodeRef, isOver, items }) => (
168
+ <section
169
+ ref={(node) => {
170
+ setNodeRef(node);
171
+ if (node) columnEls.current.set(column.id, node);
172
+ else columnEls.current.delete(column.id);
173
+ }}
174
+ aria-label={`${column.title}, ${items.length} ${items.length === 1 ? 'card' : 'cards'}`}
175
+ data-cb-kanban-column={column.id}
176
+ className={cn(
177
+ 'flex max-h-full w-72 shrink-0 snap-start flex-col gap-2 rounded-lg bg-muted/40 p-2 transition-colors max-md:w-full',
178
+ isOver && 'bg-muted ring-1 ring-inset ring-accent/50',
179
+ )}
180
+ >
181
+ <header className='flex min-h-8 items-center gap-2 px-1'>
182
+ {column.icon && (
183
+ <span aria-hidden className='flex shrink-0 text-muted-foreground [&_svg]:size-4'>
184
+ {column.icon}
185
+ </span>
186
+ )}
187
+ <h3 className='m-0 min-w-0 truncate text-sm font-medium text-foreground'>{column.title}</h3>
188
+ <Badge tone='neutral' className='tabular-nums'>
189
+ {items.length}
190
+ </Badge>
191
+ {onAdd && (
192
+ <IconButton
193
+ size='sm'
194
+ className='ml-auto'
195
+ label={`${addLabel} to ${column.title}`}
196
+ onClick={() => onAdd(column.id)}
197
+ >
198
+ <Plus />
199
+ </IconButton>
200
+ )}
201
+ </header>
202
+ {/* `overscroll-x-auto`: a y-scroller is a scroll container on BOTH axes, and the
203
+ ScrollArea's `overscroll-contain` kept every sideways swipe and wheel that
204
+ started on a card from reaching the strip (vault-stage at 390, 3033). */}
205
+ <ScrollArea
206
+ axis='y'
207
+ space='compact'
208
+ safeArea={false}
209
+ fade
210
+ className='flex flex-col gap-2 overscroll-x-auto'
211
+ >
212
+ {items.length === 0 ? (
213
+ <p className='m-0 rounded-md border border-dashed border-border px-3 py-6 text-center text-caption text-muted-foreground'>
214
+ {emptyText}
215
+ </p>
216
+ ) : (
217
+ items.map((item) => {
218
+ const key = getKey(item);
219
+ const selected = key === selectedKey;
220
+ return (
221
+ <Item key={key} containerId={column.id} itemKey={key}>
222
+ {({ setNodeRef: cardRef, isDragging }) => (
223
+ <div
224
+ ref={cardRef}
225
+ data-cb-kanban-card={key}
226
+ data-selected={selected || undefined}
227
+ className={cn('relative isolate', isDragging && 'opacity-40')}
228
+ >
229
+ <CardTapTarget
230
+ aria-label={cardLabel(item)}
231
+ onClick={() => onOpen(item)}
232
+ className={cn(
233
+ // A border, not a ring: the column scrolls, and a scroller clips the
234
+ // ring a card draws OUTSIDE itself (seen on vault-stage, 2026-10-13).
235
+ 'flex w-full flex-col gap-1 rounded-md border border-border bg-surface px-3 py-2 text-sm transition-colors hover:border-border-strong focus-visible:border-accent focus-visible:outline-none',
236
+ onMove && 'cursor-grab active:cursor-grabbing',
237
+ selected && 'border-accent/70',
238
+ )}
239
+ >
240
+ {renderCard(item)}
241
+ </CardTapTarget>
242
+ </div>
243
+ )}
244
+ </Item>
245
+ );
246
+ })
247
+ )}
248
+ </ScrollArea>
249
+ </section>
250
+ )}
251
+ </Container>
252
+ ))}
253
+ {onNewColumn && (
254
+ <Button
255
+ variant='ghost'
256
+ size='sm'
257
+ className='shrink-0'
258
+ leftIcon={<Plus />}
259
+ tooltip={`${newColumnLabel} — a new column at the end`}
260
+ onClick={onNewColumn}
261
+ >
262
+ {newColumnLabel}
263
+ </Button>
264
+ )}
265
+ </div>
266
+ </ScrollArea>
267
+ </div>
268
+ </ContainerContext>
269
+ );
270
+ }
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { Button } from './Button.js';
3
4
  import { ResponsiveWorkbench } from './ResponsiveWorkbench.js';
4
5
 
5
6
  // useMediaQuery reads window.matchMedia; stub it so the breakpoint is deterministic
@@ -168,4 +169,60 @@ describe('ResponsiveWorkbench — below breakpoint (mobile)', () => {
168
169
  );
169
170
  expect(getByText('+ Add')).toBeTruthy();
170
171
  });
172
+
173
+ test('sheets: the rails rise as bottom sheets and the bar floats as a handle, not a row', () => {
174
+ stubMatchMedia(false);
175
+ const { container } = render(
176
+ <ResponsiveWorkbench
177
+ mobilePanels='sheets'
178
+ right={<div>Inspector</div>}
179
+ rightOpen
180
+ onRightOpenChange={() => {}}
181
+ mobileUtilityBar={<Button tooltip='What is selected'>Details</Button>}
182
+ >
183
+ <div>Canvas</div>
184
+ </ResponsiveWorkbench>,
185
+ );
186
+ const handle = container.querySelector('[data-cb-workbench-sheet-handle]');
187
+ expect(handle?.textContent).toContain('Details');
188
+ expect(container.querySelector('[data-cb-workbench-utility-bar]')).toBeNull();
189
+ // Floated: absolutely placed over the main area, so the main area keeps the whole height.
190
+ expect(handle?.parentElement?.className).toContain('absolute');
191
+ expect(dialogs()[0]?.className).toContain('bottom-0');
192
+ });
193
+
194
+ test('sheets: a swipe up on the handle opens the right rail, and does not also click the button under it', () => {
195
+ stubMatchMedia(false);
196
+ const opened: string[] = [];
197
+ const clicked: string[] = [];
198
+ const { container, getByText } = render(
199
+ <ResponsiveWorkbench
200
+ mobilePanels='sheets'
201
+ left={<div>Palette</div>}
202
+ right={<div>Inspector</div>}
203
+ leftOpen={false}
204
+ onLeftOpenChange={(v) => v && opened.push('left')}
205
+ rightOpen={false}
206
+ onRightOpenChange={(v) => v && opened.push('right')}
207
+ mobileUtilityBar={
208
+ <Button tooltip='Flows and the legend' onClick={() => clicked.push('explore')}>Explore</Button>
209
+ }
210
+ >
211
+ <div>Canvas</div>
212
+ </ResponsiveWorkbench>,
213
+ );
214
+ const handle = container.querySelector('[data-cb-workbench-sheet-handle]') as HTMLElement;
215
+ const button = getByText('Explore');
216
+ fireEvent.pointerDown(button, { pointerId: 1, clientY: 300 });
217
+ fireEvent.pointerUp(button, { pointerId: 1, clientY: 240 });
218
+ fireEvent.click(button);
219
+ expect(opened).toEqual(['right']);
220
+ expect(clicked).toEqual([]);
221
+ // A plain tap is still a tap.
222
+ fireEvent.pointerDown(button, { pointerId: 2, clientY: 300 });
223
+ fireEvent.pointerUp(button, { pointerId: 2, clientY: 298 });
224
+ fireEvent.click(button);
225
+ expect(clicked).toEqual(['explore']);
226
+ expect(handle).not.toBeNull();
227
+ });
171
228
  });
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useState } from 'react';
1
+ import { type ReactNode, useRef, useState } from 'react';
2
2
  import { useMediaQuery } from '../hooks/useMediaQuery.js';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import { Drawer } from './Drawer.js';
@@ -71,9 +71,26 @@ export interface ResponsiveWorkbenchProps {
71
71
  */
72
72
  mobileUtilityBar?: ReactNode;
73
73
 
74
+ /**
75
+ * How the rails come in below the breakpoint.
76
+ *
77
+ * - `drawers` (default): each rail slides in from its own side, and `mobileUtilityBar` is a
78
+ * row under the main area.
79
+ * - `sheets`: each rail rises as a bottom sheet, and `mobileUtilityBar` FLOATS over the bottom
80
+ * of the main area as a handle (a grabber over the caller's buttons) instead of taking a row —
81
+ * a canvas keeps the whole height, the way Apple Maps' and Figma's phone panels do. Swiping
82
+ * the handle up opens `mobileSwipeOpens`.
83
+ */
84
+ mobilePanels?: 'drawers' | 'sheets';
85
+ /** The rail a swipe up on the `sheets` handle opens. Default `right` (or `left` when there is no right). */
86
+ mobileSwipeOpens?: 'left' | 'right';
87
+
74
88
  className?: string;
75
89
  }
76
90
 
91
+ /** Travel, in px, that turns a press on the sheets handle into a swipe up. */
92
+ const SWIPE_UP = 32;
93
+
77
94
  /**
78
95
  * The three-pane workbench (left rail / main / right rail) used by builder-style apps.
79
96
  * At/above `breakpoint` (default 1024, Tailwind `lg`) both rails render as persistent,
@@ -107,11 +124,20 @@ export function ResponsiveWorkbench({
107
124
  rightCollapsedRail,
108
125
  breakpoint = DEFAULT_BREAKPOINT,
109
126
  mobileUtilityBar,
127
+ mobilePanels = 'drawers',
128
+ mobileSwipeOpens,
110
129
  className,
111
130
  }: ResponsiveWorkbenchProps) {
112
131
  const isDesktop = useWorkbenchBreakpoint(breakpoint);
113
132
  const [leftOpen, setLeftOpen] = useOpenState(leftOpenProp, onLeftOpenChange, defaultLeftOpen);
114
133
  const [rightOpen, setRightOpen] = useOpenState(rightOpenProp, onRightOpenChange, defaultRightOpen);
134
+ const sheets = mobilePanels === 'sheets';
135
+
136
+ // The handle's swipe up. A swipe that starts on one of the caller's buttons must not ALSO click
137
+ // it (Explore opening on top of the sheet the swipe opened), so the click after a swipe is eaten.
138
+ const swipe = useRef<{ id: number; y: number } | null>(null);
139
+ const swiped = useRef(false);
140
+ const swipeTarget = mobileSwipeOpens ?? (right ? 'right' : 'left');
115
141
 
116
142
  return (
117
143
  // A workbench spans the screen by design — LAY-1's `full` row (canvas, studio, workbench),
@@ -135,7 +161,8 @@ export function ResponsiveWorkbench({
135
161
  <Drawer
136
162
  open={leftOpen}
137
163
  onClose={() => setLeftOpen(false)}
138
- side='left'
164
+ side={sheets ? 'bottom' : 'left'}
165
+ size={sheets ? 'lg' : undefined}
139
166
  title={leftTitle}
140
167
  scrollBody={false}
141
168
  >
@@ -167,7 +194,8 @@ export function ResponsiveWorkbench({
167
194
  <Drawer
168
195
  open={rightOpen}
169
196
  onClose={() => setRightOpen(false)}
170
- side='right'
197
+ side={sheets ? 'bottom' : 'right'}
198
+ size={sheets ? 'lg' : undefined}
171
199
  title={rightTitle}
172
200
  scrollBody={false}
173
201
  >
@@ -176,7 +204,44 @@ export function ResponsiveWorkbench({
176
204
  ))}
177
205
  </div>
178
206
 
179
- {!isDesktop && mobileUtilityBar && (
207
+ {!isDesktop && mobileUtilityBar && sheets && (
208
+ // Floats over the main area: the canvas runs to the bottom edge under it.
209
+ <div className='pointer-events-none absolute inset-x-3 bottom-[max(env(safe-area-inset-bottom,0px),0.75rem)] flex justify-center'>
210
+ <div
211
+ data-cb-workbench-sheet-handle=''
212
+ className={cn(
213
+ 'pointer-events-auto flex w-full max-w-md touch-none flex-col items-stretch gap-1.5',
214
+ 'rounded-2xl border border-border/60 bg-background/85 px-2 pt-1.5 pb-2 shadow-raised backdrop-blur-md',
215
+ )}
216
+ onPointerDown={(e) => {
217
+ swipe.current = { id: e.pointerId, y: e.clientY };
218
+ swiped.current = false;
219
+ }}
220
+ onPointerUp={(e) => {
221
+ const start = swipe.current;
222
+ swipe.current = null;
223
+ if (!start || start.id !== e.pointerId || start.y - e.clientY < SWIPE_UP) return;
224
+ swiped.current = true;
225
+ if (swipeTarget === 'right') setRightOpen(true);
226
+ else setLeftOpen(true);
227
+ }}
228
+ onPointerCancel={() => {
229
+ swipe.current = null;
230
+ }}
231
+ onClickCapture={(e) => {
232
+ if (!swiped.current) return;
233
+ swiped.current = false;
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ }}
237
+ >
238
+ <span aria-hidden className='mx-auto h-1 w-9 shrink-0 rounded-full bg-border' />
239
+ {mobileUtilityBar}
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ {!isDesktop && mobileUtilityBar && !sheets && (
180
245
  <div
181
246
  data-cb-workbench-utility-bar=''
182
247
  className={cn(