@weasel-js/labkit 1.0.3 → 1.1.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 (94) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  4. package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
  5. package/dist/canvas/index.d.ts +1 -1
  6. package/dist/chunk-3TYUJR7Z.js +488 -0
  7. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  8. package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
  9. package/dist/chunk-C6GJKPUI.js.map +1 -0
  10. package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
  11. package/dist/chunk-DJLDIRFN.js.map +1 -0
  12. package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
  13. package/dist/chunk-NRKWVTVT.js.map +1 -0
  14. package/dist/dragdrop/index.d.ts +1 -1
  15. package/dist/index.d.ts +197 -148
  16. package/dist/index.js +275 -161
  17. package/dist/index.js.map +1 -1
  18. package/dist/layers/index.d.ts +2 -2
  19. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  20. package/dist/passthrough/weasel-canvas.js +1 -1
  21. package/dist/passthrough/weasel-ui.d.ts +10 -3
  22. package/dist/passthrough/weasel-ui.js +2 -2
  23. package/dist/state/index.d.ts +14 -14
  24. package/dist/state/index.js +5 -5
  25. package/dist/state/index.js.map +1 -1
  26. package/dist/styles.css +179 -14
  27. package/dist/ui/layers/index.js +3 -3
  28. package/dist/undo/index.d.ts +1 -1
  29. package/package.json +2 -1
  30. package/src/canvas/AGENTS.md +6 -2
  31. package/src/dragdrop/dragDrop.test.tsx +4 -7
  32. package/src/index.test.ts +52 -0
  33. package/src/index.ts +11 -7
  34. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  35. package/src/instrument/capabilityDetector.ts +1 -1
  36. package/src/instrument/types.ts +18 -6
  37. package/src/lab/Lab.less +16 -0
  38. package/src/lab/Lab.stories.tsx +5 -5
  39. package/src/lab/Lab.test.tsx +25 -25
  40. package/src/lab/Lab.tsx +56 -44
  41. package/src/lab/LabContext.ts +12 -11
  42. package/src/lab/LabShell.tsx +1 -1
  43. package/src/lab/Workspace.less +40 -0
  44. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
  45. package/src/lab/Workspace.test.tsx +114 -0
  46. package/src/lab/Workspace.tsx +196 -0
  47. package/src/lab/index.ts +2 -4
  48. package/src/layers/AGENTS.md +3 -3
  49. package/src/primitives/Toolbar.stories.tsx +2 -2
  50. package/src/state/SingletonExperiment.test.tsx +8 -8
  51. package/src/state/SingletonExperiment.tsx +9 -9
  52. package/src/state/context.tsx +12 -12
  53. package/src/state/document.test.ts +369 -0
  54. package/src/state/document.ts +194 -0
  55. package/src/state/helpers.test.ts +74 -46
  56. package/src/state/helpers.ts +23 -31
  57. package/src/state/index.ts +16 -8
  58. package/src/state/store.test.ts +440 -40
  59. package/src/state/store.ts +166 -90
  60. package/src/state/types.ts +30 -10
  61. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  62. package/src/state/useTrialState.ts +29 -0
  63. package/src/styles.less +2 -2
  64. package/src/test-setup.ts +19 -0
  65. package/src/theme/base.less +1 -1
  66. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  67. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  68. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  69. package/src/trial/Trial.canvas.test.tsx +80 -0
  70. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  71. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
  72. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
  73. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  74. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  75. package/src/{workspace → trial}/index.ts +13 -12
  76. package/src/{workspace → trial}/slotTypes.ts +16 -16
  77. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
  78. package/src/trial/trialOps.ts +99 -0
  79. package/src/ui/format.test.ts +33 -0
  80. package/src/ui/format.ts +11 -0
  81. package/src/ui/properties/CurveField.tsx +6 -6
  82. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  83. package/dist/chunk-3WPOGKUP.js.map +0 -1
  84. package/dist/chunk-73PXCRCR.js.map +0 -1
  85. package/dist/chunk-N5KTQKQA.js.map +0 -1
  86. package/dist/chunk-T7OKNJTY.js +0 -351
  87. package/dist/chunk-T7OKNJTY.js.map +0 -1
  88. package/src/lab/WorkspaceGrid.less +0 -8
  89. package/src/lab/WorkspaceGrid.test.tsx +0 -40
  90. package/src/lab/WorkspaceGrid.tsx +0 -22
  91. package/src/lab/gridDims.test.ts +0 -35
  92. package/src/lab/gridDims.ts +0 -13
  93. package/src/state/useExperimentState.ts +0 -31
  94. package/src/workspace/workspaceOps.ts +0 -86
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  React widgets for building self-contained interactive **lab** pages — pages with sliders, controls, and canvas-based experimentation.
4
4
 
5
- This is the v0.x of the library. The Lab/Workspace/Instrument runtime arrives in later plans; v0.0.1 ships presentational primitives.
5
+ This is the v0.x of the library. The Lab/Trial/Instrument runtime arrives in later plans; v0.0.1 ships presentational primitives.
6
6
 
7
7
  ## Installation
8
8
 
@@ -28,16 +28,16 @@ Then point your app at the local clone:
28
28
  ## Usage
29
29
 
30
30
  ```tsx
31
- import { LabShell, Toolbar, WorkspaceGrid, FpsMeter } from '@weasel-js/labkit';
31
+ import { LabShell, Toolbar, Workspace, FpsMeter } from '@weasel-js/labkit';
32
32
  import '@weasel-js/labkit/styles.css';
33
33
 
34
34
  function MyLab() {
35
35
  return (
36
36
  <LabShell title="My Lab" header={<button>+ Add</button>}>
37
- <WorkspaceGrid>
38
- <div>Workspace 1</div>
39
- <div>Workspace 2</div>
40
- </WorkspaceGrid>
37
+ <Workspace>
38
+ <div>Trial 1</div>
39
+ <div>Trial 2</div>
40
+ </Workspace>
41
41
  </LabShell>
42
42
  );
43
43
  }
@@ -185,7 +185,9 @@ interface History {
185
185
  * 2026-05-24-modality-design.md for the full lifecycle. */
186
186
  beginJournal(opts: BeginJournalOptions): Journal;
187
187
  /** Re-activate a suspended journal. Throws if the journal was committed or
188
- * cancelled (those are terminal). Staleness checking is the caller's
188
+ * cancelled (those are terminal), or if a different journal is currently
189
+ * active — at most one journal writes to the adapter at a time, on resume
190
+ * as well as on open. Staleness checking is the caller's
189
191
  * responsibility — consult `journal.forkedAtEntryId` against
190
192
  * `currentEntryId()` and your own op-semantic rules to decide whether
191
193
  * to resume or discard before calling this. */
@@ -2,20 +2,24 @@ import { ReactNode } from 'react';
2
2
  import { C as ConfigField } from './types-x92Kfeme.js';
3
3
 
4
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. */
5
+ * for both, the trial it is mounted in, and a way to emit named events. */
6
6
  interface RenderContext<TS = unknown, TC = unknown> {
7
7
  state: TS;
8
8
  config: TC;
9
9
  setState: (next: TS | ((prev: TS) => TS)) => void;
10
10
  setConfig: (key: keyof TC, value: unknown) => void;
11
- workspace: {
11
+ trial: {
12
12
  id: string;
13
13
  zoom: number;
14
14
  setZoom: (z: number) => void;
15
15
  };
16
16
  emit: (event: string) => void;
17
17
  }
18
- /** One 2D canvas layer of an instrument, drawn in declaration order. */
18
+ /** One 2D canvas layer of an instrument, drawn in declaration order.
19
+ *
20
+ * `draw` is called with the camera already applied, so it works in world
21
+ * coordinates. `zoom` is passed for the things that must not scale with it —
22
+ * set `ctx.lineWidth = 1 / zoom` to keep a hairline hairline. */
19
23
  interface CanvasLayer<TS = unknown, TC = unknown> {
20
24
  id: string;
21
25
  draw: (ctx: CanvasRenderingContext2D, args: {
@@ -36,7 +40,7 @@ interface CanvasCapability<TS = unknown, TC = unknown> {
36
40
  };
37
41
  };
38
42
  }
39
- /** Declares which of an instrument's layers the workspace should offer
43
+ /** Declares which of an instrument's layers the trial should offer
40
44
  * show/hide controls for. */
41
45
  interface LayerCapability {
42
46
  ids: string[];
@@ -72,7 +76,7 @@ type HitResult = {
72
76
  layerId?: string;
73
77
  pointId?: string;
74
78
  };
75
- /** A workspace's camera. */
79
+ /** A trial's camera. */
76
80
  type ViewTransform = {
77
81
  zoom: number;
78
82
  pan: Point;
@@ -102,13 +106,15 @@ type DragFeedback = {
102
106
  * and `state`, what the experiment is currently doing — and renders from both.
103
107
  * The optional capability fields declare what else it wants from the runtime:
104
108
  * a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
105
- * is what makes the workspace provide the corresponding chrome.
109
+ * is what makes the trial provide the corresponding chrome.
106
110
  */
107
111
  interface Instrument<TS = unknown, TC = unknown> {
108
112
  name: string;
109
113
  defaultConfig: () => TC;
110
114
  initialState: (config: TC) => TS;
111
115
  configSchema?: () => ConfigField[];
116
+ /** The instrument's DOM. With `canvas`, this renders as an overlay above the
117
+ * layers rather than instead of them; return `null` for canvas only. */
112
118
  render: (ctx: RenderContext<TS, TC>) => ReactNode;
113
119
  onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
114
120
  serialize?: (state: TS) => unknown;
@@ -118,5 +124,9 @@ interface Instrument<TS = unknown, TC = unknown> {
118
124
  dragDrop?: DragDropCapability<TS, TC>;
119
125
  undo?: UndoCapability;
120
126
  }
127
+ /** Instruments as a lab receives them. `any` rather than `unknown` because
128
+ * parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
129
+ * an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
130
+ type InstrumentList = readonly Instrument<any, any>[];
121
131
 
122
- export type { CanvasCapability as C, DragDropCapability as D, HitResult as H, Instrument as I, LayerCapability as L, PaletteItem as P, RenderContext as R, SystemEvent as S, UndoCapability as U, ViewTransform as V, CanvasLayer as a, DragFeedback as b, LayerDescriptor as c, Point as d };
132
+ export type { CanvasCapability as C, DragDropCapability as D, HitResult as H, Instrument as I, LayerCapability as L, PaletteItem as P, RenderContext as R, SystemEvent as S, UndoCapability as U, ViewTransform as V, InstrumentList as a, CanvasLayer as b, DragFeedback as c, LayerDescriptor as d, Point as e };
@@ -2,15 +2,15 @@ import * as react from 'react';
2
2
  import { ReactNode, ReactElement } from 'react';
3
3
  import { StoreApi } from 'zustand/vanilla';
4
4
 
5
- /** A workspace's undo history, as snapshots of its state either side of the
5
+ /** A trial's undo history, as snapshots of its state either side of the
6
6
  * present. */
7
7
  interface UndoStack {
8
8
  past: unknown[];
9
9
  future: unknown[];
10
10
  }
11
- /** One workspace as the store holds it: which instrument it runs, that
11
+ /** One trial as the store holds it: which instrument it runs, that
12
12
  * instrument's config and state, the camera, and the undo history. */
13
- interface WorkspaceRecord<TS = unknown, TC = unknown> {
13
+ interface TrialRecord<TS = unknown, TC = unknown> {
14
14
  id: string;
15
15
  instrumentName: string;
16
16
  config: TC;
@@ -24,12 +24,12 @@ interface WorkspaceRecord<TS = unknown, TC = unknown> {
24
24
  };
25
25
  undoStack: UndoStack;
26
26
  }
27
- /** A named, saved copy of a workspace's config and state, restorable into any
28
- * workspace running the same instrument. */
27
+ /** A named, saved copy of a trial's config and state, restorable into any
28
+ * trial running the same instrument. */
29
29
  interface SavedSnapshot {
30
30
  id: string;
31
31
  name: string;
32
- workspaceId: string;
32
+ trialId: string;
33
33
  instrumentName: string;
34
34
  config: unknown;
35
35
  state: unknown;
@@ -37,12 +37,15 @@ interface SavedSnapshot {
37
37
  }
38
38
  /** `auto` follows the OS; the other two are an explicit choice. */
39
39
  type LabMode = 'auto' | 'light' | 'dark';
40
- /** Everything a lab persists: its workspaces, its saved snapshots, and the
40
+ /** Everything a lab persists: its trials, its saved snapshots, and the
41
41
  * chosen color mode. */
42
42
  interface LabStoreState {
43
- workspaces: WorkspaceRecord[];
43
+ trials: TrialRecord[];
44
44
  savedSnapshots: SavedSnapshot[];
45
45
  mode: LabMode;
46
+ /** Per-trial tile extents, keyed by trial id. Opaque here — the
47
+ * shape belongs to whatever lays the trials out. */
48
+ layout: Record<string, unknown>;
46
49
  }
47
50
  /** Where a lab persists itself. Implementations are keyed string storage and
48
51
  * nothing more, so the same store works against localStorage, the URL hash,
@@ -52,9 +55,9 @@ interface StorageAdapter {
52
55
  write(key: string, value: string): void;
53
56
  delete?(key: string): void;
54
57
  }
55
- /** What `useExperimentState` hands an instrument: its state and config, with
58
+ /** What `useTrialState` hands an instrument: its state and config, with
56
59
  * a setter for each. */
57
- interface ExperimentStateHandle<TS, TC> {
60
+ interface TrialStateHandle<TS, TC> {
58
61
  state: TS;
59
62
  setState: (next: TS | ((prev: TS) => TS)) => void;
60
63
  config: TC;
@@ -73,6 +76,20 @@ type InstrumentSerializers = Record<string, {
73
76
  serialize?: (state: unknown) => unknown;
74
77
  deserialize?: (data: unknown) => unknown;
75
78
  } | undefined>;
79
+ /** A trial as it is persisted: everything but the undo history, which is
80
+ * session-only. */
81
+ type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
82
+ /** Everything a lab persists, under one key, at a known version. */
83
+ interface LabDocument {
84
+ version: number;
85
+ trials: SerializedTrial[];
86
+ saves: SavedSnapshot[];
87
+ layout: Record<string, unknown>;
88
+ mode: LabMode;
89
+ }
90
+ /** Migrates a document one version forward. Index `i` in the chain takes a
91
+ * version-`i` document to version `i + 1`. */
92
+ type Migration = (doc: Record<string, unknown>) => Record<string, unknown>;
76
93
 
77
94
  /** Persist to `localStorage` — state survives a reload and a new tab. Falls
78
95
  * back to no-ops when storage is unavailable. */
@@ -88,21 +105,22 @@ declare function createMemoryAdapter(): StorageAdapter;
88
105
  /** Persists nothing and reads back nothing. */
89
106
  declare const noneAdapter: StorageAdapter;
90
107
 
91
- /** Every mutation a lab store supports: managing workspaces, saving and
108
+ /** Every mutation a lab store supports: managing trials, saving and
92
109
  * restoring snapshots, and setting the color mode. */
93
110
  interface LabStoreActions {
94
- addWorkspace: (record: Omit<WorkspaceRecord, 'undoStack'>) => void;
95
- removeWorkspace: (id: string) => void;
96
- updateWorkspaceState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
97
- updateWorkspaceConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
98
- updateWorkspaceView: (id: string, view: WorkspaceRecord['view']) => void;
99
- updateWorkspaceUndoStack: (id: string, next: WorkspaceRecord['undoStack'] | ((prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack'])) => void;
100
- setWorkspaceInstrument: (id: string, instrumentName: string) => void;
101
- saveSnapshot: (workspaceId: string, name: string) => void;
102
- loadSnapshot: (snapshotId: string, workspaceId: string) => void;
111
+ addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
112
+ removeTrial: (id: string) => void;
113
+ updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
114
+ updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
115
+ updateTrialView: (id: string, view: TrialRecord['view']) => void;
116
+ updateTrialUndoStack: (id: string, next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])) => void;
117
+ setTrialInstrument: (id: string, instrumentName: string) => void;
118
+ saveSnapshot: (trialId: string, name: string) => void;
119
+ loadSnapshot: (snapshotId: string, trialId: string) => void;
103
120
  deleteSnapshot: (snapshotId: string) => void;
104
- listSnapshots: (workspaceId?: string) => SavedSnapshot[];
121
+ listSnapshots: (trialId?: string) => SavedSnapshot[];
105
122
  setMode: (mode: LabMode) => void;
123
+ setLayout: (layout: Record<string, unknown>) => void;
106
124
  }
107
125
  /** A lab's store: its state and actions, plus the hook instruments use to
108
126
  * register how their state is serialized. */
@@ -126,22 +144,32 @@ declare function LabStoreProvider({ store, children, }: {
126
144
  }): ReactElement;
127
145
  /** Subscribe to the whole lab store. Throws outside a `<LabStoreProvider>`. */
128
146
  declare function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>;
129
- /** Context carrying which workspace the subtree belongs to. */
130
- declare const WorkspaceIdContext: react.Context<string | null>;
131
- /** Names the workspace its subtree belongs to, so an instrument's hooks can
147
+ /** Context carrying which trial the subtree belongs to. */
148
+ declare const TrialIdContext: react.Context<string | null>;
149
+ /** Names the trial its subtree belongs to, so an instrument's hooks can
132
150
  * find their own record in the store without being passed an id. */
133
- declare function WorkspaceIdProvider({ workspaceId, children, }: {
134
- workspaceId: string;
151
+ declare function TrialIdProvider({ trialId, children, }: {
152
+ trialId: string;
135
153
  children: ReactNode;
136
154
  }): ReactElement;
137
- /** The id of the workspace this component is inside. Throws outside a
138
- * `<WorkspaceIdProvider>`. */
139
- declare function useWorkspaceId(): string;
155
+ /** The id of the trial this component is inside. Throws outside a
156
+ * `<TrialIdProvider>`. */
157
+ declare function useTrialId(): string;
158
+
159
+ /** Bumped whenever the persisted shape changes; every bump needs a migration. */
160
+ declare const CURRENT_DOCUMENT_VERSION = 2;
161
+ /** The one key a lab persists under. The `:doc` suffix keeps it out of the
162
+ * legacy bucket namespace, where a lab named `a:saves` would otherwise write
163
+ * its document over lab `a`'s saves bucket. */
164
+ declare function labDocumentKey(storageKey: string): string;
165
+ /** Where a document that failed to migrate is set aside, so a bad migration
166
+ * loses state loudly rather than silently. */
167
+ declare function quarantineKey(storageKey: string): string;
140
168
 
141
169
  /** An instrument's own state and config, plus setters. Reads the surrounding
142
- * workspace id, so an instrument never has to know which workspace it is
143
- * running in. Throws outside a lab store and workspace. */
144
- declare function useExperimentState<TS = unknown, TC = unknown>(): ExperimentStateHandle<TS, TC>;
170
+ * trial id, so an instrument never has to know which trial it is
171
+ * running in. Throws outside a lab store and trial. */
172
+ declare function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS, TC>;
145
173
 
146
- export { LabStoreContext as b, LabStoreProvider as c, WorkspaceIdContext as f, WorkspaceIdProvider as g, createLabStore as h, createMemoryAdapter as i, useExperimentState as j, useLabStore as k, localStorageAdapter as l, useWorkspaceId as m, noneAdapter as n, sessionStorageAdapter as s, urlHashAdapter as u };
147
- export type { CreateLabStoreOptions as C, ExperimentStateHandle as E, InstrumentSerializers as I, LabStore as L, StorageAdapter as S, UndoStack as U, WorkspaceRecord as W, LabStoreActions as a, LabStoreState as d, SavedSnapshot as e, LabMode as o };
174
+ export { CURRENT_DOCUMENT_VERSION as C, LabStoreContext as e, LabStoreProvider as f, TrialIdContext as i, TrialIdProvider as j, createLabStore as l, createMemoryAdapter as m, labDocumentKey as n, localStorageAdapter as o, noneAdapter as p, quarantineKey as q, useLabStore as r, sessionStorageAdapter as s, useTrialId as t, urlHashAdapter as u, useTrialState as v };
175
+ export type { InstrumentSerializers as I, LabDocument as L, Migration as M, SerializedTrial as S, TrialRecord as T, UndoStack as U, StorageAdapter as a, CreateLabStoreOptions as b, LabStore as c, LabStoreActions as d, LabStoreState as g, SavedSnapshot as h, TrialStateHandle as k, LabMode as w };
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
3
  import { ReactNode } from 'react';
4
- import { V as ViewTransform, d as Point } from '../_dts/index-CFlDPeZh.js';
4
+ import { V as ViewTransform, e as Point } from '../_dts/index-iAP6XbH3.js';
5
5
  import '../_dts/types-x92Kfeme.js';
6
6
 
7
7
  /** One layer of a canvas stack: its id, whether it is currently shown, and how