@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,29 +1,44 @@
1
- export { U as UndoCapability } from '../_dts/index-C6Yze7sQ.js';
1
+ export { U as UndoCapability } from '../_dts/index-CFlDPeZh.js';
2
2
  import 'react';
3
- import '../_dts/types-Si4Fw-1F.js';
3
+ import '../_dts/types-x92Kfeme.js';
4
4
 
5
+ /** A subscriber to a named event. */
5
6
  type EventListener = () => void;
7
+ /** Named-event pub/sub, the channel an instrument's `emit` writes to and undo
8
+ * snapshotting listens on. */
6
9
  interface EventBus {
7
10
  on(event: string, listener: EventListener): () => void;
8
11
  emit(event: string): void;
9
12
  clear(): void;
10
13
  }
14
+ /** Build an empty event bus. */
11
15
  declare function createEventBus(): EventBus;
12
16
 
17
+ /** Undo history as two stacks of state snapshots: what has been done, and
18
+ * what has been undone. The present state is held elsewhere, not here. */
13
19
  interface UndoStack {
14
20
  past: unknown[];
15
21
  future: unknown[];
16
22
  }
23
+ /** A fresh, empty history. */
17
24
  declare function emptyStack(): UndoStack;
25
+ /** Record a new snapshot, discarding the redo branch and the oldest entry
26
+ * once `maxDepth` is reached. */
18
27
  declare function pushSnapshot(stack: UndoStack, snapshot: unknown, maxDepth: number): UndoStack;
28
+ /** Step back one snapshot, returning the new history and the state to restore.
29
+ * `null` when there is nothing to undo. The caller supplies `current` so it
30
+ * can be redone. */
19
31
  declare function undo(stack: UndoStack, current: unknown): {
20
32
  stack: UndoStack;
21
33
  snapshot: unknown;
22
34
  } | null;
35
+ /** Step forward one snapshot, returning the new history and the state to
36
+ * restore. `null` when there is nothing to redo. */
23
37
  declare function redo(stack: UndoStack, current: unknown): {
24
38
  stack: UndoStack;
25
39
  snapshot: unknown;
26
40
  } | null;
41
+ /** Discard the whole history. */
27
42
  declare function clearUndo(_stack: UndoStack): UndoStack;
28
43
 
29
44
  export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo };
@@ -1,3 +1,3 @@
1
- export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from '../chunk-7BKDG73Z.js';
1
+ export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from '../chunk-RL2LOLNI.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -97,6 +97,7 @@
97
97
  "earcut": "2.2.4",
98
98
  "polygon-clipping": "^0.15.7",
99
99
  "react-aria-components": "^1.5.0",
100
+ "windease": "^1.2.1",
100
101
  "zustand": "^5.0.12"
101
102
  },
102
103
  "devDependencies": {
@@ -5,6 +5,7 @@ import { screenToWorld } from './canvasCoords';
5
5
  import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
6
6
  import { usePanZoom } from './usePanZoom';
7
7
 
8
+ /** Props for `<CanvasStack>`. */
8
9
  export interface CanvasStackProps {
9
10
  layers: CanvasLayerDescriptor[];
10
11
  view: ViewTransform;
@@ -16,6 +17,9 @@ export interface CanvasStackProps {
16
17
  children?: ReactNode;
17
18
  }
18
19
 
20
+ /** Stacks one `<canvas>` per layer and drives them from a shared view, so a
21
+ * layer that changes rarely is not redrawn with one that changes every frame.
22
+ * Handles sizing, device pixel ratio, and pan/zoom. */
19
23
  export function CanvasStack({
20
24
  layers,
21
25
  view,
@@ -1,8 +1,11 @@
1
1
  import { createContext } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
3
 
4
+ /** What a canvas stack publishes to its children — currently the view, so
5
+ * DOM overlays can position themselves in the same coordinates. */
4
6
  export interface CanvasStackContextValue {
5
7
  view: ViewTransform;
6
8
  }
7
9
 
10
+ /** Context carrying the surrounding canvas stack's view. */
8
11
  export const CanvasStackContext = createContext<CanvasStackContextValue | null>(null);
@@ -1,5 +1,6 @@
1
1
  import type { Point, ViewTransform } from '../instrument/types';
2
2
 
3
+ /** Project a world point into screen coordinates under a view. */
3
4
  export function worldToScreen(world: Point, view: ViewTransform): Point {
4
5
  return {
5
6
  x: world.x * view.zoom + view.pan.x,
@@ -7,6 +8,7 @@ export function worldToScreen(world: Point, view: ViewTransform): Point {
7
8
  };
8
9
  }
9
10
 
11
+ /** Unproject a screen point back to world coordinates under a view. */
10
12
  export function screenToWorld(screen: Point, view: ViewTransform): Point {
11
13
  return {
12
14
  x: (screen.x - view.pan.x) / view.zoom,
@@ -1,6 +1,8 @@
1
1
  import { type RefObject, useEffect, useRef } from 'react';
2
2
  import type { ViewTransform } from '../instrument/types';
3
3
 
4
+ /** One layer of a canvas stack: its id, whether it is currently shown, and how
5
+ * it paints itself. */
4
6
  export interface CanvasLayerDescriptor {
5
7
  id: string;
6
8
  visible: boolean;
@@ -16,6 +16,8 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
16
16
  className?: string;
17
17
  }
18
18
 
19
+ /** Render an instrument's config schema as a stack of controls, each writing
20
+ * back through `setConfig`. */
19
21
  export function ControlPanel<TC extends Record<string, unknown>>({
20
22
  fields,
21
23
  config,
@@ -1,11 +1,15 @@
1
+ /** Which control a config field is edited with. */
1
2
  export type ConfigFieldType = 'slider' | 'checkbox' | 'select' | 'number' | 'text' | 'color';
2
3
 
4
+ /** What every config field carries: the config key it writes, the label shown
5
+ * beside it, and which control renders it. */
3
6
  export interface ConfigFieldBase {
4
7
  key: string;
5
8
  label: string;
6
9
  type: ConfigFieldType;
7
10
  }
8
11
 
12
+ /** A bounded number edited by dragging. */
9
13
  export interface SliderField extends ConfigFieldBase {
10
14
  type: 'slider';
11
15
  default: number;
@@ -14,22 +18,27 @@ export interface SliderField extends ConfigFieldBase {
14
18
  step?: number;
15
19
  }
16
20
 
21
+ /** A boolean. */
17
22
  export interface CheckboxField extends ConfigFieldBase {
18
23
  type: 'checkbox';
19
24
  default: boolean;
20
25
  }
21
26
 
27
+ /** One choice in a select field. */
22
28
  export interface SelectOption {
23
29
  value: string;
24
30
  label: string;
25
31
  }
26
32
 
33
+ /** A fixed set of labeled choices. */
27
34
  export interface SelectField extends ConfigFieldBase {
28
35
  type: 'select';
29
36
  default: string;
30
37
  options: SelectOption[];
31
38
  }
32
39
 
40
+ /** A number typed directly, optionally bounded. Use a slider field instead
41
+ * when the range matters more than the exact value. */
33
42
  export interface NumberField extends ConfigFieldBase {
34
43
  type: 'number';
35
44
  default: number;
@@ -38,6 +47,8 @@ export interface NumberField extends ConfigFieldBase {
38
47
  step?: number;
39
48
  }
40
49
 
50
+ /** A free-text string. Writes are debounced so typing does not re-run the
51
+ * instrument on every keystroke. */
41
52
  export interface TextField extends ConfigFieldBase {
42
53
  type: 'text';
43
54
  default: string;
@@ -47,11 +58,14 @@ export interface TextField extends ConfigFieldBase {
47
58
  debounceMs?: number;
48
59
  }
49
60
 
61
+ /** A color, as a CSS color string. */
50
62
  export interface ColorField extends ConfigFieldBase {
51
63
  type: 'color';
52
64
  default: string;
53
65
  }
54
66
 
67
+ /** One field of an instrument's config schema. The schema is what the control
68
+ * panel renders, and what `validateConfigSchema` checks. */
55
69
  export type ConfigField =
56
70
  | SliderField
57
71
  | CheckboxField
@@ -1,5 +1,7 @@
1
1
  import type { Instrument } from './types';
2
2
 
3
+ /** Which optional capabilities an instrument declared — what the workspace
4
+ * consults to decide which chrome to show. */
3
5
  export interface CapabilityFlags {
4
6
  hasCanvas: boolean;
5
7
  hasLayers: boolean;
@@ -7,6 +9,7 @@ export interface CapabilityFlags {
7
9
  hasUndo: boolean;
8
10
  }
9
11
 
12
+ /** Read an instrument's declared capabilities off its definition. */
10
13
  export function detectCapabilities(instrument: Instrument<unknown, unknown>): CapabilityFlags {
11
14
  return {
12
15
  hasCanvas: instrument.canvas != null,
@@ -1,5 +1,7 @@
1
1
  import type { Instrument } from './types';
2
2
 
3
+ /** Identity at runtime; exists so an instrument's state and config types are
4
+ * inferred from the spec rather than having to be written out. */
3
5
  export function defineInstrument<TS, TC>(spec: Instrument<TS, TC>): Instrument<TS, TC> {
4
6
  return spec;
5
7
  }
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { ConfigField } from '../controls/types';
3
3
 
4
+ /** What an instrument's `render` is handed: its state and config, the setters
5
+ * for both, the workspace it is mounted in, and a way to emit named events. */
4
6
  export interface RenderContext<TS = unknown, TC = unknown> {
5
7
  state: TS;
6
8
  config: TC;
@@ -14,20 +16,28 @@ export interface RenderContext<TS = unknown, TC = unknown> {
14
16
  emit: (event: string) => void;
15
17
  }
16
18
 
19
+ /** One 2D canvas layer of an instrument, drawn in declaration order. */
17
20
  export interface CanvasLayer<TS = unknown, TC = unknown> {
18
21
  id: string;
19
22
  draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
20
23
  }
21
24
 
25
+ /** Declares that an instrument draws to a canvas: its layers, and where the
26
+ * view starts. */
22
27
  export interface CanvasCapability<TS = unknown, TC = unknown> {
23
28
  layers: CanvasLayer<TS, TC>[];
24
29
  initialView?: { zoom: number; pan: { x: number; y: number } };
25
30
  }
26
31
 
32
+ /** Declares which of an instrument's layers the workspace should offer
33
+ * show/hide controls for. */
27
34
  export interface LayerCapability {
28
35
  ids: string[];
29
36
  }
30
37
 
38
+ /** Declares that an instrument accepts items dragged from a palette: what the
39
+ * palette offers, what a drop does to the state, and — optionally — live
40
+ * feedback during the drag and the ability to drag existing items back out. */
31
41
  export interface DragDropCapability<TS = unknown, TC = unknown> {
32
42
  palette: PaletteItem[] | ((state: TS, config: TC) => PaletteItem[]);
33
43
  onDrop: (worldPos: Point, item: PaletteItem, state: TS, config: TC) => TS;
@@ -35,20 +45,39 @@ export interface DragDropCapability<TS = unknown, TC = unknown> {
35
45
  pickUp?: (hit: HitResult, state: TS, config: TC) => { item: PaletteItem; state: TS } | null;
36
46
  }
37
47
 
48
+ /** Declares that an instrument's state is undoable: which emitted events
49
+ * snapshot it, and how many snapshots to keep. */
38
50
  export interface UndoCapability {
39
51
  snapshotOn?: string[];
40
52
  maxDepth?: number;
41
53
  }
42
54
 
55
+ /** The name of an event an instrument emits through `RenderContext.emit`. */
43
56
  export type SystemEvent = string;
44
57
 
58
+ /** A point in world coordinates. */
45
59
  export type Point = { x: number; y: number };
60
+ /** What a hit-test found, and where. */
46
61
  export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
62
+ /** A workspace's camera. */
47
63
  export type ViewTransform = { zoom: number; pan: Point };
64
+ /** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
48
65
  export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
66
+ /** One draggable entry in an instrument's palette. */
49
67
  export type PaletteItem = { id: string; label: string; data?: unknown };
68
+ /** Whether a drop would be accepted at the current position, and why not if
69
+ * it would not. */
50
70
  export type DragFeedback = { ok: boolean; reason?: string };
51
71
 
72
+ /**
73
+ * An instrument: one self-contained interactive experiment a lab can host.
74
+ *
75
+ * It owns two pieces of data — `config`, the settings the control panel edits,
76
+ * and `state`, what the experiment is currently doing — and renders from both.
77
+ * The optional capability fields declare what else it wants from the runtime:
78
+ * a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
79
+ * is what makes the workspace provide the corresponding chrome.
80
+ */
52
81
  export interface Instrument<TS = unknown, TC = unknown> {
53
82
  name: string;
54
83
  defaultConfig: () => TC;
@@ -1,5 +1,7 @@
1
1
  import type { ConfigField, ConfigFieldType } from '../controls/types';
2
2
 
3
+ /** Whether a config schema is usable, and every problem found rather than
4
+ * just the first. */
3
5
  export interface ValidationResult {
4
6
  valid: boolean;
5
7
  errors: string[];
@@ -7,6 +9,9 @@ export interface ValidationResult {
7
9
 
8
10
  const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
9
11
 
12
+ /** Check a config schema for the mistakes that would otherwise surface as a
13
+ * silently broken control: empty or duplicate keys, unknown field types, and
14
+ * fields whose own constraints do not hold. */
10
15
  export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
11
16
  const errors: string[] = [];
12
17
  const seenKeys = new Set<string>();
package/src/lab/Lab.tsx CHANGED
@@ -12,13 +12,15 @@ import {
12
12
  addWorkspace as addWorkspaceOp,
13
13
  cloneWorkspace as cloneWorkspaceOp,
14
14
  closeWorkspace as closeWorkspaceOp,
15
+ reorderWorkspaces as reorderWorkspacesOp,
15
16
  resetWorkspace as resetWorkspaceOp,
16
17
  } from '../workspace/workspaceOps';
17
18
  import { LabContext, type LabContextValue } from './LabContext';
18
19
  import { LabShell } from './LabShell';
19
20
  import { useResolvedMode } from './useSystemMode';
20
- import { WorkspaceGrid } from './WorkspaceGrid';
21
+ import { WorkspaceGrid, type WorkspaceLayout } from './WorkspaceGrid';
21
22
 
23
+ /** Props for `<Lab>`. */
22
24
  export interface LabProps {
23
25
  instruments: Instrument[];
24
26
  defaultInstrument: string;
@@ -76,6 +78,8 @@ function buildNebula(colors: readonly string[]): string {
76
78
  return blobs.join(', ');
77
79
  }
78
80
 
81
+ /** The lab runtime: creates the store, provides it, and renders one workspace
82
+ * per record in a grid. Each workspace runs one of `instruments`. */
79
83
  export function Lab({
80
84
  instruments,
81
85
  defaultInstrument,
@@ -105,6 +109,7 @@ export function Lab({
105
109
  const workspaces = useStore(store, (s) => s.workspaces);
106
110
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
107
111
  const modeValue = useStore(store, (s) => s.mode);
112
+ const layout = useStore(store, (s) => s.layout);
108
113
  const resolvedMode = useResolvedMode(modeValue);
109
114
 
110
115
  useEffect(() => {
@@ -137,6 +142,9 @@ export function Lab({
137
142
  const next = closeWorkspaceOp(store.getState().workspaces, id);
138
143
  replaceWorkspaces(next);
139
144
  },
145
+ reorderWorkspaces: (ids) => {
146
+ replaceWorkspaces(reorderWorkspacesOp(store.getState().workspaces, ids));
147
+ },
140
148
  resetWorkspace: (id) => {
141
149
  const next = resetWorkspaceOp(store.getState().workspaces, id, instruments);
142
150
  const record = next.find((w) => w.id === id);
@@ -184,7 +192,14 @@ export function Lab({
184
192
  style={backdropStyle}
185
193
  >
186
194
  <LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
187
- <WorkspaceGrid>
195
+ <WorkspaceGrid
196
+ ids={workspaces.map((w) => w.id)}
197
+ resizable
198
+ reorderable
199
+ onReorder={(ids) => contextValue.reorderWorkspaces(ids)}
200
+ layout={layout as WorkspaceLayout}
201
+ onLayoutChange={(next) => store.getState().setLayout(next)}
202
+ >
188
203
  {workspaces.map((w) => (
189
204
  <Workspace key={w.id} id={w.id} />
190
205
  ))}
@@ -2,6 +2,9 @@ import { createContext, useContext } from 'react';
2
2
  import type { Instrument } from '../instrument/types';
3
3
  import type { LabMode, SavedSnapshot, WorkspaceRecord } from '../state/types';
4
4
 
5
+ /** Lab-wide state and commands: the available instruments, the open
6
+ * workspaces and the operations over them, saved snapshots, and the color
7
+ * mode. */
5
8
  export interface LabContextValue {
6
9
  instruments: Instrument[];
7
10
  workspaces: WorkspaceRecord[];
@@ -9,6 +12,7 @@ export interface LabContextValue {
9
12
  cloneWorkspace: (id: string) => void;
10
13
  closeWorkspace: (id: string) => void;
11
14
  resetWorkspace: (id: string) => void;
15
+ reorderWorkspaces: (ids: readonly string[]) => void;
12
16
  savedSnapshots: SavedSnapshot[];
13
17
  saveSnapshot: (workspaceId: string, name?: string) => void;
14
18
  loadSnapshot: (workspaceId: string, snapshotId: string) => void;
@@ -17,8 +21,10 @@ export interface LabContextValue {
17
21
  setMode: (m: LabMode) => void;
18
22
  }
19
23
 
24
+ /** Context carrying the surrounding lab. Prefer `useLabContext`. */
20
25
  export const LabContext = createContext<LabContextValue | null>(null);
21
26
 
27
+ /** The surrounding lab. Throws outside a `<Lab>`. */
22
28
  export function useLabContext(): LabContextValue {
23
29
  const ctx = useContext(LabContext);
24
30
  if (ctx === null) {
@@ -4,6 +4,7 @@ import type { LabMode } from '../state/types';
4
4
  import { interstellarTheme } from '../theme/interstellar';
5
5
  import { useResolvedMode } from './useSystemMode';
6
6
 
7
+ /** Props for `<LabShell>`. */
7
8
  export interface LabShellProps {
8
9
  title: string;
9
10
  children: ReactNode;
@@ -15,6 +16,9 @@ export interface LabShellProps {
15
16
  mode?: LabMode;
16
17
  }
17
18
 
19
+ /** Page frame for a lab: a titled header, a body, and an optional footer,
20
+ * themed for the resolved color mode. Presentational only — use `<Lab>` when
21
+ * the workspace runtime is wanted too. */
18
22
  export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
19
23
  const resolved = useResolvedMode(mode);
20
24
  const outer = useThemeOptional();
@@ -1,8 +1,40 @@
1
+ // Tiles are positioned by windease at strategy-computed rects; `.windease-zone`
2
+ // (from windease/styles.css) supplies the containing block and the 100% box.
3
+ // This class exists for consumers to target.
1
4
  .lk-workspace-grid {
2
- display: grid;
3
- grid-template-columns: repeat(var(--lk-grid-cols, 1), 1fr);
4
- grid-template-rows: repeat(var(--lk-grid-rows, 1), 1fr);
5
- gap: var(--wzl-space-md);
6
- width: 100%;
5
+ min-height: 0;
6
+ }
7
+
8
+ // Only rendered when `reorderable` — a tile is full of controls, so the drag
9
+ // target has to be its own strip rather than the whole pane.
10
+ .lk-workspace-tile {
11
+ display: flex;
12
+ flex-direction: column;
7
13
  height: 100%;
14
+
15
+ &__grip {
16
+ flex: 0 0 auto;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ height: 14px;
21
+ cursor: grab;
22
+ border-radius: var(--wzl-radius-sm) var(--wzl-radius-sm) 0 0;
23
+ background: var(--wzl-surface-sunken);
24
+
25
+ &:active { cursor: grabbing; }
26
+ }
27
+
28
+ &__grip-dots {
29
+ width: 28px;
30
+ height: 3px;
31
+ border-radius: 999px;
32
+ background: var(--wzl-fg-muted);
33
+ opacity: 0.5;
34
+ }
35
+
36
+ &__body {
37
+ flex: 1;
38
+ min-height: 0;
39
+ }
8
40
  }
@@ -59,3 +59,19 @@ export const SevenTiles: Story = {
59
59
  </div>
60
60
  ),
61
61
  };
62
+
63
+ /** Draggable seams. Drag a tile edge, or Tab to a seam and press an arrow —
64
+ * a grid moves extents a whole cell at a time. */
65
+ export const Resizable: Story = {
66
+ render: () => (
67
+ <div style={{ height: 500 }}>
68
+ <WorkspaceGrid ids={['a', 'b', 'c', 'd', 'e']} resizable>
69
+ <Tile>1</Tile>
70
+ <Tile>2</Tile>
71
+ <Tile>3</Tile>
72
+ <Tile>4</Tile>
73
+ <Tile>5</Tile>
74
+ </WorkspaceGrid>
75
+ </div>
76
+ ),
77
+ };
@@ -1,11 +1,13 @@
1
1
  import { render, screen } from '@testing-library/react';
2
- import { describe, expect, test } from 'vitest';
2
+ import { describe, expect, test, vi } from 'vitest';
3
3
  import { WorkspaceGrid } from './WorkspaceGrid';
4
4
 
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
5
7
  describe('WorkspaceGrid', () => {
6
8
  test('renders all children', () => {
7
9
  render(
8
- <WorkspaceGrid>
10
+ <WorkspaceGrid viewport={VIEWPORT}>
9
11
  <div>one</div>
10
12
  <div>two</div>
11
13
  <div>three</div>
@@ -16,25 +18,97 @@ describe('WorkspaceGrid', () => {
16
18
  expect(screen.getByText('three')).toBeInTheDocument();
17
19
  });
18
20
 
19
- test('sets CSS custom properties for grid dimensions based on child count', () => {
20
- const { container } = render(
21
- <WorkspaceGrid>
21
+ test('keeps a child with the tile its id names when an earlier one closes', () => {
22
+ const ids = ['a', 'b', 'c'];
23
+ const { rerender, container } = render(
24
+ <WorkspaceGrid ids={ids} viewport={VIEWPORT}>
22
25
  <div>a</div>
23
26
  <div>b</div>
24
27
  <div>c</div>
25
28
  </WorkspaceGrid>,
26
29
  );
27
- const grid = container.firstChild as HTMLElement;
28
- expect(grid.style.getPropertyValue('--lk-grid-cols')).toBe('2');
29
- expect(grid.style.getPropertyValue('--lk-grid-rows')).toBe('2');
30
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
31
+
32
+ rerender(
33
+ <WorkspaceGrid ids={['b', 'c']} viewport={VIEWPORT}>
34
+ <div>b</div>
35
+ <div>c</div>
36
+ </WorkspaceGrid>,
37
+ );
38
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
39
+ expect(container.querySelector('[data-node="a"]')).toBeNull();
30
40
  });
31
41
 
32
42
  test('uses lk-workspace-grid class', () => {
33
43
  const { container } = render(
34
- <WorkspaceGrid>
44
+ <WorkspaceGrid viewport={VIEWPORT}>
35
45
  <div />
36
46
  </WorkspaceGrid>,
37
47
  );
38
- expect((container.firstChild as HTMLElement).className).toBe('lk-workspace-grid');
48
+ expect((container.firstChild as HTMLElement).className).toContain('lk-workspace-grid');
49
+ });
50
+
51
+ test('renders resize affordances only when resizable', () => {
52
+ const { container, rerender } = render(
53
+ <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
54
+ <div>a</div>
55
+ <div>b</div>
56
+ </WorkspaceGrid>,
57
+ );
58
+ expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
59
+
60
+ rerender(
61
+ <WorkspaceGrid ids={['a', 'b']} resizable viewport={VIEWPORT}>
62
+ <div>a</div>
63
+ <div>b</div>
64
+ </WorkspaceGrid>,
65
+ );
66
+ expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
67
+ });
68
+
69
+ test('applies a saved extent to a tile as it registers', () => {
70
+ const { container } = render(
71
+ <WorkspaceGrid
72
+ ids={['a', 'b']}
73
+ resizable
74
+ viewport={VIEWPORT}
75
+ layout={{ a: { span: { cols: 2 } } }}
76
+ >
77
+ <div>a</div>
78
+ <div>b</div>
79
+ </WorkspaceGrid>,
80
+ );
81
+ const a = container.querySelector('[data-node="a"]') as HTMLElement;
82
+ const b = container.querySelector('[data-node="b"]') as HTMLElement;
83
+ // `a` holds two columns, so it is wider than the single-column `b`.
84
+ expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
85
+ });
86
+
87
+ test('reports the order a drop would produce instead of applying it', () => {
88
+ const onReorder = vi.fn();
89
+ const { container } = render(
90
+ <WorkspaceGrid ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
91
+ <div>a</div>
92
+ <div>b</div>
93
+ </WorkspaceGrid>,
94
+ );
95
+ // The grid is controlled: it renders a handle per tile and commits nothing
96
+ // itself. Order still comes from `ids`.
97
+ expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(2);
98
+ expect(onReorder).not.toHaveBeenCalled();
99
+ const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
100
+ el.getAttribute('data-node'),
101
+ );
102
+ expect(nodes).toEqual(['a', 'b']);
103
+ });
104
+
105
+ test('renders no drag handles unless reorderable', () => {
106
+ const { container } = render(
107
+ <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
108
+ <div>a</div>
109
+ <div>b</div>
110
+ </WorkspaceGrid>,
111
+ );
112
+ expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(0);
39
113
  });
40
114
  });