@weasel-js/labkit 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +177 -53
  51. package/dist/index.js +1333 -1683
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +26 -202
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +153 -40
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,6 +1,6 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
2
3
  import { useState } from 'react';
3
- import { expect, userEvent, waitFor, within } from 'storybook/test';
4
4
  import { createIndexedDbAdapter } from './adapters';
5
5
  import { Persistence } from './Persistence';
6
6
  import { usePersistedState } from './usePersistedState';
@@ -64,11 +64,11 @@ export const SurvivesARemountInIndexedDb: StoryObj<typeof Remountable> = {
64
64
  args: { database: `labkit-story-${Date.now()}` },
65
65
  play: async ({ canvasElement }) => {
66
66
  const canvas = within(canvasElement);
67
- await waitFor(() => expect(canvas.getByLabelText('tab')).toHaveTextContent('shape'));
67
+ await waitFor(() => expect(canvas.getByLabelText('tab').textContent).toBe('shape'));
68
68
  await userEvent.click(canvas.getByText('choose color'));
69
69
  await userEvent.click(canvas.getByText('remount'));
70
70
  await waitFor(() => expect(canvas.queryByLabelText('tab')).toBeNull());
71
- await waitFor(() => expect(canvas.getByLabelText('tab')).toHaveTextContent('color'), {
71
+ await waitFor(() => expect(canvas.getByLabelText('tab').textContent).toBe('color'), {
72
72
  timeout: 3000,
73
73
  });
74
74
  },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { auto } from '../config/auto';
2
3
  import type { Instrument } from '../instrument/types';
3
4
  import { createLabStore } from './store';
4
5
  import type { CreateLabStoreOptions } from './types';
@@ -163,6 +164,70 @@ describe('save/load/delete snapshots', () => {
163
164
  expect((s.getState().trials[0]?.state as { n: number }).n).toBe(5);
164
165
  });
165
166
 
167
+ it('loadSnapshot restores the auto paths the trial had when saved', () => {
168
+ const s = makeStore();
169
+ s.getState().addTrial({
170
+ id: 'w1',
171
+ instrumentName: 'T',
172
+ config: { x: 1, y: 2 },
173
+ state: {},
174
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
175
+ auto: ['x'],
176
+ });
177
+ s.getState().saveSnapshot('w1', 'snap1');
178
+ expect(s.getState().savedSnapshots[0]?.auto).toEqual(['x']);
179
+ const snapId = s.getState().savedSnapshots[0]?.id ?? '';
180
+
181
+ const w = s.getState().trials[0];
182
+ s.getState().addTrial({ ...(w as NonNullable<typeof w>), id: 'w2', auto: ['y'] });
183
+ s.getState().loadSnapshot(snapId, 'w2');
184
+ expect(s.getState().trials[1]?.auto).toEqual(['x']);
185
+ });
186
+
187
+ it('a snapshot saved with nothing auto pins every field on load', () => {
188
+ const s = makeStore();
189
+ s.getState().addTrial({
190
+ id: 'w1',
191
+ instrumentName: 'T',
192
+ config: { x: 1 },
193
+ state: {},
194
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
195
+ });
196
+ s.getState().saveSnapshot('w1', 'snap1');
197
+ expect(s.getState().savedSnapshots[0]?.auto).toEqual([]);
198
+ const snapId = s.getState().savedSnapshots[0]?.id ?? '';
199
+ s.getState().updateTrialConfig('w1', 'x', auto);
200
+ expect(s.getState().trials[0]?.auto).toEqual(['x']);
201
+ s.getState().loadSnapshot(snapId, 'w1');
202
+ expect(s.getState().trials[0]?.auto).toBeUndefined();
203
+ });
204
+
205
+ it("a snapshot saved before snapshots carried auto paths leaves the trial's own", () => {
206
+ const snap = {
207
+ id: 'old',
208
+ name: 'old',
209
+ trialId: 'w1',
210
+ instrumentName: 'T',
211
+ config: { x: 7 },
212
+ state: {},
213
+ savedAt: 1,
214
+ };
215
+ const s = makeStore({
216
+ initial: { version: 1, trials: [], saves: [snap], mode: 'auto' } as never,
217
+ });
218
+ s.getState().addTrial({
219
+ id: 'w1',
220
+ instrumentName: 'T',
221
+ config: { x: 1 },
222
+ state: {},
223
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
224
+ auto: ['x'],
225
+ });
226
+ s.getState().loadSnapshot('old', 'w1');
227
+ expect(s.getState().trials[0]?.config).toEqual({ x: 7 });
228
+ expect(s.getState().trials[0]?.auto).toEqual(['x']);
229
+ });
230
+
166
231
  it('loadSnapshot blocks cross-instrument load', () => {
167
232
  const s = makeStore();
168
233
  s.getState().addTrial({
@@ -1,6 +1,12 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
- import { fillConfigDefaults, withValueAtPath } from '../config/path';
3
- import { configDefaultsOf, configMigrationsOf, serializersOf } from '../instrument/serializers';
2
+ import { applyConfigWrite } from '../config/autoConfig';
3
+ import { fillConfigDefaults } from '../config/path';
4
+ import {
5
+ configDefaultsOf,
6
+ configMigrationsOf,
7
+ configSchemasOf,
8
+ serializersOf,
9
+ } from '../instrument/serializers';
4
10
  import type { InstrumentList } from '../instrument/types';
5
11
  import { emptyDocument } from './document';
6
12
  import { deserializeTrials, emptyUndoStack, newId } from './helpers';
@@ -75,6 +81,7 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
75
81
  serializers: options.serializers ?? {},
76
82
  configDefaults: options.configDefaults ?? {},
77
83
  configMigrations: options.configMigrations ?? {},
84
+ configSchemas: options.configSchemas ?? {},
78
85
  };
79
86
  const initial = options.initial ?? emptyDocument(options.initialMode ?? 'auto');
80
87
 
@@ -117,9 +124,18 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
117
124
 
118
125
  updateTrialConfig: (id, path, value) => {
119
126
  set((s) => ({
120
- trials: s.trials.map((w) =>
121
- w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
122
- ),
127
+ trials: s.trials.map((w) => {
128
+ if (w.id !== id) return w;
129
+ const was = w.auto ?? [];
130
+ // The sentinel is never stored: it becomes membership in `auto`, and
131
+ // the pinned value at the path is left exactly where it is.
132
+ const next = applyConfigWrite(w.config, was, path, value);
133
+ if (next.config === w.config && next.autoPaths === was) return w;
134
+ const { auto: _was, ...kept } = w;
135
+ const record: TrialRecord = { ...kept, config: next.config };
136
+ if (next.autoPaths.length > 0) record.auto = next.autoPaths;
137
+ return record;
138
+ }),
123
139
  }));
124
140
  },
125
141
 
@@ -202,6 +218,7 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
202
218
  instrumentName: trial.instrumentName,
203
219
  config: clonedConfig,
204
220
  state: serializedState,
221
+ auto: [...(trial.auto ?? [])],
205
222
  savedAt,
206
223
  };
207
224
  set((s) => ({ savedSnapshots: [...s.savedSnapshots, snapshot] }));
@@ -223,9 +240,14 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
223
240
  ? reg.deserialize(snapshot.state, snapshot.config)
224
241
  : snapshot.state;
225
242
  set((s) => ({
226
- trials: s.trials.map((w) =>
227
- w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
228
- ),
243
+ trials: s.trials.map((w) => {
244
+ if (w.id !== trialId) return w;
245
+ const loaded: TrialRecord = { ...w, state: restoredState, config: snapshot.config };
246
+ if (snapshot.auto === undefined) return loaded;
247
+ delete loaded.auto;
248
+ if (snapshot.auto.length > 0) loaded.auto = [...snapshot.auto];
249
+ return loaded;
250
+ }),
229
251
  }));
230
252
  },
231
253
 
@@ -302,6 +324,7 @@ function hooksOf(instruments: InstrumentList): InstrumentHooks {
302
324
  return {
303
325
  serializers: serializersOf(instruments),
304
326
  configDefaults: configDefaultsOf(instruments),
327
+ configSchemas: configSchemasOf(instruments),
305
328
  configMigrations: configMigrationsOf(instruments),
306
329
  };
307
330
  }
@@ -317,6 +340,7 @@ function mergedHooks(old: InstrumentHooks, instruments: InstrumentList): Instrum
317
340
  return {
318
341
  serializers: { ...unlisted(old.serializers), ...fresh.serializers },
319
342
  configDefaults: { ...unlisted(old.configDefaults), ...fresh.configDefaults },
343
+ configSchemas: { ...unlisted(old.configSchemas), ...fresh.configSchemas },
320
344
  configMigrations: { ...unlisted(old.configMigrations), ...fresh.configMigrations },
321
345
  };
322
346
  }
@@ -1,4 +1,5 @@
1
- import type { ConfigPath, ValueAtPath } from '../config/types';
1
+ import type { Auto } from '../config/auto';
2
+ import type { ConfigPath, ResolvedConfig, ValueAtPath } from '../config/types';
2
3
  import type { InstrumentList } from '../instrument/types';
3
4
  import type { UndockedPanels } from './undock';
4
5
  /** A trial's undo history, as snapshots of its state either side of the
@@ -31,6 +32,15 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
31
32
  * defaults. Kept so Reset restores the trial's own subject rather than the
32
33
  * bare defaults. */
33
34
  configSeed?: Partial<TC>;
35
+ /** Dotted paths the trial's seed config opened as auto. Kept beside
36
+ * `configSeed`, which cannot carry them: the sentinel is stripped on the way
37
+ * in, so Reset would otherwise have no way back to the state the trial
38
+ * opened in. Absent means none. */
39
+ autoSeed?: readonly string[];
40
+ /** Dotted paths this trial has unpinned. The value at such a path stays in
41
+ * `config` — it is what the field pins back to — but the instrument reads
42
+ * the resolver's value, or `undefined`, instead. Absent means none. */
43
+ auto?: readonly string[];
34
44
  /** This trial's own tool slot. Undefined means it reads the lab's. */
35
45
  activeToolId?: string | null;
36
46
  /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
@@ -60,6 +70,10 @@ export interface SavedSnapshot {
60
70
  instrumentName: string;
61
71
  config: unknown;
62
72
  state: unknown;
73
+ /** The trial's `auto` paths when saved; empty means every field was pinned.
74
+ * Absent on a snapshot saved before snapshots carried them, which loads
75
+ * over the trial's current set rather than guessing one. */
76
+ auto?: readonly string[];
63
77
  savedAt: number;
64
78
  }
65
79
 
@@ -111,9 +125,14 @@ export interface TrialStateHandle<TS, TC> {
111
125
  state: TS;
112
126
  setState: (next: TS | ((prev: TS) => TS)) => void;
113
127
  config: TC;
128
+ /** The config as stored, before any auto path is resolved. What the control
129
+ * panel renders, so a ghosted control sits at the value it pins back to. */
130
+ raw: TC;
131
+ /** Dotted paths this trial has unpinned. */
132
+ auto: ReadonlySet<string>;
114
133
  /** Write one config value, by dotted path — `'grid.size'` for a leaf under
115
134
  * an `f.group`, `'cellSize'` for one at the root. */
116
- setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
135
+ setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P> | Auto) => void;
117
136
  }
118
137
 
119
138
  /** Options for `createLabStore`, which knows nothing about storage. */
@@ -129,6 +148,10 @@ export interface CreateLabStoreOptions {
129
148
  /** Each instrument's `migrateConfig`, keyed by instrument name, run on a
130
149
  * stored config before its defaults fill it. */
131
150
  configMigrations?: Record<string, (stored: unknown) => unknown>;
151
+ /** Each instrument's resolved config schema, keyed by instrument name. What
152
+ * a trial's auto paths are read against, away from the lab that would
153
+ * otherwise supply the rules. */
154
+ configSchemas?: Record<string, ResolvedConfig>;
132
155
  /** How each instrument's state survives a reload. Hydration is the first
133
156
  * thing `createLabStore` does, so these have to arrive with the store. */
134
157
  serializers?: InstrumentSerializers;
@@ -142,6 +165,7 @@ export interface InstrumentHooks {
142
165
  serializers: InstrumentSerializers;
143
166
  configDefaults: Record<string, () => unknown>;
144
167
  configMigrations: Record<string, (stored: unknown) => unknown>;
168
+ configSchemas: Record<string, ResolvedConfig>;
145
169
  }
146
170
 
147
171
  /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
@@ -1,6 +1,10 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { auto } from '../config/auto';
5
+ import { f } from '../config/builder';
6
+ import { defineInstrument } from '../instrument/defineInstrument';
7
+ import type { Instrument } from '../instrument/types';
4
8
  import { LabStoreProvider, TrialIdProvider } from './context';
5
9
  import { createLabStore } from './store';
6
10
  import { useTrialState } from './useTrialState';
@@ -99,3 +103,84 @@ describe('useTrialState', () => {
99
103
  expect(() => renderHook(() => useTrialState())).toThrow('[labkit]');
100
104
  });
101
105
  });
106
+
107
+ const autoInstrument = defineInstrument({
108
+ name: 'Auto',
109
+ config: f.schema({
110
+ width: f.number(432),
111
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
112
+ }),
113
+ initialState: () => ({ count: 0 }),
114
+ render: () => null,
115
+ });
116
+
117
+ function makeAutoWrapper(trialId: string) {
118
+ const store = createLabStore({ instruments: [autoInstrument as Instrument] });
119
+ store.getState().addTrial({
120
+ id: trialId,
121
+ instrumentName: 'Auto',
122
+ config: autoInstrument.defaultConfig(),
123
+ state: { count: 0 },
124
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
125
+ });
126
+ return {
127
+ store,
128
+ wrapper: ({ children }: { children: ReactNode }) => (
129
+ <LabStoreProvider store={store}>
130
+ <TrialIdProvider trialId={trialId}>{children}</TrialIdProvider>
131
+ </LabStoreProvider>
132
+ ),
133
+ };
134
+ }
135
+
136
+ describe('auto paths', () => {
137
+ it('hands the instrument the resolved config and the raw one separately', () => {
138
+ const { store, wrapper } = makeAutoWrapper('w1');
139
+ const { result } = renderHook(
140
+ () => useTrialState<{ count: number }, { width: number; gap: number }>(),
141
+ { wrapper },
142
+ );
143
+ act(() => store.getState().updateTrialConfig('w1', 'gap', auto));
144
+ expect(result.current.config.gap).toBe(18);
145
+ expect(result.current.raw.gap).toBe(12);
146
+ expect([...result.current.auto]).toEqual(['gap']);
147
+ });
148
+
149
+ it('leaves a pinned config untouched', () => {
150
+ const { wrapper } = makeAutoWrapper('w1');
151
+ const { result } = renderHook(
152
+ () => useTrialState<{ count: number }, { width: number; gap: number }>(),
153
+ { wrapper },
154
+ );
155
+ expect(result.current.config.gap).toBe(12);
156
+ expect(result.current.config).toBe(result.current.raw);
157
+ expect([...result.current.auto]).toEqual([]);
158
+ });
159
+
160
+ it('records a path written as auto instead of storing the sentinel', () => {
161
+ const { store } = makeAutoWrapper('w1');
162
+ store.getState().updateTrialConfig('w1', 'gap', 24);
163
+ store.getState().updateTrialConfig('w1', 'gap', auto);
164
+ const rec = store.getState().trials[0];
165
+ expect(rec?.auto).toEqual(['gap']);
166
+ // The last pinned value survives, so un-pinning is lossless.
167
+ expect(rec?.config).toEqual({ width: 432, gap: 24 });
168
+ });
169
+
170
+ it('pins again when a real value is written to an auto path', () => {
171
+ const { store } = makeAutoWrapper('w1');
172
+ store.getState().updateTrialConfig('w1', 'gap', auto);
173
+ store.getState().updateTrialConfig('w1', 'gap', 30);
174
+ const rec = store.getState().trials[0];
175
+ expect(rec?.auto).toBeUndefined();
176
+ expect((rec?.config as { gap: number }).gap).toBe(30);
177
+ });
178
+
179
+ it('does not allocate a new record when the path is already auto', () => {
180
+ const { store } = makeAutoWrapper('w1');
181
+ store.getState().updateTrialConfig('w1', 'gap', auto);
182
+ const first = store.getState().trials[0];
183
+ store.getState().updateTrialConfig('w1', 'gap', auto);
184
+ expect(store.getState().trials[0]).toBe(first);
185
+ });
186
+ });
@@ -1,5 +1,6 @@
1
1
  import { useContext } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
+ import { useResolvedConfig } from '../config/useResolvedConfig';
3
4
  import { LabStoreContext, TrialIdContext } from './context';
4
5
  import type { TrialStateHandle } from './types';
5
6
 
@@ -19,10 +20,17 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
19
20
 
20
21
  const updateTrialState = useStore(ctx.store, (s) => s.updateTrialState);
21
22
  const updateTrialConfig = useStore(ctx.store, (s) => s.updateTrialConfig);
23
+ // The store's own copy, not `useConfigSchema`: that reads `LabContext`, and
24
+ // this hook only has the store's.
25
+ const instrumentHooks = useStore(ctx.store, (s) => s.instrumentHooks);
26
+ const schema = instrumentHooks().configSchemas[record.instrumentName];
27
+ const { config, auto } = useResolvedConfig<TC>(schema, record.config as TC, record.auto);
22
28
 
23
29
  return {
24
30
  state: record.state as TS,
25
- config: record.config as TC,
31
+ config,
32
+ raw: record.config as TC,
33
+ auto,
26
34
  setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
27
35
  setConfig: (path, value) => updateTrialConfig(trialId, path, value),
28
36
  };
package/src/styles.less CHANGED
@@ -25,3 +25,4 @@
25
25
  @import './annotations/Annotations.less';
26
26
  @import './dragdrop/Palette.less';
27
27
  @import './dragdrop/DragGhost.less';
28
+ @import './specimen/Specimen.less';
@@ -1,8 +1,8 @@
1
1
  export { composeRects, rectsEqual } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
- export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
5
4
  export type { SurfaceCanvases, SurfaceLayer } from './SurfaceContext';
5
+ export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
6
6
  export {
7
7
  useSurface,
8
8
  useSurfaceCanvas,
@@ -11,7 +11,7 @@ function fakeHandle(): SurfaceHandle {
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
13
  registerClear: vi.fn(() => () => {}),
14
- registerPainter: vi.fn(() => () => {}),
14
+ registerPainter: vi.fn(() => () => {}),
15
15
  containerRef: vi.fn(),
16
16
  getContainer: vi.fn(() => null),
17
17
  };
@@ -1,4 +1,4 @@
1
- import { act, render } from '@testing-library/react';
1
+ import { act, render, renderHook } from '@testing-library/react';
2
2
  import { useEffect } from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import type { SurfaceFrame, SurfaceHandle } from './useTiledSurface';
@@ -315,8 +315,20 @@ describe('useTiledSurface', () => {
315
315
  surface.containerRef(el);
316
316
  }}
317
317
  >
318
- <div ref={(el) => { if (!el) return; stubBox(el, 0, 0, 400, 600); surface.registerTile('a', el); }} />
319
- <div ref={(el) => { if (!el) return; stubBox(el, 400, 0, 400, 600); surface.registerTile('b', el); }} />
318
+ <div
319
+ ref={(el) => {
320
+ if (!el) return;
321
+ stubBox(el, 0, 0, 400, 600);
322
+ surface.registerTile('a', el);
323
+ }}
324
+ />
325
+ <div
326
+ ref={(el) => {
327
+ if (!el) return;
328
+ stubBox(el, 400, 0, 400, 600);
329
+ surface.registerTile('b', el);
330
+ }}
331
+ />
320
332
  </div>
321
333
  );
322
334
  }
@@ -345,7 +357,12 @@ describe('useTiledSurface', () => {
345
357
  const frames: SurfaceFrame[] = [];
346
358
  let handle: SurfaceHandle | null = null;
347
359
  const { getByTestId } = render(
348
- <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
360
+ <Harness
361
+ frames={frames}
362
+ onHandle={(h) => {
363
+ handle = h;
364
+ }}
365
+ />,
349
366
  );
350
367
  flushFrames();
351
368
  frames.length = 0;
@@ -368,7 +385,12 @@ describe('useTiledSurface', () => {
368
385
  const frames: SurfaceFrame[] = [];
369
386
  let handle: SurfaceHandle | null = null;
370
387
  const { getByTestId } = render(
371
- <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
388
+ <Harness
389
+ frames={frames}
390
+ onHandle={(h) => {
391
+ handle = h;
392
+ }}
393
+ />,
372
394
  );
373
395
  flushFrames();
374
396
 
@@ -407,7 +429,12 @@ describe('useTiledSurface', () => {
407
429
  const frames: SurfaceFrame[] = [];
408
430
  let handle: SurfaceHandle | null = null;
409
431
  const { getByTestId } = render(
410
- <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
432
+ <Harness
433
+ frames={frames}
434
+ onHandle={(h) => {
435
+ handle = h;
436
+ }}
437
+ />,
411
438
  );
412
439
  flushFrames();
413
440
 
@@ -423,8 +450,18 @@ describe('useTiledSurface', () => {
423
450
  const stops = [340, 370, 400, 400, 400];
424
451
  let call = 0;
425
452
  vi.spyOn(b, 'getBoundingClientRect').mockImplementation(() => {
426
- const left = stops[Math.min(call++, stops.length - 1)]!;
427
- return { left, top: 0, width: 400, height: 600, right: left + 400, bottom: 600, x: left, y: 0, toJSON: () => ({}) } as DOMRect;
453
+ const left = stops[Math.min(call++, stops.length - 1)] ?? 0;
454
+ return {
455
+ left,
456
+ top: 0,
457
+ width: 400,
458
+ height: 600,
459
+ right: left + 400,
460
+ bottom: 600,
461
+ x: left,
462
+ y: 0,
463
+ toJSON: () => ({}),
464
+ } as DOMRect;
428
465
  });
429
466
 
430
467
  seen.length = 0;
@@ -442,13 +479,20 @@ describe('useTiledSurface', () => {
442
479
  const frames: SurfaceFrame[] = [];
443
480
  let handle: SurfaceHandle | null = null;
444
481
  const { getByTestId } = render(
445
- <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
482
+ <Harness
483
+ frames={frames}
484
+ onHandle={(h) => {
485
+ handle = h;
486
+ }}
487
+ />,
446
488
  );
447
489
  flushFrames();
448
490
 
449
491
  const clear = vi.fn();
450
492
  let off: (() => void) | undefined;
451
- act(() => { off = handle?.registerClear('a', clear); });
493
+ act(() => {
494
+ off = handle?.registerClear('a', clear);
495
+ });
452
496
  stubBox(getByTestId('b'), 310, 0, 400, 600);
453
497
  act(() => handle?.invalidateRects());
454
498
  flushFrames();
@@ -462,3 +506,14 @@ describe('useTiledSurface', () => {
462
506
  });
463
507
  });
464
508
  });
509
+
510
+ describe('useTiledSurface handle identity', () => {
511
+ it('returns the same handle across renders', () => {
512
+ // A new handle per render reaches useSurfaceTile through context as a new
513
+ // ref callback, which unregisters and re-registers every tile and retiles.
514
+ const { result, rerender } = renderHook(() => useTiledSurface({ onFrame: () => {} }));
515
+ const first = result.current;
516
+ rerender();
517
+ expect(result.current).toBe(first);
518
+ });
519
+ });
@@ -1,5 +1,5 @@
1
1
  import { useVisibleRaf } from '@weasel-js/core';
2
- import { useCallback, useEffect, useRef } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
3
3
  import { composeRects, rectsEqual } from './composeRects';
4
4
  import type { Box, Rect } from './rect';
5
5
 
@@ -235,14 +235,26 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
235
235
  };
236
236
  }, [schedule, frameLoop]);
237
237
 
238
- return {
239
- invalidate,
240
- invalidateAll,
241
- invalidateRects,
242
- registerTile,
243
- registerClear,
244
- registerPainter,
245
- containerRef,
246
- getContainer,
247
- };
238
+ return useMemo(
239
+ () => ({
240
+ invalidate,
241
+ invalidateAll,
242
+ invalidateRects,
243
+ registerTile,
244
+ registerClear,
245
+ registerPainter,
246
+ containerRef,
247
+ getContainer,
248
+ }),
249
+ [
250
+ invalidate,
251
+ invalidateAll,
252
+ invalidateRects,
253
+ registerTile,
254
+ registerClear,
255
+ registerPainter,
256
+ containerRef,
257
+ getContainer,
258
+ ],
259
+ );
248
260
  }
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import type { TokenName } from '@weasel-js/theme';
3
3
  import { useTheme } from '@weasel-js/theme/react';
4
4
  import {
@@ -18,7 +18,7 @@ const meta: Meta = {
18
18
  description: {
19
19
  component:
20
20
  'The interstellar theme: deep-space cosmic gradient + starscape backdrop, glass surfaces, Oswald display type, purple accent. ' +
21
- 'Authored as DTCG in `src/theme/interstellar.tokens.json` and loaded with `loadDTCG`; it extends the built-in weasel theme, overriding values rather than adding tokens.',
21
+ 'A pins-only theme definition in `src/theme/interstellar.theme.json`, loaded with `defineTheme`; it extends the built-in weasel theme, overriding values rather than adding tokens.',
22
22
  },
23
23
  },
24
24
  },
@@ -1,11 +1,20 @@
1
- import { resolveTheme } from '@weasel-js/theme';
1
+ import { resolveTheme, THEME_SOURCES, type ThemeDefinition } from '@weasel-js/theme';
2
+ import { derive } from '@weasel-js/theme/engine';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import { interstellarTheme } from './interstellar';
5
+ import definition from './interstellar.theme.json' with { type: 'json' };
4
6
 
5
7
  describe('interstellarTheme', () => {
8
+ it('derives as a definition extending weasel with no issues', () => {
9
+ const lookup = (name: string) => (name === 'weasel' ? THEME_SOURCES.weasel : undefined);
10
+ for (const mode of ['dark', 'light']) {
11
+ expect(derive(definition as ThemeDefinition, { mode }, lookup).issues, mode).toEqual([]);
12
+ }
13
+ });
14
+
6
15
  it('extends weasel and resolves both modes', () => {
7
- const dark = resolveTheme(interstellarTheme, 'dark');
8
- const light = resolveTheme(interstellarTheme, 'light');
16
+ const dark = resolveTheme(interstellarTheme, { mode: 'dark' });
17
+ const light = resolveTheme(interstellarTheme, { mode: 'light' });
9
18
 
10
19
  // Values carried over verbatim from the retired Less.
11
20
  expect(dark['--wzl-surface']).toBe('#0a0a14');
@@ -24,7 +33,9 @@ describe('interstellarTheme', () => {
24
33
  });
25
34
 
26
35
  it('carries the cosmic backdrop in dark and drops it in light', () => {
27
- expect(resolveTheme(interstellarTheme, 'dark')['--wzl-backdrop']).toContain('radial-gradient');
28
- expect(resolveTheme(interstellarTheme, 'light')['--wzl-backdrop']).toBe('none');
36
+ expect(resolveTheme(interstellarTheme, { mode: 'dark' })['--wzl-backdrop']).toContain(
37
+ 'radial-gradient',
38
+ );
39
+ expect(resolveTheme(interstellarTheme, { mode: 'light' })['--wzl-backdrop']).toBe('none');
29
40
  });
30
41
  });