@weasel-js/labkit 1.0.2 → 1.0.4

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 (111) hide show
  1. package/dist/_dts/{DrawCommand-CitWPxMY.d.ts → DrawCommand-BkZztJsW.d.ts} +59 -1
  2. package/dist/_dts/{index-C6Yze7sQ.d.ts → index-CFlDPeZh.d.ts} +30 -1
  3. package/dist/_dts/{types-Si4Fw-1F.d.ts → types-x92Kfeme.d.ts} +14 -0
  4. package/dist/_dts/{useExperimentState-CJn2hHzd.d.ts → useExperimentState-D7EQnnwJ.d.ts} +47 -0
  5. package/dist/canvas/index.d.ts +13 -2
  6. package/dist/canvas/index.js +2 -2
  7. package/dist/{chunk-54IQ2DX7.js → chunk-574LJAV4.js} +3 -3
  8. package/dist/chunk-574LJAV4.js.map +1 -0
  9. package/dist/{chunk-VLAHRJOC.js → chunk-5R2ATYPJ.js} +2 -2
  10. package/dist/{chunk-VLAHRJOC.js.map → chunk-5R2ATYPJ.js.map} +1 -1
  11. package/dist/{chunk-R4TACNW7.js → chunk-BAPZPDDA.js} +631 -591
  12. package/dist/chunk-BAPZPDDA.js.map +1 -0
  13. package/dist/{chunk-PWC7AQZM.js → chunk-CPUJ3QXL.js} +2 -2
  14. package/dist/chunk-CPUJ3QXL.js.map +1 -0
  15. package/dist/{chunk-2ZRE7WGQ.js → chunk-DEWXYFEU.js} +6 -6
  16. package/dist/chunk-DEWXYFEU.js.map +1 -0
  17. package/dist/{chunk-3P56ZCCJ.js → chunk-DWV7SFKR.js} +601 -269
  18. package/dist/chunk-DWV7SFKR.js.map +1 -0
  19. package/dist/{chunk-53XSBIUK.js → chunk-KSTEW2AF.js} +18 -2
  20. package/dist/chunk-KSTEW2AF.js.map +1 -0
  21. package/dist/{chunk-HXZHVU4G.js → chunk-PMAU3SEE.js} +3 -3
  22. package/dist/chunk-PMAU3SEE.js.map +1 -0
  23. package/dist/{chunk-7BKDG73Z.js → chunk-RL2LOLNI.js} +2 -2
  24. package/dist/chunk-RL2LOLNI.js.map +1 -0
  25. package/dist/{chunk-2QNYYL3V.js → chunk-SFL7NFKN.js} +2 -2
  26. package/dist/chunk-SFL7NFKN.js.map +1 -0
  27. package/dist/controls/index.d.ts +4 -2
  28. package/dist/controls/index.js +1 -1
  29. package/dist/dragdrop/index.d.ts +2 -2
  30. package/dist/index.d.ts +180 -15
  31. package/dist/index.js +146 -37
  32. package/dist/index.js.map +1 -1
  33. package/dist/layers/index.d.ts +6 -3
  34. package/dist/layers/index.js +1 -1
  35. package/dist/passthrough/weasel-canvas.d.ts +5 -1
  36. package/dist/passthrough/weasel-canvas.js +1 -1
  37. package/dist/passthrough/weasel-ui.d.ts +444 -7
  38. package/dist/passthrough/weasel-ui.js +2 -2
  39. package/dist/primitives/index.d.ts +13 -0
  40. package/dist/primitives/index.js +2 -2
  41. package/dist/state/index.d.ts +16 -3
  42. package/dist/state/index.js +2 -2
  43. package/dist/state/index.js.map +1 -1
  44. package/dist/styles.css +163 -6
  45. package/dist/ui/layers/index.d.ts +5 -0
  46. package/dist/ui/layers/index.js +3 -3
  47. package/dist/undo/index.d.ts +17 -2
  48. package/dist/undo/index.js +1 -1
  49. package/package.json +2 -1
  50. package/src/canvas/CanvasStack.tsx +4 -0
  51. package/src/canvas/CanvasStackContext.ts +3 -0
  52. package/src/canvas/canvasCoords.ts +2 -0
  53. package/src/canvas/useLayerScheduler.ts +2 -0
  54. package/src/controls/ControlPanel.tsx +2 -0
  55. package/src/controls/types.ts +14 -0
  56. package/src/instrument/capabilityDetector.ts +3 -0
  57. package/src/instrument/defineInstrument.ts +2 -0
  58. package/src/instrument/types.ts +29 -0
  59. package/src/instrument/validateConfigSchema.ts +5 -0
  60. package/src/lab/Lab.tsx +17 -2
  61. package/src/lab/LabContext.ts +6 -0
  62. package/src/lab/LabShell.tsx +4 -0
  63. package/src/lab/WorkspaceGrid.less +37 -5
  64. package/src/lab/WorkspaceGrid.stories.tsx +16 -0
  65. package/src/lab/WorkspaceGrid.test.tsx +84 -10
  66. package/src/lab/WorkspaceGrid.tsx +188 -12
  67. package/src/lab/index.ts +0 -2
  68. package/src/layers/LayerList.tsx +3 -0
  69. package/src/primitives/FpsMeter.tsx +1 -0
  70. package/src/primitives/ScaleIndicator.tsx +4 -0
  71. package/src/primitives/Sidebar.tsx +3 -0
  72. package/src/primitives/StatusBar.tsx +3 -0
  73. package/src/primitives/Toolbar.tsx +6 -0
  74. package/src/state/SingletonExperiment.tsx +1 -0
  75. package/src/state/adapters.ts +8 -0
  76. package/src/state/context.tsx +9 -0
  77. package/src/state/helpers.ts +13 -1
  78. package/src/state/store.test.ts +2 -1
  79. package/src/state/store.ts +25 -0
  80. package/src/state/types.ts +20 -0
  81. package/src/state/useExperimentState.ts +3 -0
  82. package/src/test-setup.ts +19 -0
  83. package/src/ui/layers/LayerStack.tsx +5 -0
  84. package/src/ui/properties/CurveField.tsx +4 -1
  85. package/src/ui/properties/EffectCard.tsx +12 -0
  86. package/src/ui/properties/PropertyGroup.tsx +1 -0
  87. package/src/ui/properties/PropertyPanel.tsx +28 -0
  88. package/src/undo/eventBus.ts +4 -0
  89. package/src/undo/undoStack.ts +11 -0
  90. package/src/workspace/DefaultSidebar.tsx +3 -0
  91. package/src/workspace/DefaultStatusBar.tsx +3 -0
  92. package/src/workspace/DefaultToolbar.tsx +3 -0
  93. package/src/workspace/Workspace.stories.tsx +1 -0
  94. package/src/workspace/Workspace.test.tsx +1 -0
  95. package/src/workspace/Workspace.tsx +3 -0
  96. package/src/workspace/WorkspaceChrome.tsx +4 -0
  97. package/src/workspace/index.ts +1 -0
  98. package/src/workspace/slotTypes.ts +9 -0
  99. package/src/workspace/workspaceOps.test.ts +34 -1
  100. package/src/workspace/workspaceOps.ts +24 -0
  101. package/dist/chunk-2QNYYL3V.js.map +0 -1
  102. package/dist/chunk-2ZRE7WGQ.js.map +0 -1
  103. package/dist/chunk-3P56ZCCJ.js.map +0 -1
  104. package/dist/chunk-53XSBIUK.js.map +0 -1
  105. package/dist/chunk-54IQ2DX7.js.map +0 -1
  106. package/dist/chunk-7BKDG73Z.js.map +0 -1
  107. package/dist/chunk-HXZHVU4G.js.map +0 -1
  108. package/dist/chunk-PWC7AQZM.js.map +0 -1
  109. package/dist/chunk-R4TACNW7.js.map +0 -1
  110. package/src/lab/gridDims.test.ts +0 -35
  111. package/src/lab/gridDims.ts +0 -11
@@ -1,10 +1,13 @@
1
1
  import { useCallback, useMemo } from 'react';
2
2
  import { type ControlPoint, CurveEditor, dlog } from '../../passthrough/weasel-ui';
3
3
 
4
+ /** An annotation drawn over a curve plot: a shaded band across a range of x,
5
+ * or a vertical line at one x. */
4
6
  export type CurveMark =
5
7
  | { kind: 'band'; x: [number, number]; color?: string }
6
8
  | { kind: 'line'; x: number; color?: string };
7
9
 
10
+ /** Props for `<CurveField>`. */
8
11
  export interface CurveFieldProps {
9
12
  /** Flat [x0, y0, x1, y1, …] — matches how curve-as-array configs
10
13
  * serialize in JSON snapshots. */
@@ -91,7 +94,7 @@ export function CurveField({
91
94
  <div className="lk-curve-field__plot">
92
95
  <CurveEditor
93
96
  value={points}
94
- onChange={handleChange}
97
+ onInput={handleChange}
95
98
  domain="1d"
96
99
  constrain="function"
97
100
  xRange={[0, 1]}
@@ -5,12 +5,15 @@ import { type CSSProperties, type ReactNode, useState } from 'react';
5
5
  // label flanked by a horizontal rule — no background, padding, or border;
6
6
  // purely a typographic divider. Ported from speech-balloons styles.css:277-313.
7
7
 
8
+ /** Props for `<Subpanel>`. */
8
9
  export interface SubpanelProps {
9
10
  title: ReactNode;
10
11
  children: ReactNode;
11
12
  className?: string;
12
13
  }
13
14
 
15
+ /** A typographic divider inside a property list: a small title flanked by a
16
+ * rule. Purely a heading — use `<PropertyGroup>` for a bordered section. */
14
17
  export function Subpanel({ title, children, className }: SubpanelProps) {
15
18
  const cls = className ? `lk-subpanel ${className}` : 'lk-subpanel';
16
19
  return (
@@ -29,6 +32,7 @@ export function Subpanel({ title, children, className }: SubpanelProps) {
29
32
  // per-instance recolors the title bar, border-left, and (via re-binding --wzl-accent
30
33
  // inside the card) every descendant control that reads from the accent token.
31
34
 
35
+ /** Props for `<EffectCard>`. */
32
36
  export interface EffectCardProps {
33
37
  /** Card title rendered in the title bar (e.g. effect kind label or a select). */
34
38
  title: ReactNode;
@@ -59,6 +63,9 @@ export interface EffectCardProps {
59
63
  className?: string;
60
64
  }
61
65
 
66
+ /** An accented, collapsible card for one item in a list of like things —
67
+ * effects, layers, tails. The accent color rebinds the theme accent token
68
+ * within the card, so its controls pick it up too. */
62
69
  export function EffectCard({
63
70
  title,
64
71
  primary,
@@ -182,10 +189,12 @@ function DragHandleIcon() {
182
189
  // onReorder fires with (sourceId, targetId, position) on drop. Mirrors the
183
190
  // LayerStack pattern from speech-balloons Lab.tsx:630-707.
184
191
 
192
+ /** The minimum an item must carry to appear in an `<EffectCardList>`. */
185
193
  export interface EffectCardListItem {
186
194
  id: string | number;
187
195
  }
188
196
 
197
+ /** Props for `<EffectCardList>`. */
189
198
  export interface EffectCardListProps<T extends EffectCardListItem> {
190
199
  items: ReadonlyArray<T>;
191
200
  renderItem: (
@@ -217,6 +226,9 @@ export interface EffectCardListProps<T extends EffectCardListItem> {
217
226
  defaultExpandedIds?: ReadonlyArray<T['id']>;
218
227
  }
219
228
 
229
+ /** A drag-reorderable list of `<EffectCard>`s. Owns the expanded and dragging
230
+ * state and hands it back to `renderItem`, so the caller only supplies each
231
+ * card's contents. */
220
232
  export function EffectCardList<T extends EffectCardListItem>({
221
233
  items,
222
234
  renderItem,
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
+ /** Props for `<PropertyGroup>`. */
3
4
  export interface PropertyGroupProps {
4
5
  /** Title rendered between two rules at the top of the group. */
5
6
  title: ReactNode;
@@ -2,12 +2,15 @@ import { type ReactNode, useState } from 'react';
2
2
  import { dlog } from '../../passthrough/weasel-ui';
3
3
  import { formatNumber, parseSignedNumber } from '../format';
4
4
 
5
+ /** Props for `<PropertyPanel>`. */
5
6
  export interface PropertyPanelProps {
6
7
  title?: ReactNode;
7
8
  children: ReactNode;
8
9
  className?: string;
9
10
  }
10
11
 
12
+ /** A titled panel holding property rows — the sidebar container the rest of
13
+ * this module's components fill. */
11
14
  export function PropertyPanel({ title, children, className }: PropertyPanelProps) {
12
15
  const cls = className ? `lk-property-panel ${className}` : 'lk-property-panel';
13
16
  return (
@@ -18,8 +21,10 @@ export function PropertyPanel({ title, children, className }: PropertyPanelProps
18
21
  );
19
22
  }
20
23
 
24
+ /** How a property list packs its rows into two columns. */
21
25
  export type PropertyListPack = 'auto-color' | 'pairs';
22
26
 
27
+ /** Props for `<PropertyList>`. */
23
28
  export interface PropertyListProps {
24
29
  children: ReactNode;
25
30
  className?: string;
@@ -44,9 +49,12 @@ export function PropertyList({ children, className, pack = 'auto-color' }: Prope
44
49
  return <div className={cls}>{children}</div>;
45
50
  }
46
51
 
52
+ /** Which control shape a row holds, which decides its intrinsic layout. */
47
53
  export type PropertyRowVariant = 'default' | 'color' | 'checkbox';
54
+ /** Whether a row's label sits above its control or beside it. */
48
55
  export type PropertyRowLayout = 'block' | 'inline';
49
56
 
57
+ /** Props for `<PropertyRow>`. */
50
58
  export interface PropertyRowProps {
51
59
  label: ReactNode;
52
60
  /** Right-aligned readout shown next to the label (e.g. current value). */
@@ -63,6 +71,8 @@ export interface PropertyRowProps {
63
71
  className?: string;
64
72
  }
65
73
 
74
+ /** The label-plus-control frame every typed row below is built from. Use it
75
+ * directly for a control this module does not cover. */
66
76
  export function PropertyRow({
67
77
  label,
68
78
  readout,
@@ -91,6 +101,7 @@ export function PropertyRow({
91
101
 
92
102
  // ── Row implementations ──────────────────────────────────────────────
93
103
 
104
+ /** Props for `<SliderRow>`. */
94
105
  export interface SliderRowProps {
95
106
  label: ReactNode;
96
107
  value: number;
@@ -109,6 +120,8 @@ export interface SliderRowProps {
109
120
  layout?: PropertyRowLayout;
110
121
  }
111
122
 
123
+ /** A bounded number edited by dragging, with a live readout whose precision
124
+ * follows `step`. */
112
125
  export function SliderRow({
113
126
  label,
114
127
  value,
@@ -237,6 +250,7 @@ function EditableReadout({ value, min, max, format, unit, onCommit }: EditableRe
237
250
  );
238
251
  }
239
252
 
253
+ /** Props for `<ColorRow>`. */
240
254
  export interface ColorRowProps {
241
255
  label: ReactNode;
242
256
  value: string;
@@ -251,6 +265,7 @@ export interface ColorRowProps {
251
265
  alphaDisabled?: boolean;
252
266
  }
253
267
 
268
+ /** A color swatch, optionally with an alpha slider beneath it. */
254
269
  export function ColorRow({
255
270
  label,
256
271
  value,
@@ -280,12 +295,14 @@ export function ColorRow({
280
295
  );
281
296
  }
282
297
 
298
+ /** Props for `<CheckboxRow>`. */
283
299
  export interface CheckboxRowProps {
284
300
  label: ReactNode;
285
301
  value: boolean;
286
302
  onChange: (next: boolean) => void;
287
303
  }
288
304
 
305
+ /** A boolean checkbox. */
289
306
  export function CheckboxRow({ label, value, onChange }: CheckboxRowProps) {
290
307
  return (
291
308
  <PropertyRow label={label} variant="checkbox">
@@ -294,6 +311,7 @@ export function CheckboxRow({ label, value, onChange }: CheckboxRowProps) {
294
311
  );
295
312
  }
296
313
 
314
+ /** Props for `<TextRow>`. */
297
315
  export interface TextRowProps {
298
316
  label: ReactNode;
299
317
  value: string;
@@ -303,6 +321,7 @@ export interface TextRowProps {
303
321
  layout?: PropertyRowLayout;
304
322
  }
305
323
 
324
+ /** A single-line text input. */
306
325
  export function TextRow({ label, value, onChange, placeholder, maxLength, layout }: TextRowProps) {
307
326
  return (
308
327
  <PropertyRow label={label} layout={layout}>
@@ -317,6 +336,7 @@ export function TextRow({ label, value, onChange, placeholder, maxLength, layout
317
336
  );
318
337
  }
319
338
 
339
+ /** Props for `<NumberRow>`. */
320
340
  export interface NumberRowProps {
321
341
  label: ReactNode;
322
342
  value: number;
@@ -328,6 +348,8 @@ export interface NumberRowProps {
328
348
  layout?: PropertyRowLayout;
329
349
  }
330
350
 
351
+ /** A number typed directly. Reach for `<SliderRow>` when the range matters
352
+ * more than the exact value. */
331
353
  export function NumberRow({
332
354
  label,
333
355
  value,
@@ -363,6 +385,7 @@ export interface SelectOption<T extends string> {
363
385
  label: ReactNode;
364
386
  }
365
387
 
388
+ /** Props for `<SelectRow>`. */
366
389
  export interface SelectRowProps<T extends string> {
367
390
  label: ReactNode;
368
391
  value: T;
@@ -371,6 +394,8 @@ export interface SelectRowProps<T extends string> {
371
394
  layout?: PropertyRowLayout;
372
395
  }
373
396
 
397
+ /** A dropdown over a fixed set of choices. Prefer `<ToggleRow>` when there
398
+ * are only two or three and they should all stay visible. */
374
399
  export function SelectRow<T extends string>({
375
400
  label,
376
401
  value,
@@ -399,6 +424,7 @@ export function SelectRow<T extends string>({
399
424
  );
400
425
  }
401
426
 
427
+ /** Props for `<ToggleRow>`. */
402
428
  export interface ToggleRowProps<T extends string> {
403
429
  label: ReactNode;
404
430
  value: T;
@@ -407,6 +433,8 @@ export interface ToggleRowProps<T extends string> {
407
433
  layout?: PropertyRowLayout;
408
434
  }
409
435
 
436
+ /** A segmented control: the same choice as a select, with every option
437
+ * visible at once. */
410
438
  export function ToggleRow<T extends string>({
411
439
  label,
412
440
  value,
@@ -1,11 +1,15 @@
1
+ /** A subscriber to a named event. */
1
2
  export type EventListener = () => void;
2
3
 
4
+ /** Named-event pub/sub, the channel an instrument's `emit` writes to and undo
5
+ * snapshotting listens on. */
3
6
  export interface EventBus {
4
7
  on(event: string, listener: EventListener): () => void;
5
8
  emit(event: string): void;
6
9
  clear(): void;
7
10
  }
8
11
 
12
+ /** Build an empty event bus. */
9
13
  export function createEventBus(): EventBus {
10
14
  const listeners = new Map<string, Set<EventListener>>();
11
15
  return {
@@ -1,18 +1,26 @@
1
+ /** Undo history as two stacks of state snapshots: what has been done, and
2
+ * what has been undone. The present state is held elsewhere, not here. */
1
3
  export interface UndoStack {
2
4
  past: unknown[];
3
5
  future: unknown[];
4
6
  }
5
7
 
8
+ /** A fresh, empty history. */
6
9
  export function emptyStack(): UndoStack {
7
10
  return { past: [], future: [] };
8
11
  }
9
12
 
13
+ /** Record a new snapshot, discarding the redo branch and the oldest entry
14
+ * once `maxDepth` is reached. */
10
15
  export function pushSnapshot(stack: UndoStack, snapshot: unknown, maxDepth: number): UndoStack {
11
16
  const past = stack.past.length >= maxDepth ? stack.past.slice(1) : stack.past.slice();
12
17
  past.push(snapshot);
13
18
  return { past, future: [] };
14
19
  }
15
20
 
21
+ /** Step back one snapshot, returning the new history and the state to restore.
22
+ * `null` when there is nothing to undo. The caller supplies `current` so it
23
+ * can be redone. */
16
24
  export function undo(
17
25
  stack: UndoStack,
18
26
  current: unknown,
@@ -23,6 +31,8 @@ export function undo(
23
31
  return { stack: { past, future: [...stack.future, current] }, snapshot };
24
32
  }
25
33
 
34
+ /** Step forward one snapshot, returning the new history and the state to
35
+ * restore. `null` when there is nothing to redo. */
26
36
  export function redo(
27
37
  stack: UndoStack,
28
38
  current: unknown,
@@ -33,6 +43,7 @@ export function redo(
33
43
  return { stack: { past: [...stack.past, current], future }, snapshot };
34
44
  }
35
45
 
46
+ /** Discard the whole history. */
36
47
  export function clearUndo(_stack: UndoStack): UndoStack {
37
48
  return emptyStack();
38
49
  }
@@ -2,10 +2,13 @@ import { ControlPanel } from '../controls/ControlPanel';
2
2
  import { Sidebar } from '../primitives/Sidebar';
3
3
  import type { WorkspaceSidebarContext } from './slotTypes';
4
4
 
5
+ /** Props for `<DefaultSidebar>`. */
5
6
  export interface DefaultSidebarProps {
6
7
  ctx: WorkspaceSidebarContext;
7
8
  }
8
9
 
10
+ /** The sidebar a workspace renders when no `sidebar` slot is supplied: a
11
+ * control panel over the instrument's config schema. */
9
12
  export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
10
13
  if (ctx.configFields.length === 0) {
11
14
  return (
@@ -1,10 +1,13 @@
1
1
  import { StatusBar } from '../primitives/StatusBar';
2
2
  import type { WorkspaceStatusBarContext } from './slotTypes';
3
3
 
4
+ /** Props for `<DefaultStatusBar>`. */
4
5
  export interface DefaultStatusBarProps {
5
6
  ctx: WorkspaceStatusBarContext;
6
7
  }
7
8
 
9
+ /** The status bar a workspace renders when no `statusBar` slot is supplied:
10
+ * the instrument's name and the current zoom. */
8
11
  export function DefaultStatusBar({ ctx }: DefaultStatusBarProps) {
9
12
  return (
10
13
  <StatusBar>
@@ -1,10 +1,13 @@
1
1
  import { Toolbar } from '../primitives/Toolbar';
2
2
  import type { WorkspaceToolbarContext } from './slotTypes';
3
3
 
4
+ /** Props for `<DefaultToolbar>`. */
4
5
  export interface DefaultToolbarProps {
5
6
  ctx: WorkspaceToolbarContext;
6
7
  }
7
8
 
9
+ /** The toolbar a workspace renders when no `toolbar` slot is supplied. Only
10
+ * shows the controls the instrument's declared capabilities support. */
8
11
  export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
9
12
  return (
10
13
  <Toolbar>
@@ -34,6 +34,7 @@ function Harness() {
34
34
  cloneWorkspace: noop,
35
35
  closeWorkspace: noop,
36
36
  resetWorkspace: noop,
37
+ reorderWorkspaces: noop,
37
38
  savedSnapshots: [],
38
39
  saveSnapshot: noop,
39
40
  loadSnapshot: noop,
@@ -126,6 +126,7 @@ function ChromeHarness({
126
126
  cloneWorkspace: vi.fn(),
127
127
  closeWorkspace: vi.fn(),
128
128
  resetWorkspace: vi.fn(),
129
+ reorderWorkspaces: vi.fn(),
129
130
  savedSnapshots: [],
130
131
  saveSnapshot: vi.fn(),
131
132
  loadSnapshot: vi.fn(),
@@ -21,10 +21,13 @@ import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoSt
21
21
  import type { UndoBindings } from './WorkspaceChrome';
22
22
  import { WorkspaceChrome } from './WorkspaceChrome';
23
23
 
24
+ /** Props for `<Workspace>`. */
24
25
  export interface WorkspaceProps {
25
26
  id: string;
26
27
  }
27
28
 
29
+ /** Renders one workspace from the lab store: looks up its record and
30
+ * instrument, and mounts the instrument inside the workspace chrome. */
28
31
  export function Workspace({ id }: WorkspaceProps) {
29
32
  const lab = useLabContext();
30
33
  const storeCtx = useContext(LabStoreContext);
@@ -23,6 +23,7 @@ export interface UndoBindings {
23
23
  redo: () => void;
24
24
  }
25
25
 
26
+ /** Props for `<WorkspaceChrome>`. */
26
27
  export interface WorkspaceChromeProps {
27
28
  workspaceId: string;
28
29
  record: WorkspaceRecord;
@@ -36,6 +37,9 @@ export interface WorkspaceChromeProps {
36
37
  children: ReactNode;
37
38
  }
38
39
 
40
+ /** The frame around a running instrument — toolbar, sidebar, status bar —
41
+ * each replaceable by a slot. Assembles the slot contexts and wires the undo
42
+ * keyboard shortcuts. */
39
43
  export function WorkspaceChrome({
40
44
  workspaceId,
41
45
  record,
@@ -20,5 +20,6 @@ export {
20
20
  addWorkspace,
21
21
  cloneWorkspace,
22
22
  closeWorkspace,
23
+ reorderWorkspaces,
23
24
  resetWorkspace,
24
25
  } from './workspaceOps';
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import type { SavedSnapshot } from '../state/types';
4
4
 
5
+ /** What a workspace hands its toolbar: which instrument is running, the undo
6
+ * state and commands, the zoom controls, and the snapshot commands. */
5
7
  export interface WorkspaceToolbarContext {
6
8
  workspaceId: string;
7
9
  instrumentName: string;
@@ -25,6 +27,8 @@ export interface WorkspaceToolbarContext {
25
27
  isLastWorkspace: boolean;
26
28
  }
27
29
 
30
+ /** What a workspace hands its sidebar: the instrument's config schema, its
31
+ * current values, and the setter. */
28
32
  export interface WorkspaceSidebarContext {
29
33
  workspaceId: string;
30
34
  instrumentName: string;
@@ -33,12 +37,17 @@ export interface WorkspaceSidebarContext {
33
37
  setConfig: (key: string, value: unknown) => void;
34
38
  }
35
39
 
40
+ /** What a workspace hands its status bar. */
36
41
  export interface WorkspaceStatusBarContext {
37
42
  workspaceId: string;
38
43
  instrumentName: string;
39
44
  zoom: number;
40
45
  }
41
46
 
47
+ /** Replaces a workspace's toolbar. Receives everything the default one uses,
48
+ * so a custom toolbar need not reach into the store. */
42
49
  export type ToolbarSlot = (ctx: WorkspaceToolbarContext) => ReactNode;
50
+ /** Replaces a workspace's sidebar. */
43
51
  export type SidebarSlot = (ctx: WorkspaceSidebarContext) => ReactNode;
52
+ /** Replaces a workspace's status bar. */
44
53
  export type StatusBarSlot = (ctx: WorkspaceStatusBarContext) => ReactNode;
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Instrument } from '../instrument/types';
3
3
  import type { WorkspaceRecord } from '../state/types';
4
- import { addWorkspace, cloneWorkspace, closeWorkspace, resetWorkspace } from './workspaceOps';
4
+ import {
5
+ addWorkspace,
6
+ cloneWorkspace,
7
+ closeWorkspace,
8
+ reorderWorkspaces,
9
+ resetWorkspace,
10
+ } from './workspaceOps';
5
11
 
6
12
  interface CounterState {
7
13
  count: number;
@@ -140,3 +146,30 @@ describe('resetWorkspace', () => {
140
146
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
147
  });
142
148
  });
149
+
150
+ describe('reorderWorkspaces', () => {
151
+ const ws = (id: string) =>
152
+ ({
153
+ id,
154
+ instrumentName: 'T',
155
+ config: {},
156
+ state: {},
157
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
158
+ undoStack: { past: [], future: [] },
159
+ }) as never;
160
+
161
+ it('reorders to match the given ids', () => {
162
+ const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
163
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
164
+ });
165
+
166
+ it('drops ids that no longer exist rather than resurrecting them', () => {
167
+ const next = reorderWorkspaces([ws('a'), ws('b')], ['gone', 'b', 'a']);
168
+ expect(next.map((w) => w.id)).toEqual(['b', 'a']);
169
+ });
170
+
171
+ it('keeps unmentioned workspaces, after the named ones', () => {
172
+ const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c']);
173
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
174
+ });
175
+ });
@@ -17,6 +17,8 @@ function initialView(instrument: Instrument): WorkspaceRecord['view'] {
17
17
  : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
18
18
  }
19
19
 
20
+ /** Append a new workspace running `instrumentName`, at that instrument's
21
+ * default config and initial state. */
20
22
  export function addWorkspace(
21
23
  workspaces: WorkspaceRecord[],
22
24
  instruments: Instrument[],
@@ -36,6 +38,8 @@ export function addWorkspace(
36
38
  return [...workspaces, record];
37
39
  }
38
40
 
41
+ /** Insert a deep copy of a workspace directly after it. The copy starts with
42
+ * an empty undo history — the original's is not shared. */
39
43
  export function cloneWorkspace(workspaces: WorkspaceRecord[], id: string): WorkspaceRecord[] {
40
44
  const sourceIdx = workspaces.findIndex((w) => w.id === id);
41
45
  const source = workspaces[sourceIdx];
@@ -51,12 +55,16 @@ export function cloneWorkspace(workspaces: WorkspaceRecord[], id: string): Works
51
55
  return [...workspaces.slice(0, sourceIdx + 1), clone, ...workspaces.slice(sourceIdx + 1)];
52
56
  }
53
57
 
58
+ /** Remove a workspace, unless it is the last one — a lab always has at least
59
+ * one. */
54
60
  export function closeWorkspace(workspaces: WorkspaceRecord[], id: string): WorkspaceRecord[] {
55
61
  if (workspaces.length <= 1) return workspaces;
56
62
  const next = workspaces.filter((w) => w.id !== id);
57
63
  return next.length === workspaces.length ? workspaces : next;
58
64
  }
59
65
 
66
+ /** Return a workspace to its instrument's defaults, keeping its id and its
67
+ * place in the list. */
60
68
  export function resetWorkspace(
61
69
  workspaces: WorkspaceRecord[],
62
70
  id: string,
@@ -76,3 +84,19 @@ export function resetWorkspace(
76
84
  };
77
85
  return [...workspaces.slice(0, idx), reset, ...workspaces.slice(idx + 1)];
78
86
  }
87
+
88
+ /**
89
+ * Reorder to match `ids`. Ids the list doesn't mention keep their relative
90
+ * order at the end, and ids it names that no longer exist are dropped — a
91
+ * reorder that raced a close should not resurrect the closed workspace.
92
+ */
93
+ export function reorderWorkspaces(
94
+ workspaces: WorkspaceRecord[],
95
+ ids: readonly string[],
96
+ ): WorkspaceRecord[] {
97
+ const byId = new Map(workspaces.map((w) => [w.id, w]));
98
+ const named = ids.map((id) => byId.get(id)).filter((w): w is WorkspaceRecord => w !== undefined);
99
+ const seen = new Set(named.map((w) => w.id));
100
+ const rest = workspaces.filter((w) => !seen.has(w.id));
101
+ return [...named, ...rest];
102
+ }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/layers/LayerList.tsx"],"names":[],"mappings":"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","file":"chunk-2QNYYL3V.js","sourcesContent":["import { type PointerEvent, useRef, useState } from 'react';\nimport type { LayerDescriptor } from '../instrument/types';\n\nexport interface LayerListProps {\n layers: LayerDescriptor[];\n visibility: Record<string, boolean>;\n onReorder: (newOrder: LayerDescriptor[]) => void;\n onToggle: (id: string, visible: boolean) => void;\n className?: string;\n}\n\ninterface DragState {\n pointerId: number;\n fromIndex: number;\n startY: number;\n}\n\nexport function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {\n const reorderable = layers.filter((l) => !l.alwaysOn);\n const pinned = layers.filter((l) => l.alwaysOn);\n const [dragIndex, setDragIndex] = useState<number | null>(null);\n const dragRef = useRef<DragState | null>(null);\n\n if (layers.length === 0) {\n return (\n <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>\n <div className=\"lk-layer-list__empty\">No layers</div>\n </div>\n );\n }\n\n const startDrag = (e: PointerEvent<HTMLElement>, index: number) => {\n e.currentTarget.setPointerCapture(e.pointerId);\n dragRef.current = { pointerId: e.pointerId, fromIndex: index, startY: e.clientY };\n setDragIndex(index);\n };\n\n const moveDrag = (e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n const rowHeight = 28;\n const delta = Math.round((e.clientY - drag.startY) / rowHeight);\n const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));\n setDragIndex(target);\n };\n\n const endDrag = (e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n e.currentTarget.releasePointerCapture(e.pointerId);\n }\n if (dragIndex !== null && dragIndex !== drag.fromIndex) {\n const next = [...reorderable];\n const [moved] = next.splice(drag.fromIndex, 1);\n if (moved) next.splice(dragIndex, 0, moved);\n onReorder([...next, ...pinned]);\n }\n dragRef.current = null;\n setDragIndex(null);\n };\n\n return (\n <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>\n {reorderable.map((layer, i) => {\n const isDragging = dragIndex === i;\n return (\n <div\n key={layer.id}\n className={\n isDragging ? 'lk-layer-list__row lk-layer-list__row--dragging' : 'lk-layer-list__row'\n }\n >\n <button\n type=\"button\"\n className=\"lk-layer-list__handle\"\n aria-label={`Reorder ${layer.label}`}\n onPointerDown={(e) => startDrag(e, i)}\n onPointerMove={moveDrag}\n onPointerUp={endDrag}\n >\n ⋮⋮\n </button>\n <input\n className=\"lk-layer-list__check\"\n type=\"checkbox\"\n checked={visibility[layer.id] !== false}\n onChange={(e) => onToggle(layer.id, e.target.checked)}\n aria-label={`Toggle ${layer.label}`}\n />\n <span className=\"lk-layer-list__label\">{layer.label}</span>\n </div>\n );\n })}\n {pinned.map((layer) => (\n <div key={layer.id} className=\"lk-layer-list__row lk-layer-list__row--pinned\">\n <span className=\"lk-layer-list__lock\" role=\"img\" aria-label=\"Always on\">\n 🔒\n </span>\n <span className=\"lk-layer-list__label\">{layer.label}</span>\n </div>\n ))}\n </div>\n );\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/ui/layers/LayerStack.tsx"],"names":["s"],"mappings":";;;;AAwCO,SAAS,UAAA,CAAW;AAAA,EACzB,KAAA;AAAA,EACA,KAAA;AAAA,EACA,YAAA;AAAA,EACA,KAAA;AAAA,EACA,QAAA;AAAA,EACA,SAAA;AAAA,EACA,eAAA;AAAA,EACA,UAAA;AAAA,EACA;AACF,CAAA,EAAoB;AAClB,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,CAAA,GAAI,QAAA;AAAA,IACpC,MAAM,IAAI,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,eAAA,KAAoB,KAAK,EAAE,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,EAAE,CAAC;AAAA,GACjF;AAEA,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,cAAA,CAAe,CAAC,IAAA,KAAS;AACvB,MAAA,IAAI,IAAA,GAAoC,IAAA;AACxC,MAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,QAAA,IAAI,IAAA,CAAK,oBAAoB,KAAA,EAAO;AACpC,QAAA,IAAI,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,EAAG;AACvB,QAAA,IAAI,IAAA,KAAS,IAAA,EAAM,IAAA,GAAO,IAAI,IAAI,IAAI,CAAA;AACtC,QAAA,IAAA,CAAK,GAAA,CAAI,KAAK,EAAE,CAAA;AAAA,MAClB;AACA,MAAA,OAAO,IAAA,IAAQ,IAAA;AAAA,IACjB,CAAC,CAAA;AAAA,EACH,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAEV,EAAA,MAAM,SAAA,GAAY,KAAA,CAAM,GAAA,CAAI,CAAC,QAAQ,EAAE,EAAA,EAAI,MAAA,CAAO,EAAA,CAAG,EAAE,CAAA,EAAG,KAAA,EAAO,EAAA,CAAG,MAAK,CAAE,CAAA;AAC3E,EAAA,MAAM,OAAO,CAAA,CAAmB;AAAA,IAC9B,KAAA,EAAO,SAAA;AAAA,IACP,aAAa,EAAC;AAAA,IACd,SAAA,EAAW,CAAC,GAAA,EAAK,WAAA,KAAgB;AAC/B,MAAA,CAAA,CAAK,aAAA,EAAe,WAAA,EAAa,EAAE,GAAA,EAAK,aAAa,CAAA;AACrD,MAAA,MAAM,OAAO,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM,EAAE,EAAE,CAAA;AAClC,MAAA,MAAM,MAAA,GAAS,IAAI,GAAA,CAAI,GAAG,CAAA;AAC1B,MAAA,MAAM,SAAA,GAAY,IAAA,CAAK,MAAA,CAAO,CAAC,EAAA,KAAO,CAAC,MAAA,CAAO,GAAA,CAAI,MAAA,CAAO,EAAE,CAAC,CAAC,CAAA;AAC7D,MAAA,MAAM,WAAW,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM,EAAE,EAAE,CAAA,CAAE,MAAA,CAAO,CAAC,OAAO,MAAA,CAAO,GAAA,CAAI,MAAA,CAAO,EAAE,CAAC,CAAC,CAAA;AAI7E,MAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,CAAC,IAAI,GAAA,KAAQ,MAAA,CAAO,GAAA,CAAI,MAAA,CAAO,EAAE,CAAC,CAAA,IAAK,GAAA,GAAM,WAAW,CAAA,CAAE,MAAA;AACpF,MAAA,MAAM,WAAW,WAAA,GAAc,KAAA;AAC/B,MAAA,MAAM,GAAA,GAAM,CAAC,GAAG,SAAS,CAAA;AACzB,MAAA,GAAA,CAAI,MAAA,CAAO,QAAA,EAAU,CAAA,EAAG,GAAG,QAAQ,CAAA;AACnC,MAAA,SAAA,CAAU,GAAG,CAAA;AAAA,IACf;AAAA,GACD,CAAA;AAED,EAAA,MAAM,cAAA,GAAiB,CAAC,EAAA,KAAwB;AAC9C,IAAA,cAAA,CAAe,CAACA,EAAAA,KAAM;AACpB,MAAA,MAAM,CAAA,GAAI,IAAI,GAAA,CAAIA,EAAC,CAAA;AACnB,MAAA,IAAI,EAAE,GAAA,CAAI,EAAE,CAAA,EAAG,CAAA,CAAE,OAAO,EAAE,CAAA;AAAA,WACrB,CAAA,CAAE,IAAI,EAAE,CAAA;AACb,MAAA,OAAO,CAAA;AAAA,IACT,CAAC,CAAA;AAAA,EACH,CAAA;AAEA,EAAA,uBACE,IAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,gBAAA,EACZ,QAAA,EAAA;AAAA,IAAA,CAAC,QAAA,oBACA,IAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,sBAAA,EACb,QAAA,EAAA;AAAA,sBAAA,GAAA,CAAC,IAAA,EAAA,EAAG,SAAA,EAAU,uBAAA,EAAyB,QAAA,EAAA,KAAA,EAAM,CAAA;AAAA,0BAC5C,KAAA,EAAA,EAAI,SAAA,EAAU,2BACZ,QAAA,EAAA,YAAA,CAAa,GAAA,CAAI,CAAC,CAAA,qBACjB,GAAA;AAAA,QAAC,QAAA;AAAA,QAAA;AAAA,UAEC,IAAA,EAAK,QAAA;AAAA,UACL,SAAA,EAAU,qBAAA;AAAA,UACV,SAAS,MAAM;AACb,YAAA,CAAA,CAAK,aAAA,EAAe,OAAA,EAAS,EAAE,IAAA,EAAM,GAAG,CAAA;AACxC,YAAA,KAAA,CAAM,CAAC,CAAA;AAAA,UACT,CAAA;AAAA,UACA,YAAA,EAAY,OAAO,CAAC,CAAA,CAAA;AAAA,UAEnB,QAAA,EAAA;AAAA,SAAA;AAAA,QATI;AAAA,OAWR,CAAA,EACH;AAAA,KAAA,EACF,CAAA;AAAA,oBAEF,IAAA;AAAA,MAAC,KAAA;AAAA,MAAA;AAAA,QACC,SAAA,EAAU,sBAAA;AAAA,QACV,GAAA,EAAK,KAAK,cAAA,CAAe,GAAA;AAAA,QACzB,aAAA,EAAe,KAAK,cAAA,CAAe,aAAA;AAAA,QACnC,WAAA,EAAa,KAAK,cAAA,CAAe,WAAA;AAAA,QACjC,eAAA,EAAiB,KAAK,cAAA,CAAe,eAAA;AAAA,QAEpC,QAAA,EAAA;AAAA,UAAA,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,EAAM,CAAA,KAAM;AACtB,YAAA,MAAM,QAAA,GAAW,WAAA,CAAY,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA;AACxC,YAAA,MAAM,SAAA,GAAY,IAAA,CAAK,KAAA,CAAM,UAAA,GAAa,CAAC,CAAA;AAC3C,YAAA,MAAM,UAAA,GAAa,SAAA,KAAc,MAAA,CAAO,IAAA,CAAK,EAAE,CAAA;AAC/C,YAAA,MAAM,cAAA,GAAiB,KAAK,KAAA,CAAM,WAAA,KAAgB,KAAK,SAAA,KAAc,MAAA,CAAO,KAAK,EAAE,CAAA;AACnF,YAAA,MAAM,aAAA,GAAgB,KAAK,KAAA,CAAM,WAAA,KAAgB,MAAM,MAAA,IAAU,CAAA,KAAM,MAAM,MAAA,GAAS,CAAA;AACtF,YAAA,MAAM,OAAA,GAAU;AAAA,cACd,eAAA;AAAA,cACA,WAAW,aAAA,GAAgB,cAAA;AAAA,cAC3B,aAAa,aAAA,GAAgB,EAAA;AAAA,cAC7B,IAAA,CAAK,SAAS,YAAA,GAAe;AAAA,aAC/B,CACG,MAAA,CAAO,OAAO,CAAA,CACd,KAAK,GAAG,CAAA;AACX,YAAA,MAAM,YAAY,IAAA,CAAK,MAAA,GAClB,EAAE,wBAAA,EAA0B,IAAA,CAAK,QAAO,GACzC,MAAA;AACJ,YAAA,MAAM,EAAE,eAAc,GAAI,IAAA,CAAK,SAAS,MAAA,CAAO,IAAA,CAAK,EAAE,CAAA,EAAG,CAAC,CAAA;AAC1D,YAAA,uBACE,IAAA,CAAC,KAAA,EAAA,EAAkB,SAAA,EAAU,oBAAA,EAC1B,QAAA,EAAA;AAAA,cAAA,cAAA,oBAAkB,GAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,2BAAA,EAA4B,CAAA;AAAA,8BAC9D,IAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAW,OAAA,EAAS,aAAA,EAAa,iBAAiB,IAAA,CAAK,EAAE,CAAA,CAAA,EAAI,KAAA,EAAO,SAAA,EACvE,QAAA,EAAA;AAAA,gCAAA,IAAA,CAAC,KAAA,EAAA,EAAI,WAAU,qBAAA,EACb,QAAA,EAAA;AAAA,kCAAA,GAAA;AAAA,oBAAC,QAAA;AAAA,oBAAA;AAAA,sBACC,IAAA,EAAK,QAAA;AAAA,sBACL,SAAA,EAAU,uBAAA;AAAA,sBACV,YAAA,EAAY,CAAA,sBAAA,EAAyB,IAAA,CAAK,EAAE,CAAA,CAAA;AAAA,sBAC5C,aAAA;AAAA,sBACA,OAAA,EAAS,MAAM,cAAA,CAAe,IAAA,CAAK,EAAE,CAAA;AAAA,sBAEpC,QAAA,EAAA,IAAA,CAAK,KAAA,oBAAS,GAAA,CAAC,eAAA,EAAA,EAAgB;AAAA;AAAA,mBAClC;AAAA,kBACC,IAAA,CAAK,YAAA,KAAiB,MAAA,IAAa,IAAA,CAAK,cAAA,mBACvC,GAAA;AAAA,oBAAC,QAAA;AAAA,oBAAA;AAAA,sBACC,SAAA,EAAU,wBAAA;AAAA,sBACV,OAAO,IAAA,CAAK,YAAA;AAAA,sBACZ,YAAA,EAAY,CAAA,yBAAA,EAA4B,IAAA,CAAK,EAAE,CAAA,CAAA;AAAA,sBAC/C,QAAA,EAAU,CAAC,CAAA,KAAM,eAAA,CAAgB,KAAK,EAAA,EAAI,CAAA,CAAE,OAAO,KAAK,CAAA;AAAA,sBACxD,OAAA,EAAS,CAAC,CAAA,KAAM,CAAA,CAAE,eAAA,EAAgB;AAAA,sBAEjC,QAAA,EAAA,IAAA,CAAK,cAAA,CAAe,GAAA,CAAI,CAAC,CAAA,qBACxB,GAAA,CAAC,QAAA,EAAA,EAAe,KAAA,EAAO,CAAA,EACpB,QAAA,EAAA,CAAA,EAAA,EADU,CAEb,CACD;AAAA;AAAA,sCAGH,GAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,qBAAA,EAAuB,eAAK,IAAA,EAAK,CAAA;AAAA,kCAEnD,GAAA;AAAA,oBAAC,QAAA;AAAA,oBAAA;AAAA,sBACC,IAAA,EAAK,QAAA;AAAA,sBACL,SAAA,EAAU,uBAAA;AAAA,sBACV,YAAA,EAAW,cAAA;AAAA,sBACX,OAAA,EAAS,CAAC,CAAA,KAAM;AACd,wBAAA,CAAA,CAAE,eAAA,EAAgB;AAClB,wBAAA,QAAA,CAAS,KAAK,EAAE,CAAA;AAAA,sBAClB,CAAA;AAAA,sBACD,QAAA,EAAA;AAAA;AAAA;AAED,iBAAA,EACF,CAAA;AAAA,gBACC,4BAAY,GAAA,CAAC,KAAA,EAAA,EAAI,WAAU,qBAAA,EAAuB,QAAA,EAAA,UAAA,CAAW,IAAI,CAAA,EAAE;AAAA,eAAA,EACtE,CAAA;AAAA,cACC,aAAA,oBAAiB,GAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,2BAAA,EAA4B;AAAA,aAAA,EAAA,EA5CrD,KAAK,EA6Cf,CAAA;AAAA,UAEJ,CAAC,CAAA;AAAA,UACA,MAAM,MAAA,KAAW,CAAA,wBACf,KAAA,EAAA,EAAI,SAAA,EAAU,yBAAwB,QAAA,EAAA,iCAAA,EAA0B;AAAA;AAAA;AAAA;AAErE,GAAA,EACF,CAAA;AAEJ;AAEA,SAAS,eAAA,GAAkB;AACzB,EAAA,uBACE,IAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,IAAA,EAAK,MAAA,EAAO,IAAA,EAAK,OAAA,EAAQ,UAAA,EAAW,IAAA,EAAK,cAAA,EAAe,aAAA,EAAY,MAAA,EAC7E,QAAA,EAAA;AAAA,oBAAA,GAAA,CAAC,YAAO,EAAA,EAAG,GAAA,EAAI,EAAA,EAAG,GAAA,EAAI,GAAE,KAAA,EAAM,CAAA;AAAA,wBAC7B,QAAA,EAAA,EAAO,EAAA,EAAG,KAAI,EAAA,EAAG,GAAA,EAAI,GAAE,KAAA,EAAM,CAAA;AAAA,wBAC7B,QAAA,EAAA,EAAO,EAAA,EAAG,KAAI,EAAA,EAAG,GAAA,EAAI,GAAE,KAAA,EAAM,CAAA;AAAA,wBAC7B,QAAA,EAAA,EAAO,EAAA,EAAG,KAAI,EAAA,EAAG,GAAA,EAAI,GAAE,KAAA,EAAM,CAAA;AAAA,wBAC7B,QAAA,EAAA,EAAO,EAAA,EAAG,KAAI,EAAA,EAAG,IAAA,EAAK,GAAE,KAAA,EAAM,CAAA;AAAA,wBAC9B,QAAA,EAAA,EAAO,EAAA,EAAG,KAAI,EAAA,EAAG,IAAA,EAAK,GAAE,KAAA,EAAM;AAAA,GAAA,EACjC,CAAA;AAEJ","file":"chunk-2ZRE7WGQ.js","sourcesContent":["import { type CSSProperties, type ReactNode, type RefCallback, useEffect, useState } from 'react';\nimport { dlog, useReorderDragList } from '../../passthrough/weasel-ui';\n\nexport interface LayerStackItem {\n /** Stable id used for keys, onRemove, onReorder. Numeric to match\n * common id-from-nextId conventions; string ids also work. */\n id: number | string;\n /** Short kind label rendered in the header when no primary select\n * is hoisted (e.g. \"shadow\", \"stroke\"). */\n kind: string;\n /** When present, hoist this select into the card header so the user\n * can switch mode/shape without expanding. */\n primaryValue?: string;\n primaryOptions?: string[];\n /** Accent CSS color used as the left border / index-badge fill. */\n accent?: string;\n /** Optional badge text rendered before the primary control\n * (e.g. tail index \"1\", \"2\", \"3\"). When omitted a drag handle\n * glyph renders in its place. */\n badge?: string;\n /** Initial expanded state. Defaults to true for newly-added items. */\n defaultExpanded?: boolean;\n}\n\nexport interface LayerStackProps {\n title: string;\n items: LayerStackItem[];\n /** Kinds the user can add via the header palette. */\n paletteKinds: string[];\n onAdd: (kind: string) => void;\n onRemove: (id: number | string) => void;\n onReorder: (orderedIds: Array<number | string>) => void;\n onPrimaryChange: (id: number | string, nextValue: string) => void;\n /** Render the body controls for each item. */\n renderBody: (item: LayerStackItem) => ReactNode;\n /** Hide the title + palette row (used when an outer wrap renders its\n * own head — see speech-balloons Tails panel). */\n hideHead?: boolean;\n}\n\nexport function LayerStack({\n title,\n items,\n paletteKinds,\n onAdd,\n onRemove,\n onReorder,\n onPrimaryChange,\n renderBody,\n hideHead,\n}: LayerStackProps) {\n const [expandedIds, setExpandedIds] = useState<Set<number | string>>(\n () => new Set(items.filter((i) => i.defaultExpanded !== false).map((i) => i.id)),\n );\n\n useEffect(() => {\n setExpandedIds((prev) => {\n let next: Set<number | string> | null = null;\n for (const item of items) {\n if (item.defaultExpanded === false) continue;\n if (prev.has(item.id)) continue;\n if (next === null) next = new Set(prev);\n next.add(item.id);\n }\n return next ?? prev;\n });\n }, [items]);\n\n const dragItems = items.map((it) => ({ id: String(it.id), label: it.kind }));\n const drag = useReorderDragList({\n items: dragItems,\n selectedIds: [],\n onReorder: (ids, targetIndex) => {\n dlog('layer-stack', 'onReorder', { ids, targetIndex });\n const orig = items.map((i) => i.id);\n const moving = new Set(ids);\n const remaining = orig.filter((id) => !moving.has(String(id)));\n const movedIds = items.map((i) => i.id).filter((id) => moving.has(String(id)));\n // weasel reports targetIndex against the original list (which includes\n // the dragged row). Each moved item that originally sat before\n // targetIndex needs to shift the insertion point left by one.\n const shift = orig.filter((id, idx) => moving.has(String(id)) && idx < targetIndex).length;\n const adjusted = targetIndex - shift;\n const out = [...remaining];\n out.splice(adjusted, 0, ...movedIds);\n onReorder(out);\n },\n });\n\n const toggleExpanded = (id: number | string) => {\n setExpandedIds((s) => {\n const n = new Set(s);\n if (n.has(id)) n.delete(id);\n else n.add(id);\n return n;\n });\n };\n\n return (\n <div className=\"lk-layer-stack\">\n {!hideHead && (\n <div className=\"lk-layer-stack__head\">\n <h2 className=\"lk-layer-stack__title\">{title}</h2>\n <div className=\"lk-layer-stack__palette\">\n {paletteKinds.map((k) => (\n <button\n key={k}\n type=\"button\"\n className=\"lk-layer-stack__add\"\n onClick={() => {\n dlog('layer-stack', 'onAdd', { kind: k });\n onAdd(k);\n }}\n aria-label={`Add ${k}`}\n >\n {k}\n </button>\n ))}\n </div>\n </div>\n )}\n <div\n className=\"lk-layer-stack__list\"\n ref={drag.containerProps.ref as RefCallback<HTMLDivElement>}\n onPointerMove={drag.containerProps.onPointerMove}\n onPointerUp={drag.containerProps.onPointerUp}\n onPointerCancel={drag.containerProps.onPointerCancel}\n >\n {items.map((item, i) => {\n const expanded = expandedIds.has(item.id);\n const draggedId = drag.state.draggedIds?.[0];\n const isDragging = draggedId === String(item.id);\n const showHintBefore = drag.state.targetIndex === i && draggedId !== String(item.id);\n const showHintAfter = drag.state.targetIndex === items.length && i === items.length - 1;\n const cardCls = [\n 'lk-layer-card',\n expanded ? 'is-expanded' : 'is-collapsed',\n isDragging ? 'is-dragging' : '',\n item.accent ? 'has-accent' : '',\n ]\n .filter(Boolean)\n .join(' ');\n const cardStyle = item.accent\n ? ({ '--lk-layer-card-accent': item.accent } as CSSProperties)\n : undefined;\n const { onPointerDown } = drag.rowProps(String(item.id), i);\n return (\n <div key={item.id} className=\"lk-layer-card-wrap\">\n {showHintBefore && <div className=\"lk-layer-stack__drop-hint\" />}\n <div className={cardCls} data-testid={`lk-layer-card-${item.id}`} style={cardStyle}>\n <div className=\"lk-layer-card__head\">\n <button\n type=\"button\"\n className=\"lk-layer-card__handle\"\n aria-label={`Drag to reorder layer ${item.id}`}\n onPointerDown={onPointerDown}\n onClick={() => toggleExpanded(item.id)}\n >\n {item.badge ?? <DragHandleGlyph />}\n </button>\n {item.primaryValue !== undefined && item.primaryOptions ? (\n <select\n className=\"lk-layer-card__primary\"\n value={item.primaryValue}\n aria-label={`Primary select for layer ${item.id}`}\n onChange={(e) => onPrimaryChange(item.id, e.target.value)}\n onClick={(e) => e.stopPropagation()}\n >\n {item.primaryOptions.map((o) => (\n <option key={o} value={o}>\n {o}\n </option>\n ))}\n </select>\n ) : (\n <span className=\"lk-layer-card__kind\">{item.kind}</span>\n )}\n <button\n type=\"button\"\n className=\"lk-layer-card__remove\"\n aria-label=\"Remove layer\"\n onClick={(e) => {\n e.stopPropagation();\n onRemove(item.id);\n }}\n >\n ✕\n </button>\n </div>\n {expanded && <div className=\"lk-layer-card__body\">{renderBody(item)}</div>}\n </div>\n {showHintAfter && <div className=\"lk-layer-stack__drop-hint\" />}\n </div>\n );\n })}\n {items.length === 0 && (\n <div className=\"lk-layer-stack__empty\">No layers — add one above.</div>\n )}\n </div>\n </div>\n );\n}\n\nfunction DragHandleGlyph() {\n return (\n <svg width=\"12\" height=\"16\" viewBox=\"0 0 8 16\" fill=\"currentColor\" aria-hidden=\"true\">\n <circle cx=\"2\" cy=\"3\" r=\"1.1\" />\n <circle cx=\"6\" cy=\"3\" r=\"1.1\" />\n <circle cx=\"2\" cy=\"8\" r=\"1.1\" />\n <circle cx=\"6\" cy=\"8\" r=\"1.1\" />\n <circle cx=\"2\" cy=\"13\" r=\"1.1\" />\n <circle cx=\"6\" cy=\"13\" r=\"1.1\" />\n </svg>\n );\n}\n"]}