@weasel-js/labkit 1.4.2 → 1.4.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 (117) hide show
  1. package/README.md +20 -2
  2. package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-kTEZvEgE.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
  4. package/dist/_dts/{frac-A4R6v4ld.d.ts → frac-z7ker2Vx.d.ts} +11 -7
  5. package/dist/_dts/{index-CsU9WhjM.d.ts → index-Blv9uQzu.d.ts} +42 -9
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -7
  10. package/dist/canvas/index.d.ts +9 -8
  11. package/dist/canvas/index.js +1 -2
  12. package/dist/chrome/index.d.ts +7 -6
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-ULDW42CR.js → chunk-2P6PP5N4.js} +33 -23
  15. package/dist/chunk-2P6PP5N4.js.map +1 -0
  16. package/dist/{chunk-W2FJR5FF.js → chunk-64ZCN3DA.js} +74 -76
  17. package/dist/chunk-64ZCN3DA.js.map +1 -0
  18. package/dist/{chunk-NSOVI3AZ.js → chunk-BKFVHKJH.js} +6 -6
  19. package/dist/{chunk-NSOVI3AZ.js.map → chunk-BKFVHKJH.js.map} +1 -1
  20. package/dist/chunk-E44SU6XS.js +50 -0
  21. package/dist/chunk-E44SU6XS.js.map +1 -0
  22. package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
  23. package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  24. package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
  25. package/dist/chunk-I6JCVE24.js.map +1 -0
  26. package/dist/{chunk-UDXOYZEC.js → chunk-MOM3GOVY.js} +4 -4
  27. package/dist/{chunk-UDXOYZEC.js.map → chunk-MOM3GOVY.js.map} +1 -1
  28. package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
  29. package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
  30. package/dist/{chunk-UTOEDPNU.js → chunk-SMHP6XZ4.js} +7 -6
  31. package/dist/chunk-SMHP6XZ4.js.map +1 -0
  32. package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
  33. package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
  34. package/dist/{chunk-XKENZTNE.js → chunk-U3IYHIAE.js} +71 -55
  35. package/dist/chunk-U3IYHIAE.js.map +1 -0
  36. package/dist/chunk-W3ECWC2K.js +7335 -0
  37. package/dist/chunk-W3ECWC2K.js.map +1 -0
  38. package/dist/controls/index.d.ts +5 -4
  39. package/dist/controls/index.js +3 -3
  40. package/dist/dragdrop/index.d.ts +6 -5
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +139 -26
  43. package/dist/index.js +309 -144
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +7 -6
  46. package/dist/layers/index.js +2 -3
  47. package/dist/loupe/index.d.ts +8 -7
  48. package/dist/loupe/index.js +1 -2
  49. package/dist/passthrough/weasel-canvas.d.ts +1 -3
  50. package/dist/passthrough/weasel-canvas.js +1 -1
  51. package/dist/passthrough/weasel-canvas.js.map +1 -1
  52. package/dist/passthrough/weasel-ui.d.ts +98 -20
  53. package/dist/passthrough/weasel-ui.js +1 -2
  54. package/dist/primitives/index.js +3 -4
  55. package/dist/state/index.d.ts +6 -3
  56. package/dist/state/index.js +3 -2
  57. package/dist/state/index.js.map +1 -1
  58. package/dist/styles.css +82 -15
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.js +1 -2
  61. package/dist/undo/index.d.ts +6 -6
  62. package/package.json +7 -7
  63. package/src/annotations/ExportMenu.test.tsx +16 -0
  64. package/src/annotations/ExportMenu.tsx +0 -5
  65. package/src/annotations/frac.test.ts +4 -4
  66. package/src/annotations/frac.ts +3 -2
  67. package/src/annotations/index.ts +1 -1
  68. package/src/annotations/store.ts +2 -2
  69. package/src/annotations/svgNodes.ts +1 -2
  70. package/src/config/builder.test.ts +56 -2
  71. package/src/config/builder.ts +73 -13
  72. package/src/config/index.ts +16 -1
  73. package/src/config/path.test.ts +109 -0
  74. package/src/config/path.ts +76 -0
  75. package/src/config/resolve.test.ts +93 -2
  76. package/src/config/resolve.ts +72 -29
  77. package/src/config/types.ts +99 -9
  78. package/src/config/visible.ts +6 -4
  79. package/src/controls/ControlPanel.stories.tsx +38 -4
  80. package/src/controls/ControlPanel.test.tsx +114 -0
  81. package/src/controls/ControlPanel.tsx +99 -50
  82. package/src/index.ts +17 -2
  83. package/src/instrument/serializers.ts +36 -0
  84. package/src/instrument/types.ts +4 -2
  85. package/src/lab/Lab.test.tsx +29 -1
  86. package/src/lab/Lab.tsx +10 -1
  87. package/src/lab/LabShell.test.tsx +8 -0
  88. package/src/lab/LabShell.tsx +17 -3
  89. package/src/lab/LabSwitcher.less +85 -0
  90. package/src/lab/LabSwitcher.test.tsx +96 -0
  91. package/src/lab/LabSwitcher.tsx +112 -0
  92. package/src/lab/index.ts +2 -0
  93. package/src/primitives/ZoomControl.less +6 -2
  94. package/src/primitives/ZoomControl.tsx +1 -0
  95. package/src/state/helpers.test.ts +20 -0
  96. package/src/state/helpers.ts +17 -1
  97. package/src/state/store.test.ts +155 -1
  98. package/src/state/store.ts +32 -26
  99. package/src/state/types.ts +24 -3
  100. package/src/state/useTrialState.ts +1 -1
  101. package/src/styles.less +1 -0
  102. package/src/theme/base.less +4 -4
  103. package/src/trial/Trial.config.test.tsx +44 -0
  104. package/src/trial/Trial.tsx +3 -3
  105. package/src/trial/TrialChrome.tsx +10 -5
  106. package/src/trial/trialOps.ts +7 -10
  107. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  108. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  109. package/dist/chunk-54ZWZ5FQ.js +0 -29997
  110. package/dist/chunk-54ZWZ5FQ.js.map +0 -1
  111. package/dist/chunk-FJG4PHTL.js.map +0 -1
  112. package/dist/chunk-ISSVF5PT.js +0 -7263
  113. package/dist/chunk-ISSVF5PT.js.map +0 -1
  114. package/dist/chunk-ULDW42CR.js.map +0 -1
  115. package/dist/chunk-UTOEDPNU.js.map +0 -1
  116. package/dist/chunk-W2FJR5FF.js.map +0 -1
  117. package/dist/chunk-XKENZTNE.js.map +0 -1
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+
3
+ /** One page in a project's set of labs. */
4
+ export interface LabPage {
5
+ /** The page's own URL, and how the switcher knows which page is open. */
6
+ href: string;
7
+ label: string;
8
+ }
9
+
10
+ /** Props for `<LabSwitcher>`. */
11
+ export interface LabSwitcherProps {
12
+ /** The open page's title, shown as the control's own label. */
13
+ title: string;
14
+ pages: readonly LabPage[];
15
+ /** Defaults to the current location. Pass it in tests, or to render a
16
+ * switcher for a page other than the one being viewed. */
17
+ path?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /** Which of `pages` a path is on, or -1.
22
+ *
23
+ * Matched on the end of the path so a query string, a trailing slash or a
24
+ * leftover `.html` cannot lose it: a project whose dev server maps `/stats`
25
+ * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
+ * lost their extension still resolves to the same page. */
27
+ export function currentPage(path: string, pages: readonly LabPage[]): number {
28
+ const here = path.split('?')[0]!.split('#')[0]!
29
+ .replace(/\/$/, '').replace(/\.html$/, '');
30
+ return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
31
+ }
32
+
33
+ /** A project's lab title, doubling as the way to reach its other labs.
34
+ *
35
+ * The menu holds real anchors, not a `<select>` and not buttons. These are
36
+ * separate documents, so an `href` is what gets middle-click, cmd-click and
37
+ * the back button for free; a select fires a change event and can do none of
38
+ * the three. The trigger is a button because it opens something rather than
39
+ * going anywhere.
40
+ *
41
+ * Pages accumulate -- a project grows a wall, a dashboard, an ingest page, a
42
+ * bench -- and a tab strip is the layout that stops working first. */
43
+ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps) {
44
+ const [open, setOpen] = useState(false);
45
+ const root = useRef<HTMLDivElement>(null);
46
+ const menuId = useId();
47
+ const here = currentPage(path ?? (typeof window === 'undefined'
48
+ ? '' : window.location.pathname), pages);
49
+
50
+ const close = useCallback(() => setOpen(false), []);
51
+
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const onKey = (e: KeyboardEvent) => {
55
+ if (e.key === 'Escape') {
56
+ close();
57
+ root.current?.querySelector('button')?.focus();
58
+ }
59
+ };
60
+ // Pointer, not click: a press that starts outside should dismiss before
61
+ // whatever it lands on reacts, the way every other menu on the page does.
62
+ const onDown = (e: PointerEvent) => {
63
+ if (!root.current?.contains(e.target as Node)) close();
64
+ };
65
+ document.addEventListener('keydown', onKey);
66
+ document.addEventListener('pointerdown', onDown);
67
+ return () => {
68
+ document.removeEventListener('keydown', onKey);
69
+ document.removeEventListener('pointerdown', onDown);
70
+ };
71
+ }, [open, close]);
72
+
73
+ // One lab is not a set to choose from, and a disclosure arrow promising a
74
+ // menu of itself is worse than no control.
75
+ if (pages.length < 2) {
76
+ return <h1 className={`lk-switcher-title${className ? ` ${className}` : ''}`}>{title}</h1>;
77
+ }
78
+
79
+ return (
80
+ <div className={`lk-switcher${className ? ` ${className}` : ''}`} ref={root}>
81
+ <h1 className="lk-switcher-title">
82
+ <button
83
+ type="button"
84
+ className="lk-switcher-trigger"
85
+ aria-expanded={open}
86
+ aria-haspopup="menu"
87
+ aria-controls={open ? menuId : undefined}
88
+ onClick={() => setOpen((was) => !was)}
89
+ >
90
+ {title}
91
+ <span className="lk-switcher-caret" aria-hidden="true" />
92
+ </button>
93
+ </h1>
94
+ {open && (
95
+ <div className="lk-switcher-menu" id={menuId} role="menu">
96
+ {pages.map((page, i) => (
97
+ <a
98
+ key={page.href}
99
+ href={page.href}
100
+ role="menuitem"
101
+ className="lk-switcher-item"
102
+ aria-current={i === here ? 'page' : undefined}
103
+ onClick={close}
104
+ >
105
+ {page.label}
106
+ </a>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
package/src/lab/index.ts CHANGED
@@ -6,5 +6,7 @@ export type { LabPaletteProps } from './LabPalette';
6
6
  export { LabPalette } from './LabPalette';
7
7
  export type { LabShellProps } from './LabShell';
8
8
  export { LabShell } from './LabShell';
9
+ export type { LabPage, LabSwitcherProps } from './LabSwitcher';
10
+ export { currentPage, LabSwitcher } from './LabSwitcher';
9
11
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
10
12
  export { Workspace } from './Workspace';
@@ -13,7 +13,7 @@
13
13
  }
14
14
  }
15
15
 
16
- // Both children are weasel-ui components whose module classes set `width`.
16
+ // The slider is a weasel-ui component whose module class sets `width`.
17
17
  // See the `.lk-root` prefix convention in theme/base.less.
18
18
  .lk-root {
19
19
  .lk-zoom {
@@ -24,8 +24,12 @@
24
24
  min-width: 64px;
25
25
  }
26
26
 
27
+ // `width: fit` sizes the field from this rather than from the input's
28
+ // 20-character intrinsic width. "800%" measures 33px against the 35px a
29
+ // 5ch box gives, which is no margin at all in another UI font; 6ch also
30
+ // holds the "1600%" a consumer raising `max` can reach.
27
31
  &__field {
28
- width: 62px;
32
+ --wzl-number-field-width: 6ch;
29
33
  }
30
34
  }
31
35
  }
@@ -60,6 +60,7 @@ export function ZoomControl({
60
60
  factor itself and renders 0.5 as "50%". */}
61
61
  <NumberField
62
62
  className="lk-zoom__field"
63
+ width="fit"
63
64
  aria-label="Zoom"
64
65
  value={zoom}
65
66
  minValue={min}
@@ -4,6 +4,7 @@ import {
4
4
  deserializeTrials,
5
5
  emptyUndoStack,
6
6
  encodeUrlHash,
7
+ newId,
7
8
  labStorageKey,
8
9
  serializeTrials,
9
10
  } from './helpers';
@@ -114,4 +115,23 @@ describe('deserializeTrials', () => {
114
115
  it('returns an empty list when given something that is not an array', () => {
115
116
  expect(deserializeTrials(undefined as never, {})).toEqual([]);
116
117
  });
118
+
119
+ describe('newId', () => {
120
+ it('mints distinct ids', () => {
121
+ expect(newId()).not.toBe(newId());
122
+ });
123
+
124
+ it('falls back where crypto.randomUUID is absent', () => {
125
+ // What a lab reached by LAN IP gets: not a secure context, so the
126
+ // function is not there. It used to throw on the first render and show a
127
+ // blank page.
128
+ const real = globalThis.crypto;
129
+ Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
130
+ try {
131
+ expect(newId()).not.toBe(newId());
132
+ } finally {
133
+ Object.defineProperty(globalThis, 'crypto', { value: real, configurable: true });
134
+ }
135
+ });
136
+ });
117
137
  });
@@ -25,6 +25,22 @@ export function decodeUrlHash(hash: string): string | null {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * An id for a trial or a snapshot.
30
+ *
31
+ * `crypto.randomUUID` exists only in a secure context, and a LAN address is
32
+ * not one — so a lab opened on a phone or a tablet by IP throws on its first
33
+ * render and shows a blank page with nothing in reach to say why. The counter
34
+ * is per page load, which is all these ids have to survive: they name records
35
+ * inside one session's store, and a saved snapshot carries its own name.
36
+ */
37
+ let idCounter = 0;
38
+ export function newId(): string {
39
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
40
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
41
+ return `lk-${Date.now().toString(36)}-${idCounter++}`;
42
+ }
43
+
28
44
  /** A fresh, empty undo history. */
29
45
  export function emptyUndoStack(): UndoStack {
30
46
  return { past: [], future: [] };
@@ -58,7 +74,7 @@ export function deserializeTrials(
58
74
  const d = deserializers[r.instrumentName];
59
75
  return {
60
76
  ...r,
61
- state: d?.deserialize ? d.deserialize(r.state) : r.state,
77
+ state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
62
78
  undoStack: emptyUndoStack(),
63
79
  };
64
80
  });
@@ -100,10 +100,114 @@ describe('updateTrialConfig', () => {
100
100
  state: {},
101
101
  view: { zoom: 1, pan: { x: 0, y: 0 } },
102
102
  });
103
- s.getState().updateTrialConfig('w1', 'x' as never, 99 as never);
103
+ s.getState().updateTrialConfig('w1', 'x', 99);
104
104
  expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
105
105
  expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
106
106
  });
107
+
108
+ it('writes down a dotted path without disturbing its siblings', () => {
109
+ const s = makeStore();
110
+ const config = { grid: { size: 20, color: '#fff' }, showGrid: true };
111
+ s.getState().addTrial({
112
+ id: 'w1',
113
+ instrumentName: 'T',
114
+ config,
115
+ state: {},
116
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
117
+ });
118
+ s.getState().updateTrialConfig('w1', 'grid.size', 40);
119
+ expect(s.getState().trials[0]?.config).toEqual({
120
+ grid: { size: 40, color: '#fff' },
121
+ showGrid: true,
122
+ });
123
+ // The record handed in is never mutated: a trial re-renders on identity.
124
+ expect(config.grid.size).toBe(20);
125
+ });
126
+ });
127
+
128
+ describe('createLabStore - config defaults', () => {
129
+ const stored = (config: unknown) =>
130
+ JSON.stringify({
131
+ version: CURRENT_DOCUMENT_VERSION,
132
+ trials: [
133
+ {
134
+ id: 'w1',
135
+ instrumentName: 'T',
136
+ config,
137
+ state: {},
138
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
139
+ },
140
+ ],
141
+ saves: [
142
+ {
143
+ id: 's1',
144
+ name: 'saved',
145
+ trialId: 'w1',
146
+ instrumentName: 'T',
147
+ config,
148
+ state: {},
149
+ savedAt: 1,
150
+ },
151
+ ],
152
+ layout: {},
153
+ undockedPanels: {},
154
+ mode: 'auto',
155
+ });
156
+
157
+ it('fills a branch a config stored before the schema nested it never had', () => {
158
+ const storage = createMemoryAdapter();
159
+ storage.write(labDocumentKey('test'), stored({ showGrid: false, gridSize: 40 }));
160
+ const s = createLabStore({
161
+ storageKey: 'test',
162
+ storage,
163
+ configDefaults: { T: () => ({ showGrid: true, grid: { size: 20, color: '#fff' } }) },
164
+ });
165
+ expect(s.getState().trials[0]?.config).toEqual({
166
+ showGrid: false,
167
+ gridSize: 40,
168
+ grid: { size: 20, color: '#fff' },
169
+ });
170
+ });
171
+
172
+ it('fills the config on a saved snapshot the same way', () => {
173
+ const storage = createMemoryAdapter();
174
+ storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
175
+ const s = createLabStore({
176
+ storageKey: 'test',
177
+ storage,
178
+ configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
179
+ });
180
+ expect(s.getState().savedSnapshots[0]?.config).toEqual({
181
+ grid: { size: 40, color: '#fff' },
182
+ });
183
+ });
184
+
185
+ it('hands a deserializer the filled config, not the stored one', () => {
186
+ const storage = createMemoryAdapter();
187
+ storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
188
+ const seen: unknown[] = [];
189
+ createLabStore({
190
+ storageKey: 'test',
191
+ storage,
192
+ configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
193
+ serializers: {
194
+ T: {
195
+ deserialize: (state, config) => {
196
+ seen.push(config);
197
+ return state;
198
+ },
199
+ },
200
+ },
201
+ });
202
+ expect(seen).toEqual([{ grid: { size: 40, color: '#fff' } }]);
203
+ });
204
+
205
+ it('leaves a config alone when no defaults are registered for its instrument', () => {
206
+ const storage = createMemoryAdapter();
207
+ storage.write(labDocumentKey('test'), stored({ only: 1 }));
208
+ const s = createLabStore({ storageKey: 'test', storage });
209
+ expect(s.getState().trials[0]?.config).toEqual({ only: 1 });
210
+ });
107
211
  });
108
212
 
109
213
  describe('setMode', () => {
@@ -222,6 +326,56 @@ describe('persistence — hydration', () => {
222
326
  expect(hydrated.getState().trials).toHaveLength(1);
223
327
  expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
224
328
  });
329
+
330
+ // An instrument holding a Map, a Set or anything else JSON drops needs these
331
+ // to run at both ends. They are read while the store is being built, which
332
+ // is why they arrive as an option rather than being registered afterwards.
333
+ it("runs an instrument's serializers at flush and at hydrate", () => {
334
+ vi.useFakeTimers();
335
+ const mem = createMemoryAdapter();
336
+ const serializers = {
337
+ T: {
338
+ serialize: (state: unknown) => [...(state as Map<string, number>)],
339
+ deserialize: (data: unknown) => new Map(data as [string, number][]),
340
+ },
341
+ };
342
+
343
+ const seed = createLabStore({ storageKey: 'test', storage: mem, serializers });
344
+ seed.getState().addTrial({
345
+ id: 'w1',
346
+ instrumentName: 'T',
347
+ config: {},
348
+ state: new Map([['a', 1]]),
349
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
350
+ });
351
+ vi.advanceTimersByTime(500);
352
+ vi.useRealTimers();
353
+
354
+ const written = mem.read(labDocumentKey('test')) ?? '';
355
+ expect(written).toContain('[["a",1]]');
356
+
357
+ const hydrated = createLabStore({ storageKey: 'test', storage: mem, serializers });
358
+ expect(hydrated.getState().trials[0]?.state).toEqual(new Map([['a', 1]]));
359
+ });
360
+
361
+ it('hands `deserialize` the config the state was saved against', () => {
362
+ vi.useFakeTimers();
363
+ const mem = createMemoryAdapter();
364
+ const deserialize = vi.fn((data: unknown) => data);
365
+ const seed = createLabStore({ storageKey: 'test', storage: mem });
366
+ seed.getState().addTrial({
367
+ id: 'w1',
368
+ instrumentName: 'T',
369
+ config: { scale: 4 },
370
+ state: { n: 1 },
371
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
372
+ });
373
+ vi.advanceTimersByTime(500);
374
+ vi.useRealTimers();
375
+
376
+ createLabStore({ storageKey: 'test', storage: mem, serializers: { T: { deserialize } } });
377
+ expect(deserialize).toHaveBeenCalledWith({ n: 1 }, { scale: 4 });
378
+ });
225
379
  });
226
380
 
227
381
  describe('persistence — debounced writes', () => {
@@ -1,4 +1,5 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
+ import { fillConfigDefaults, withValueAtPath } from '../config/path';
2
3
  import {
3
4
  CURRENT_DOCUMENT_VERSION,
4
5
  deleteLegacyKeys,
@@ -10,10 +11,9 @@ import {
10
11
  readLegacyDocument,
11
12
  runMigrations,
12
13
  } from './document';
13
- import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
14
+ import { deserializeTrials, emptyUndoStack, newId, serializeTrials } from './helpers';
14
15
  import type {
15
16
  CreateLabStoreOptions,
16
- InstrumentSerializers,
17
17
  LabDocument,
18
18
  LabMode,
19
19
  LabStoreState,
@@ -32,7 +32,9 @@ export interface LabStoreActions {
32
32
  addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
33
33
  removeTrial: (id: string) => void;
34
34
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
35
- updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
35
+ /** Write one config value. `path` is dotted for a value nested under an
36
+ * `f.group`, and the bare key for one at the root. */
37
+ updateTrialConfig: (id: string, path: string, value: unknown) => void;
36
38
  updateTrialView: (id: string, view: unknown) => void;
37
39
  updateTrialSidebarWidth: (id: string, width: number) => void;
38
40
  /** Retitle a trial. `null` returns it to its instrument's name. */
@@ -59,16 +61,14 @@ export interface LabStoreActions {
59
61
  dockPanel: (trialId: string, sectionId?: string) => void;
60
62
  }
61
63
 
62
- /** A lab's store: its state and actions, plus the hook instruments use to
63
- * register how their state is serialized. */
64
- export type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
65
- registerSerializers: (s: InstrumentSerializers) => void;
66
- };
64
+ /** A lab's store: its state and its actions. How each instrument's state is
65
+ * serialized comes in through `CreateLabStoreOptions.serializers`. */
66
+ export type LabStore = StoreApi<LabStoreState & LabStoreActions>;
67
67
 
68
68
  /** Build a lab store, hydrating from storage if anything was saved under the
69
69
  * same key. Writes back are debounced. */
70
70
  export function createLabStore(options: CreateLabStoreOptions): LabStore {
71
- let serializers: InstrumentSerializers = {};
71
+ const serializers = options.serializers ?? {};
72
72
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
73
73
 
74
74
  const hydration = hydrateDocument(options);
@@ -77,8 +77,20 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
77
77
  // Cleared once the legacy keys are actually gone; see the flush.
78
78
  let foldedFromLegacy = hydration.foldedFromLegacy;
79
79
 
80
- const hydratedTrials = deserializeTrials(hydrated.trials, serializers);
81
- const hydratedSnapshots = hydrated.saves;
80
+ const configDefaults = options.configDefaults ?? {};
81
+ const filled = (instrumentName: string, config: unknown): unknown => {
82
+ const defaults = configDefaults[instrumentName];
83
+ return defaults ? fillConfigDefaults(config, defaults()) : config;
84
+ };
85
+
86
+ const hydratedTrials = deserializeTrials(
87
+ hydrated.trials.map((t) => ({ ...t, config: filled(t.instrumentName, t.config) })),
88
+ serializers,
89
+ );
90
+ const hydratedSnapshots = hydrated.saves.map((sn) => ({
91
+ ...sn,
92
+ config: filled(sn.instrumentName, sn.config),
93
+ }));
82
94
  const hydratedLayout = hydrated.layout;
83
95
  const hydratedMode = hydrated.mode;
84
96
 
@@ -121,15 +133,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
121
133
  scheduleFlush();
122
134
  },
123
135
 
124
- updateTrialConfig: (id, key, value) => {
136
+ updateTrialConfig: (id, path, value) => {
125
137
  set((s) => ({
126
- trials: s.trials.map((w) => {
127
- if (w.id !== id) return w;
128
- return {
129
- ...w,
130
- config: { ...(w.config as Record<string, unknown>), [key as string]: value },
131
- };
132
- }),
138
+ trials: s.trials.map((w) =>
139
+ w.id === id ? { ...w, config: withValueAtPath(w.config, path, value) } : w,
140
+ ),
133
141
  }));
134
142
  scheduleFlush();
135
143
  },
@@ -213,7 +221,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
213
221
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
214
222
  const savedAt = Math.max(Date.now(), lastAt + 1);
215
223
  const snapshot: SavedSnapshot = {
216
- id: crypto.randomUUID(),
224
+ id: newId(),
217
225
  name,
218
226
  trialId,
219
227
  instrumentName: trial.instrumentName,
@@ -237,7 +245,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
237
245
  return;
238
246
  }
239
247
  const reg = serializers[snapshot.instrumentName];
240
- const restoredState = reg?.deserialize ? reg.deserialize(snapshot.state) : snapshot.state;
248
+ const restoredState = reg?.deserialize
249
+ ? reg.deserialize(snapshot.state, snapshot.config)
250
+ : snapshot.state;
241
251
  set((s) => ({
242
252
  trials: s.trials.map((w) =>
243
253
  w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
@@ -313,11 +323,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
313
323
  // fold; the flush is what removes the legacy keys.
314
324
  if (foldedFromLegacy) scheduleFlush();
315
325
 
316
- return Object.assign(store, {
317
- registerSerializers(s: InstrumentSerializers) {
318
- serializers = s;
319
- },
320
- });
326
+ return store;
321
327
  }
322
328
 
323
329
  interface HydrateResult {
@@ -1,3 +1,4 @@
1
+ import type { ConfigPath, ValueAtPath } from '../config/types';
1
2
  import type { UndockedPanels } from './undock';
2
3
  /** A trial's undo history, as snapshots of its state either side of the
3
4
  * present. */
@@ -95,7 +96,9 @@ export interface TrialStateHandle<TS, TC> {
95
96
  state: TS;
96
97
  setState: (next: TS | ((prev: TS) => TS)) => void;
97
98
  config: TC;
98
- setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
99
+ /** Write one config value, by dotted path — `'grid.size'` for a leaf under
100
+ * an `f.group`, `'cellSize'` for one at the root. */
101
+ setConfig: <P extends ConfigPath<TC> & string>(path: P, value: ValueAtPath<TC, P>) => void;
99
102
  }
100
103
 
101
104
  /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
@@ -104,13 +107,31 @@ export interface CreateLabStoreOptions {
104
107
  storageKey: string;
105
108
  storage: StorageAdapter;
106
109
  initialMode?: LabMode;
110
+ /** Each instrument's default config, keyed by instrument name, used to fill
111
+ * the gaps in a stored one. A config saved before its schema grew a branch
112
+ * arrives holding that branch's defaults rather than `undefined`, and keeps
113
+ * whatever keys the schema has since stopped naming. `<Lab>` collects these
114
+ * off its `instruments`. */
115
+ configDefaults?: Record<string, () => unknown>;
116
+ /** How each instrument's state survives a reload. Hydration is the first
117
+ * thing `createLabStore` does, so these have to arrive with the store —
118
+ * anything registered afterwards is already too late to read the document
119
+ * it was built from. `<Lab>` collects them off its `instruments`. */
120
+ serializers?: InstrumentSerializers;
107
121
  }
108
122
 
109
123
  /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
110
- * instrument whose state is already JSON-safe needs no entry. */
124
+ * instrument whose state is already JSON-safe needs no entry. `deserialize`
125
+ * is handed the config the state was saved against — a trial's own for a
126
+ * reload, the snapshot's for a load — since a state rebuilt without it can
127
+ * disagree with the settings sitting next to it. */
111
128
  export type InstrumentSerializers = Record<
112
129
  string,
113
- { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
130
+ | {
131
+ serialize?: (state: unknown) => unknown;
132
+ deserialize?: (data: unknown, config: unknown) => unknown;
133
+ }
134
+ | undefined
114
135
  >;
115
136
 
116
137
  /** A trial as it is persisted: everything but the undo history, which is
@@ -24,6 +24,6 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
24
24
  state: record.state as TS,
25
25
  config: record.config as TC,
26
26
  setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
27
- setConfig: (key, value) => updateTrialConfig(trialId, key as never, value as never),
27
+ setConfig: (path, value) => updateTrialConfig(trialId, path, value),
28
28
  };
29
29
  }
package/src/styles.less CHANGED
@@ -2,6 +2,7 @@
2
2
  @import './theme/base.less';
3
3
  @import './lab/Workspace.less';
4
4
  @import './lab/LabShell.less';
5
+ @import './lab/LabSwitcher.less';
5
6
  @import './primitives/Toolbar.less';
6
7
  @import './primitives/Sidebar.less';
7
8
  @import './primitives/ZoomControl.less';
@@ -79,13 +79,13 @@
79
79
  &::-webkit-slider-runnable-track {
80
80
  height: var(--wzl-slider-track-h);
81
81
  border-radius: var(--wzl-radius-sm);
82
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
82
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
83
83
  }
84
84
 
85
85
  &::-moz-range-track {
86
86
  height: var(--wzl-slider-track-h);
87
87
  border-radius: var(--wzl-radius-sm);
88
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
88
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
89
89
  }
90
90
 
91
91
  &::-webkit-slider-thumb {
@@ -95,7 +95,7 @@
95
95
  height: var(--wzl-slider-thumb-size);
96
96
  border: none;
97
97
  border-radius: 50%;
98
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
98
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
99
99
  margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
100
100
  }
101
101
 
@@ -104,7 +104,7 @@
104
104
  height: var(--wzl-slider-thumb-size);
105
105
  border: none;
106
106
  border-radius: 50%;
107
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
107
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
108
108
  }
109
109
  }
110
110
  }
@@ -106,3 +106,47 @@ describe('lab-wide config seams', () => {
106
106
  expect(screen.queryByText('Settings')).not.toBeInTheDocument();
107
107
  });
108
108
  });
109
+
110
+ describe('a nested config value, end to end', () => {
111
+ const nestedInstrument = () => {
112
+ const seen: { config: unknown; prev: unknown }[] = [];
113
+ const instrument = defineInstrument({
114
+ name: 'Nested',
115
+ config: f.schema({
116
+ showGrid: f.boolean(true),
117
+ grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
118
+ }),
119
+ initialState: () => ({}),
120
+ onConfigChange: (config, prev, state) => {
121
+ seen.push({ config, prev });
122
+ return state;
123
+ },
124
+ render: (ctx) => (
125
+ <output>{String((ctx.config as { grid: { size: number } }).grid.size)}</output>
126
+ ),
127
+ });
128
+ return { instrument, seen };
129
+ };
130
+
131
+ it('renders a nested leaf and writes the change back into the tree', () => {
132
+ const { instrument } = nestedInstrument();
133
+ render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
134
+ expect(screen.getByText('20')).toBeInTheDocument();
135
+ fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
136
+ expect(screen.getByText('40')).toBeInTheDocument();
137
+ });
138
+
139
+ it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
140
+ const { instrument, seen } = nestedInstrument();
141
+ render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
142
+ fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
143
+ expect(seen.at(-1)?.config).toEqual({
144
+ showGrid: true,
145
+ grid: { size: 40, color: '#ffffff' },
146
+ });
147
+ expect(seen.at(-1)?.prev).toEqual({
148
+ showGrid: true,
149
+ grid: { size: 20, color: '#ffffff' },
150
+ });
151
+ });
152
+ });