@weasel-js/labkit 1.5.0 → 1.5.2

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 (206) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CMMo180l.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-ZTiwzYyT.d.ts +479 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
  6. package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.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-6BUQ77ZV.js +18 -0
  16. package/dist/chunk-6BUQ77ZV.js.map +1 -0
  17. package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
  18. package/dist/chunk-753Y6HE6.js.map +1 -0
  19. package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
  20. package/dist/chunk-A2ACAGW6.js.map +1 -0
  21. package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
  22. package/dist/chunk-CKUKF72R.js.map +1 -0
  23. package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
  24. package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
  25. package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
  26. package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  27. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  28. package/dist/chunk-ERFMC6WU.js.map +1 -0
  29. package/dist/chunk-FKLT3ITC.js +590 -0
  30. package/dist/chunk-FKLT3ITC.js.map +1 -0
  31. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  32. package/dist/chunk-IJJUK4FU.js.map +1 -0
  33. package/dist/{chunk-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
  34. package/dist/chunk-KJCUERQF.js.map +1 -0
  35. package/dist/chunk-LASCBWW7.js +13456 -0
  36. package/dist/chunk-LASCBWW7.js.map +1 -0
  37. package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
  38. package/dist/chunk-LRI3MBGR.js.map +1 -0
  39. package/dist/chunk-REOBGYMC.js +150 -0
  40. package/dist/chunk-REOBGYMC.js.map +1 -0
  41. package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
  42. package/dist/chunk-TT5C5S6R.js.map +1 -0
  43. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  44. package/dist/chunk-Z76KEZAX.js.map +1 -0
  45. package/dist/config/index.d.ts +62 -19
  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 +230 -62
  51. package/dist/index.js +1367 -1698
  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 +57 -230
  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 +266 -57
  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/index.ts +0 -9
  77. package/src/canvas/usePanZoom.test.ts +28 -0
  78. package/src/canvas/usePanZoom.ts +11 -4
  79. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  80. package/src/chrome/LabChrome.tsx +21 -5
  81. package/src/chrome/builtins.test.ts +1 -0
  82. package/src/chrome/builtins.tsx +1 -0
  83. package/src/chrome/index.ts +1 -0
  84. package/src/chrome/labTypes.ts +3 -3
  85. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  86. package/src/chrome/regions/PaletteRegion.tsx +6 -10
  87. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  88. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  89. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  90. package/src/chrome/types.ts +11 -2
  91. package/src/config/auto.test.ts +12 -0
  92. package/src/config/auto.ts +15 -0
  93. package/src/config/autoConfig.test.ts +107 -0
  94. package/src/config/autoConfig.ts +153 -0
  95. package/src/config/builder.test.ts +38 -0
  96. package/src/config/builder.ts +55 -11
  97. package/src/config/index.ts +3 -0
  98. package/src/config/path.ts +7 -2
  99. package/src/config/resolve.test.ts +25 -0
  100. package/src/config/resolve.ts +17 -1
  101. package/src/config/types.ts +32 -2
  102. package/src/config/useResolvedConfig.ts +26 -0
  103. package/src/controls/AutoControls.stories.tsx +88 -0
  104. package/src/controls/ControlPanel.less +23 -0
  105. package/src/controls/ControlPanel.stories.tsx +83 -1
  106. package/src/controls/ControlPanel.test.tsx +457 -4
  107. package/src/controls/ControlPanel.tsx +534 -73
  108. package/src/index.test.ts +0 -2
  109. package/src/index.ts +33 -11
  110. package/src/instrument/serializers.ts +29 -1
  111. package/src/lab/Lab.chrome.test.tsx +16 -0
  112. package/src/lab/Lab.stories.tsx +1 -1
  113. package/src/lab/Lab.surface.test.tsx +7 -2
  114. package/src/lab/Lab.test.tsx +79 -1
  115. package/src/lab/Lab.tsx +124 -16
  116. package/src/lab/LabContext.ts +8 -0
  117. package/src/lab/LabFit.stories.tsx +4 -4
  118. package/src/lab/LabFullChrome.stories.tsx +1 -1
  119. package/src/lab/LabHeader.tsx +0 -1
  120. package/src/lab/LabRoot.test.tsx +56 -0
  121. package/src/lab/LabRoot.tsx +47 -0
  122. package/src/lab/LabShell.less +24 -3
  123. package/src/lab/LabShell.stories.tsx +50 -1
  124. package/src/lab/LabShell.tsx +18 -27
  125. package/src/lab/LabSwitcher.less +10 -10
  126. package/src/lab/LabSwitcher.test.tsx +14 -9
  127. package/src/lab/LabSwitcher.tsx +8 -4
  128. package/src/lab/Workspace.less +1 -1
  129. package/src/lab/Workspace.stories.tsx +1 -1
  130. package/src/lab/Workspace.surface.test.tsx +1 -1
  131. package/src/lab/index.ts +2 -0
  132. package/src/layers/LayerList.less +1 -1
  133. package/src/layers/LayerList.stories.tsx +2 -2
  134. package/src/loupe/AGENTS.md +16 -4
  135. package/src/loupe/LoupeGestures.tsx +67 -0
  136. package/src/loupe/TrialLoupe.tsx +43 -28
  137. package/src/loupe/index.ts +4 -0
  138. package/src/loupe/loupeActions.ts +98 -0
  139. package/src/loupe/useLoupe.test.tsx +59 -2
  140. package/src/loupe/useLoupe.ts +28 -48
  141. package/src/passthrough/weasel-ui.ts +3 -3
  142. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  143. package/src/primitives/FpsMeter.less +6 -1
  144. package/src/primitives/FpsMeter.stories.tsx +1 -1
  145. package/src/primitives/FpsMeter.test.tsx +12 -4
  146. package/src/primitives/FpsMeter.tsx +6 -1
  147. package/src/primitives/JobProgress.stories.tsx +2 -2
  148. package/src/primitives/Legend.stories.tsx +1 -1
  149. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  150. package/src/primitives/Sidebar.less +7 -0
  151. package/src/primitives/Sidebar.stories.tsx +1 -1
  152. package/src/primitives/Split.test.tsx +25 -0
  153. package/src/primitives/Split.tsx +36 -20
  154. package/src/primitives/StatusBar.less +6 -0
  155. package/src/primitives/StatusBar.stories.tsx +1 -1
  156. package/src/primitives/Toolbar.stories.tsx +1 -1
  157. package/src/specimen/Specimen.less +95 -0
  158. package/src/specimen/Specimen.stories.tsx +13 -0
  159. package/src/specimen/Specimen.test.tsx +13 -0
  160. package/src/specimen/Specimen.tsx +888 -0
  161. package/src/state/Persistence.stories.tsx +4 -4
  162. package/src/state/store.test.ts +65 -0
  163. package/src/state/store.ts +32 -8
  164. package/src/state/types.ts +31 -2
  165. package/src/state/useTrialState.test.tsx +85 -0
  166. package/src/state/useTrialState.ts +9 -1
  167. package/src/state/view.test.ts +4 -4
  168. package/src/styles.less +1 -0
  169. package/src/surface/index.ts +1 -1
  170. package/src/surface/useSurfaceTile.test.tsx +1 -1
  171. package/src/surface/useTiledSurface.test.tsx +65 -10
  172. package/src/surface/useTiledSurface.ts +23 -11
  173. package/src/theme/Interstellar.stories.tsx +2 -2
  174. package/src/theme/interstellar.test.ts +16 -5
  175. package/src/theme/interstellar.theme.json +180 -0
  176. package/src/theme/interstellar.ts +8 -5
  177. package/src/tools/labTool.ts +3 -3
  178. package/src/trial/Trial.annotations.test.tsx +13 -4
  179. package/src/trial/Trial.canvas.test.tsx +36 -0
  180. package/src/trial/Trial.config.test.tsx +75 -2
  181. package/src/trial/Trial.less +4 -0
  182. package/src/trial/Trial.stories.tsx +4 -1
  183. package/src/trial/Trial.test.tsx +3 -0
  184. package/src/trial/Trial.tsx +21 -14
  185. package/src/trial/TrialChrome.tsx +17 -3
  186. package/src/trial/trialOps.test.ts +101 -1
  187. package/src/trial/trialOps.ts +62 -4
  188. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  189. package/dist/chunk-67SJLMC7.js.map +0 -1
  190. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  191. package/dist/chunk-E44SU6XS.js +0 -50
  192. package/dist/chunk-E44SU6XS.js.map +0 -1
  193. package/dist/chunk-EKIICY6X.js.map +0 -1
  194. package/dist/chunk-KJALCDWE.js.map +0 -1
  195. package/dist/chunk-NDRYLVOW.js.map +0 -1
  196. package/dist/chunk-NS64DXMN.js.map +0 -1
  197. package/dist/chunk-PGETSEDK.js.map +0 -1
  198. package/dist/chunk-QLSV2N3G.js.map +0 -1
  199. package/dist/chunk-S2SHZRD7.js +0 -7681
  200. package/dist/chunk-S2SHZRD7.js.map +0 -1
  201. package/dist/chunk-TQBBYTAF.js +0 -280
  202. package/dist/chunk-TQBBYTAF.js.map +0 -1
  203. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  204. package/src/canvas/useOrbit.test.ts +0 -167
  205. package/src/canvas/useOrbit.ts +0 -156
  206. package/src/theme/interstellar.tokens.json +0 -61
@@ -0,0 +1,180 @@
1
+ {
2
+ "name": "interstellar",
3
+ "extends": "weasel",
4
+ "pins": {
5
+ "radius-md": {
6
+ "value": "6px",
7
+ "type": "dimension"
8
+ },
9
+ "glass-blur": {
10
+ "value": "12px",
11
+ "type": "dimension"
12
+ },
13
+ "font-weight-light": {
14
+ "value": 300,
15
+ "type": "fontWeight"
16
+ },
17
+ "font-weight-medium": {
18
+ "value": 500,
19
+ "type": "fontWeight"
20
+ },
21
+ "font-weight-bold": {
22
+ "value": 700,
23
+ "type": "fontWeight"
24
+ },
25
+ "surface": {
26
+ "by": "mode",
27
+ "dark": {
28
+ "value": "#0a0a14",
29
+ "type": "color"
30
+ },
31
+ "light": {
32
+ "value": "#fafaf7",
33
+ "type": "color"
34
+ }
35
+ },
36
+ "surface-raised": {
37
+ "by": "mode",
38
+ "dark": {
39
+ "value": "rgba(20, 20, 36, 0.55)",
40
+ "type": "color"
41
+ },
42
+ "light": {
43
+ "value": "#ffffff",
44
+ "type": "color"
45
+ }
46
+ },
47
+ "surface-sunken": {
48
+ "by": "mode",
49
+ "dark": {
50
+ "value": "#02020a",
51
+ "type": "color"
52
+ },
53
+ "light": {
54
+ "value": "#f0ebe2",
55
+ "type": "color"
56
+ }
57
+ },
58
+ "border": {
59
+ "by": "mode",
60
+ "dark": {
61
+ "value": "rgba(255, 255, 255, 0.14)",
62
+ "type": "color"
63
+ },
64
+ "light": {
65
+ "value": "#c9bba5",
66
+ "type": "color"
67
+ }
68
+ },
69
+ "line-subtle": {
70
+ "by": "mode",
71
+ "dark": {
72
+ "value": "rgba(255, 255, 255, 0.08)",
73
+ "type": "color"
74
+ },
75
+ "light": {
76
+ "value": "rgba(0, 0, 0, 0.08)",
77
+ "type": "color"
78
+ }
79
+ },
80
+ "fg": {
81
+ "by": "mode",
82
+ "dark": {
83
+ "value": "#e8e8f0",
84
+ "type": "color"
85
+ },
86
+ "light": {
87
+ "value": "#2a2018",
88
+ "type": "color"
89
+ }
90
+ },
91
+ "fg-muted": {
92
+ "by": "mode",
93
+ "dark": {
94
+ "value": "#a8a8c0",
95
+ "type": "color"
96
+ },
97
+ "light": {
98
+ "value": "#6b5c4a",
99
+ "type": "color"
100
+ }
101
+ },
102
+ "fg-subtle": {
103
+ "by": "mode",
104
+ "dark": {
105
+ "value": "#5a5a78",
106
+ "type": "color"
107
+ },
108
+ "light": {
109
+ "value": "#a89b85",
110
+ "type": "color"
111
+ }
112
+ },
113
+ "accent": {
114
+ "by": "mode",
115
+ "dark": {
116
+ "value": "#b08adb",
117
+ "type": "color"
118
+ },
119
+ "light": {
120
+ "value": "#a86f3c",
121
+ "type": "color"
122
+ }
123
+ },
124
+ "accent-hover": {
125
+ "by": "mode",
126
+ "dark": {
127
+ "value": "#c6a3ee",
128
+ "type": "color"
129
+ },
130
+ "light": {
131
+ "value": "#c2854f",
132
+ "type": "color"
133
+ }
134
+ },
135
+ "focus-ring": {
136
+ "by": "mode",
137
+ "dark": {
138
+ "value": "rgba(176, 138, 219, 0.45)",
139
+ "type": "color"
140
+ },
141
+ "light": {
142
+ "value": "rgba(168, 111, 60, 0.35)",
143
+ "type": "color"
144
+ }
145
+ },
146
+ "shadow": {
147
+ "by": "mode",
148
+ "dark": {
149
+ "value": "rgba(0, 0, 0, 0.7)",
150
+ "type": "color"
151
+ },
152
+ "light": {
153
+ "value": "rgba(80, 60, 40, 0.18)",
154
+ "type": "color"
155
+ }
156
+ },
157
+ "border-strong": {
158
+ "by": "mode",
159
+ "dark": {
160
+ "value": "rgba(255, 255, 255, 0.36)",
161
+ "type": "color"
162
+ },
163
+ "light": {
164
+ "value": "#9e8b71",
165
+ "type": "color"
166
+ }
167
+ },
168
+ "backdrop": {
169
+ "by": "mode",
170
+ "dark": {
171
+ "value": "radial-gradient(ellipse 60% 80% at 18% 30%, rgba(150, 80, 200, 0.22), transparent 70%), radial-gradient(ellipse 55% 90% at 78% 70%, rgba(220, 100, 140, 0.18), transparent 65%), radial-gradient(ellipse 50% 70% at 50% 50%, rgba(60, 110, 200, 0.18), transparent 75%), radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)",
172
+ "type": "gradient"
173
+ },
174
+ "light": {
175
+ "value": "none",
176
+ "type": "gradient"
177
+ }
178
+ }
179
+ }
180
+ }
@@ -1,9 +1,12 @@
1
- import { loadDTCG, type Theme } from '@weasel-js/theme';
2
- import doc from './interstellar.tokens.json' with { type: 'json' };
1
+ import { defineTheme, type Theme, type ThemeInput, weaselTheme } from '@weasel-js/theme';
2
+ import definition from './interstellar.theme.json' with { type: 'json' };
3
3
 
4
4
  /**
5
5
  * labkit's theme: a cosmic dark and a warm parchment light, extending the
6
- * built-in weasel theme. Authored as DTCG and loaded at import time — the
7
- * interchange path a theme exported from a design tool would take.
6
+ * built-in weasel theme. A pins-only theme definition: nothing in it is
7
+ * derived, so it loads without the engine.
8
8
  */
9
- export const interstellarTheme: Theme = loadDTCG(doc);
9
+ export const interstellarTheme: Theme = defineTheme({
10
+ ...definition,
11
+ extends: weaselTheme,
12
+ } as ThemeInput);
@@ -2,10 +2,10 @@ import type { InstrumentList } from '../instrument/types';
2
2
 
3
3
  /**
4
4
  * The tool the lab's slot holds, once its default is applied: a lab with any
5
- * annotating instrument starts in `select`, so a first click on a picture
6
- * selects rather than draws.
5
+ * annotating instrument starts in `pointer`, so a first click reaches the
6
+ * instrument rather than the marks over it.
7
7
  */
8
8
  export function resolveLabTool(slot: string | null, instruments: InstrumentList): string | null {
9
9
  if (slot !== null) return slot;
10
- return instruments.some((i) => i.annotations != null) ? 'select' : null;
10
+ return instruments.some((i) => i.annotations != null) ? 'pointer' : null;
11
11
  }
@@ -55,7 +55,16 @@ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
55
55
  describe('an instrument that declares annotations', () => {
56
56
  it('gets a drawing palette', () => {
57
57
  render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
58
- for (const label of ['Select', 'Freehand', 'Line', 'Arrow', 'Rectangle', 'Ellipse', 'Text']) {
58
+ for (const label of [
59
+ 'Interact',
60
+ 'Select',
61
+ 'Freehand',
62
+ 'Line',
63
+ 'Arrow',
64
+ 'Rectangle',
65
+ 'Ellipse',
66
+ 'Text',
67
+ ]) {
59
68
  expect(screen.getByRole('button', { name: label })).toBeTruthy();
60
69
  }
61
70
  });
@@ -65,9 +74,9 @@ describe('an instrument that declares annotations', () => {
65
74
  expect(screen.getByTestId('pane').dataset.marks).toBe('0');
66
75
  });
67
76
 
68
- it('starts in select, so a first click does not draw', () => {
77
+ it('starts in interact, so a first click reaches the instrument', () => {
69
78
  render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
70
- expect(screen.getByRole('button', { name: 'Select' }).getAttribute('aria-current')).toBe(
79
+ expect(screen.getByRole('button', { name: 'Interact' }).getAttribute('aria-current')).toBe(
71
80
  'true',
72
81
  );
73
82
  });
@@ -106,7 +115,7 @@ describe('the annotation tool', () => {
106
115
  act(() => labRef?.addTrial('Annotating'));
107
116
  const panes = screen.getAllByTestId('pane');
108
117
  expect(panes).toHaveLength(2);
109
- for (const p of panes) expect(p.dataset.tool).toBe('select');
118
+ for (const p of panes) expect(p.dataset.tool).toBe('pointer');
110
119
 
111
120
  act(() => {
112
121
  fireEvent.click(screen.getByRole('button', { name: 'Rectangle' }));
@@ -1,7 +1,10 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
+ import { auto } from '../config/auto';
5
+ import { f } from '../config/builder';
4
6
  import { defineInstrument } from '../instrument/defineInstrument';
7
+ import type { Instrument } from '../instrument/types';
5
8
  import { Lab } from '../lab/Lab';
6
9
 
7
10
  interface St {
@@ -74,6 +77,39 @@ describe('an instrument that draws', () => {
74
77
  });
75
78
  });
76
79
 
80
+ describe('an auto config path', () => {
81
+ it('draws with the resolved value, which is what the panel shows too', async () => {
82
+ const draw = vi.fn();
83
+ const instrument = defineInstrument({
84
+ name: 'Auto',
85
+ config: f.schema({
86
+ gap: f
87
+ .number(12)
88
+ .auto(() => 18)
89
+ .range(0, 48)
90
+ .initial(auto),
91
+ }),
92
+ initialState: () => ({ n: 0 }),
93
+ render: () => null,
94
+ canvas: { layers: [{ id: 'main', draw }] },
95
+ });
96
+ const { container } = render(
97
+ <Lab instruments={[instrument as Instrument]} defaultInstrument="Auto" />,
98
+ );
99
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
100
+
101
+ expect(draw).toHaveBeenCalledWith(
102
+ expect.anything(),
103
+ expect.objectContaining({ config: { gap: 18 } }),
104
+ );
105
+ // The panel is handed the raw config, but an auto row holds what the
106
+ // resolver decided, so pinning it keeps the number the instrument drew.
107
+ // The row hides its control while auto, which takes it out of the
108
+ // accessibility tree — so this reads the element rather than the role.
109
+ expect(container.querySelector('input[type=range]')).toHaveValue('18');
110
+ });
111
+ });
112
+
77
113
  describe('LabProps.instruments', () => {
78
114
  it('accepts a typed instrument with no cast', () => {
79
115
  // The assignment is the assertion: this file fails to compile if
@@ -1,5 +1,6 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it } from 'vitest';
3
+ import { auto } from '../config/auto';
3
4
  import { f } from '../config/builder';
4
5
  import type { ConfigRule, ControlRenderer } from '../config/types';
5
6
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -132,14 +133,18 @@ describe('a nested config value, end to end', () => {
132
133
  const { instrument } = nestedInstrument();
133
134
  render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
134
135
  expect(screen.getByText('20')).toBeInTheDocument();
135
- fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
136
+ fireEvent.change(screen.getByLabelText('Cell size'), {
137
+ target: { value: '40' },
138
+ });
136
139
  expect(screen.getByText('40')).toBeInTheDocument();
137
140
  });
138
141
 
139
142
  it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
140
143
  const { instrument, seen } = nestedInstrument();
141
144
  render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
142
- fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
145
+ fireEvent.change(screen.getByLabelText('Cell size'), {
146
+ target: { value: '40' },
147
+ });
143
148
  expect(seen.at(-1)?.config).toEqual({
144
149
  showGrid: true,
145
150
  grid: { size: 40, color: '#ffffff' },
@@ -150,3 +155,71 @@ describe('a nested config value, end to end', () => {
150
155
  });
151
156
  });
152
157
  });
158
+
159
+ describe('a config written as auto', () => {
160
+ const unpinnable = () => {
161
+ const seen: { config: unknown; prev: unknown }[] = [];
162
+ const instrument = defineInstrument({
163
+ name: 'Unpinnable',
164
+ config: f.schema({
165
+ width: f.number(432),
166
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
167
+ }),
168
+ initialState: () => ({ gap: 12 }),
169
+ onConfigChange: (config, prev, state) => {
170
+ seen.push({ config, prev });
171
+ return { ...state, gap: (config as { gap: number }).gap };
172
+ },
173
+ chrome: [
174
+ {
175
+ id: 'unpin-gap',
176
+ region: 'toolbar' as const,
177
+ render: (ctx) => (
178
+ <button type="button" onClick={() => ctx.setConfig('gap', auto)}>
179
+ unpin gap
180
+ </button>
181
+ ),
182
+ },
183
+ ],
184
+ render: (ctx) => <output>{String((ctx.state as { gap: number }).gap)}</output>,
185
+ });
186
+ return { instrument, seen };
187
+ };
188
+
189
+ it('hands onConfigChange the resolved config, never the sentinel', () => {
190
+ const { instrument, seen } = unpinnable();
191
+ render(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
192
+ fireEvent.click(screen.getByText('unpin gap'));
193
+ expect(seen.at(-1)?.config).toEqual({ width: 432, gap: 18 });
194
+ expect(seen.at(-1)?.prev).toEqual({ width: 432, gap: 12 });
195
+ });
196
+
197
+ it('keeps the state that write derived free of the sentinel', () => {
198
+ const { instrument } = unpinnable();
199
+ render(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
200
+ fireEvent.click(screen.getByText('unpin gap'));
201
+ expect(screen.getByText('18')).toBeInTheDocument();
202
+ });
203
+
204
+ it("shows the trial's unpinned path in the settings panel", () => {
205
+ const instrument = defineInstrument({
206
+ name: 'Ghosted',
207
+ config: f.schema({
208
+ width: f.number(432),
209
+ gap: f
210
+ .number(12)
211
+ .initial(auto)
212
+ .auto((c) => (c.width as number) / 24)
213
+ .range(0, 48),
214
+ }),
215
+ initialState: () => ({}),
216
+ render: () => null,
217
+ });
218
+ const { container } = render(<Lab instruments={[instrument]} defaultInstrument="Ghosted" />);
219
+ expect(screen.getByRole('button', { name: /Gap/ })).toHaveAttribute('aria-pressed', 'false');
220
+ expect(screen.getByRole('button', { name: /Width/ })).toHaveAttribute('aria-pressed', 'true');
221
+ // 432 / 24. An auto row holds what the resolver decided rather than
222
+ // the pinned 12 underneath it, and un-pinning writes back what it drew.
223
+ expect((container.querySelector('input[type=range]') as HTMLInputElement).value).toBe('18');
224
+ });
225
+ });
@@ -14,6 +14,10 @@
14
14
  box-shadow: 0 1px 3px var(--wzl-shadow);
15
15
  overflow: hidden;
16
16
 
17
+ &[data-focused='true'] {
18
+ border-color: var(--wzl-accent);
19
+ }
20
+
17
21
  &__body {
18
22
  display: flex;
19
23
  flex: 1;
@@ -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 { Instrument } from '../instrument/types';
3
3
  import { LabContext, type LabContextValue } from '../lab/LabContext';
4
4
  import { LabStoreContext } from '../state/context';
@@ -35,6 +35,9 @@ function Harness() {
35
35
  closeTrial: noop,
36
36
  resetTrial: noop,
37
37
  reorderTrials: noop,
38
+ swapTrial: noop,
39
+ focusedTrialId: record.id,
40
+ focusTrial: noop,
38
41
  savedSnapshots: [],
39
42
  saveSnapshot: noop,
40
43
  loadSnapshot: noop,
@@ -62,6 +62,9 @@ function ChromeHarness({
62
62
  closeTrial: vi.fn(),
63
63
  resetTrial: vi.fn(),
64
64
  reorderTrials: vi.fn(),
65
+ swapTrial: vi.fn(),
66
+ focusedTrialId: stubRecord.id,
67
+ focusTrial: vi.fn(),
65
68
  savedSnapshots: [],
66
69
  saveSnapshot: vi.fn(),
67
70
  loadSnapshot: vi.fn(),
@@ -11,6 +11,8 @@ import { fitStage, Stage } from '../canvas/Stage';
11
11
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
12
12
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
13
13
  import type { TrialContribution } from '../chrome/types';
14
+ import { useConfigSchema } from '../config/useConfigSchema';
15
+ import { useResolvedConfig } from '../config/useResolvedConfig';
14
16
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
15
17
  import { Palette } from '../dragdrop/Palette';
16
18
  import type {
@@ -143,6 +145,11 @@ function TrialRuntime({
143
145
  const setLabTool = useStore(store, (s) => s.setLabTool);
144
146
  const setTrialTool = useStore(store, (s) => s.setTrialTool);
145
147
 
148
+ // Everything below reads `config`; `record.config` is the panel's view, and
149
+ // reaches it through `record` on `TrialChrome`.
150
+ const schema = useConfigSchema(instrument);
151
+ const { config } = useResolvedConfig(schema, record.config, record.auto);
152
+
146
153
  const busRef = useRef<EventBus | null>(null);
147
154
  if (busRef.current === null) busRef.current = createEventBus();
148
155
  const bus = busRef.current;
@@ -193,15 +200,15 @@ function TrialRuntime({
193
200
  const targetsRef = useRef<() => readonly AnnotationTargetInfo[]>(() => []);
194
201
  targetsRef.current = () =>
195
202
  annotationsCap
196
- ? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
203
+ ? annotationsCap.targets(record.state, config, { id: record.id, view: record.view })
197
204
  : [];
198
205
 
199
206
  // One store for the trial's lifetime.
200
207
  const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
201
208
  // Read through refs for the same reason `targets` is: the store is built
202
209
  // once, and an export must draw against the config the trial holds now.
203
- const configRef = useRef<unknown>(record.config);
204
- configRef.current = record.config;
210
+ const configRef = useRef<unknown>(config);
211
+ configRef.current = config;
205
212
  // The instrument can be swapped under a live trial, so the capability is read at use too.
206
213
  const capRef = useRef(annotationsCap);
207
214
  capRef.current = annotationsCap;
@@ -289,14 +296,14 @@ function TrialRuntime({
289
296
  // this — with a runner that yields nothing and is never started.
290
297
  const job = useJob({
291
298
  capability: jobCap ?? { run: async function* () {}, onItem: (_i, st) => st },
292
- config: record.config,
299
+ config,
293
300
  state: record.state,
294
301
  setState: (next) => updateTrialState(record.id, next as never),
295
302
  });
296
303
 
297
304
  const renderCtx: RenderContext<unknown, unknown> = {
298
305
  state: record.state,
299
- config: record.config,
306
+ config,
300
307
  setState: (next) => {
301
308
  snapshotIfNeeded('state.change');
302
309
  updateTrialState(record.id, next);
@@ -365,8 +372,8 @@ function TrialRuntime({
365
372
  const loupeCap = useMemo(() => {
366
373
  const declared = instrument.loupe;
367
374
  if (declared == null) return null;
368
- return resolveLoupe(typeof declared === 'function' ? declared(record.config) : declared);
369
- }, [instrument.loupe, record.config]);
375
+ return resolveLoupe(typeof declared === 'function' ? declared(config) : declared);
376
+ }, [instrument.loupe, config]);
370
377
  const loupeBindings: LoupeBindings | undefined = loupeCap
371
378
  ? { on: loupeOn, toggle: () => setLoupeOn((v) => !v) }
372
379
  : undefined;
@@ -384,10 +391,10 @@ function TrialRuntime({
384
391
  // Camera applied here, so a layer draws in world coordinates. `zoom`
385
392
  // stays in the args for line widths, which must not scale with it.
386
393
  applyCamera(ctx, view, frame);
387
- layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom });
394
+ layer.draw(ctx, { state: record.state, config, zoom: view.zoom });
388
395
  },
389
396
  }));
390
- }, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
397
+ }, [instrument.canvas, record.state, config, layerVisibility, layerOrder]);
391
398
 
392
399
  const layerDescriptors: LayerDescriptor[] = useMemo(() => {
393
400
  if (!instrument.layers) return [];
@@ -400,7 +407,7 @@ function TrialRuntime({
400
407
  view: view2d ?? DEFAULT_VIEW,
401
408
  worldSpec: instrument.canvas?.worldSpec,
402
409
  state: record.state,
403
- config: record.config,
410
+ config,
404
411
  setState: (next) => {
405
412
  snapshotIfNeeded('canvas.itemAdded');
406
413
  updateTrialState(record.id, next as never);
@@ -414,8 +421,8 @@ function TrialRuntime({
414
421
  const paletteItems: PaletteItem[] = useMemo(() => {
415
422
  if (!instrument.dragDrop) return [];
416
423
  const p = instrument.dragDrop.palette;
417
- return typeof p === 'function' ? p(record.state, record.config) : p;
418
- }, [instrument.dragDrop, record.state, record.config]);
424
+ return typeof p === 'function' ? p(record.state, config) : p;
425
+ }, [instrument.dragDrop, record.state, config]);
419
426
 
420
427
  const layersWithFeedback: CanvasLayerDescriptor[] = useMemo(() => {
421
428
  if (!dragDropResult.drag?.feedback) return canvasLayers;
@@ -449,7 +456,7 @@ function TrialRuntime({
449
456
  capability={loupeCap}
450
457
  enabled={loupeOn}
451
458
  state={record.state}
452
- config={record.config}
459
+ config={config}
453
460
  view={view2d ?? DEFAULT_VIEW}
454
461
  worldSpec={instrument.canvas?.worldSpec}
455
462
  hostRef={loupeHostRef}
@@ -553,7 +560,7 @@ function TrialRuntime({
553
560
  <AnnotationTargets
554
561
  capability={annotationsCap}
555
562
  state={record.state}
556
- config={record.config}
563
+ config={config}
557
564
  trial={{ id: record.id, view: record.view }}
558
565
  annotations={annotations}
559
566
  activeToolId={labTool}
@@ -9,8 +9,10 @@ import { TitleBarRegion } from '../chrome/regions/TitleBarRegion';
9
9
  import { ToolbarRegion } from '../chrome/regions/ToolbarRegion';
10
10
  import { ViewportRegion } from '../chrome/regions/ViewportRegion';
11
11
  import type { TrialChromeContext, TrialContribution, TrialRegion } from '../chrome/types';
12
- import { hasConfigPath, schemaNodeAtPath, withValueAtPath } from '../config/path';
12
+ import { applyConfigWrite, resolveAutoConfig } from '../config/autoConfig';
13
+ import { hasConfigPath, schemaNodeAtPath } from '../config/path';
13
14
  import { useConfigSchema } from '../config/useConfigSchema';
15
+ import { useResolvedConfig } from '../config/useResolvedConfig';
14
16
  import type { Instrument } from '../instrument/types';
15
17
  import type { JobHandle } from '../job/types';
16
18
  import { useLabContext } from '../lab/LabContext';
@@ -110,6 +112,13 @@ export function TrialChrome({
110
112
  const view2d = as2DView(record.view);
111
113
 
112
114
  const configSchema = useConfigSchema(instrument);
115
+ // What the instrument is looking at right now. The panel reads `record.config`
116
+ // — the pinned values — but anything instrument-facing gets the resolved one.
117
+ const { config: resolvedConfig, auto } = useResolvedConfig(
118
+ configSchema,
119
+ record.config,
120
+ record.auto,
121
+ );
113
122
 
114
123
  const title = record.title ?? instrument.title ?? instrument.name;
115
124
  const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
@@ -136,6 +145,7 @@ export function TrialChrome({
136
145
  configSchema,
137
146
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
138
147
  config: record.config,
148
+ auto,
139
149
  setConfig: (path, value) => {
140
150
  const prevConfig = record.config as Record<string, unknown>;
141
151
  if (
@@ -149,8 +159,9 @@ export function TrialChrome({
149
159
  }
150
160
  updateTrialConfig(trialId, path, value);
151
161
  if (instrument.onConfigChange) {
152
- const nextConfig = withValueAtPath(prevConfig, path, value);
153
- const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
162
+ const next = applyConfigWrite(prevConfig, record.auto ?? [], path, value);
163
+ const nextConfig = resolveAutoConfig(configSchema, next.config, new Set(next.autoPaths));
164
+ const nextState = instrument.onConfigChange(nextConfig, resolvedConfig, record.state);
154
165
  updateTrialState(trialId, nextState as never);
155
166
  }
156
167
  },
@@ -184,6 +195,8 @@ export function TrialChrome({
184
195
  activeToolId,
185
196
  setActiveTool,
186
197
  configSchema,
198
+ resolvedConfig,
199
+ auto,
187
200
  undockedIds,
188
201
  undockPanelAction,
189
202
  dockPanelAction,
@@ -226,6 +239,7 @@ export function TrialChrome({
226
239
  <section
227
240
  className="lk-trial"
228
241
  data-trial-id={trialId}
242
+ data-focused={lab.trials.length > 1 && lab.focusedTrialId === trialId ? 'true' : undefined}
229
243
  aria-label={`Trial ${title}`}
230
244
  tabIndex={-1}
231
245
  onKeyDown={handleKeyDown}