@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
@@ -6,7 +6,9 @@ import {
6
6
  NumberRow,
7
7
  type PrefGroup,
8
8
  type PrefLeaf,
9
+ type PrefNumber,
9
10
  type PrefNumberFormat,
11
+ type PrefNumberUnit,
10
12
  type PropertyAlign,
11
13
  type PropertyDensity,
12
14
  PropertyGroup,
@@ -14,12 +16,18 @@ import {
14
16
  type PropertyListPack,
15
17
  PropertyRow,
16
18
  type PropertyRowLayout,
19
+ prefDisplayBounds,
20
+ Select,
17
21
  SelectRow,
18
22
  SliderRow,
23
+ Switch,
24
+ SwitchRow,
19
25
  TextRow,
20
26
  ToggleRow,
21
27
  } from '@weasel-js/ui';
22
28
  import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
29
+ import { auto as autoValue } from '../config/auto';
30
+ import { resolveAutoConfig } from '../config/autoConfig';
23
31
  import { fromConfigFields } from '../config/fromConfigField';
24
32
  import { schemaNodeAtPath, valueAtPath } from '../config/path';
25
33
  import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
@@ -36,6 +44,24 @@ import type { ConfigField } from './types';
36
44
  */
37
45
  export type ControlPack = 'auto' | 'pairs' | 'one-up';
38
46
 
47
+ /** How the rows under one heading are laid out: the panel's, or a section's own. */
48
+ interface Rows {
49
+ pack: ControlPack;
50
+ layout: PropertyRowLayout | undefined;
51
+ grid: PropertyListPack;
52
+ }
53
+
54
+ const gridOf = (pack: ControlPack): PropertyListPack =>
55
+ pack === 'one-up' ? 'auto-color' : 'pairs';
56
+
57
+ function sectionRows(section: SectionSpec, rows: Rows): Rows {
58
+ return {
59
+ pack: section.pack ?? rows.pack,
60
+ layout: section.layout ?? rows.layout,
61
+ grid: section.pack ? gridOf(section.pack) : rows.grid,
62
+ };
63
+ }
64
+
39
65
  export interface ControlPanelProps<TC extends Record<string, unknown>> {
40
66
  /** The instrument's resolved config schema. */
41
67
  schema?: ResolvedConfig;
@@ -80,11 +106,19 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
80
106
  /** A fold moved. The key is a section's label — prefixed by its group's
81
107
  * dotted path when the section sits inside one — or a group's own path. */
82
108
  onCollapse?: (key: string, collapsed: boolean) => void;
109
+ /** Dotted paths currently unpinned. A row in this set draws ghosted and its
110
+ * dot reads as auto, and the dot writes the sentinel back through
111
+ * `setConfig`. Omitted, the panel keeps the set itself: the dots still work
112
+ * and the sentinel never reaches `setConfig`, which would otherwise store it
113
+ * as the row's value. */
114
+ auto?: ReadonlySet<string>;
83
115
  /** Draw leaves marked `hidden`. */
84
116
  showHidden?: boolean;
85
117
  className?: string;
86
118
  }
87
119
 
120
+ const NO_AUTO: ReadonlySet<string> = new Set();
121
+
88
122
  /** Render an instrument's config schema as a stack of controls, each writing
89
123
  * back through `setConfig`. Built on the property rows, so a lab's controls
90
124
  * are the same aligned, themed rows the rest of the kit uses. */
@@ -101,12 +135,64 @@ export function ControlPanel<TC extends Record<string, unknown>>({
101
135
  collapse,
102
136
  collapsed,
103
137
  onCollapse,
138
+ auto: given,
104
139
  showHidden = false,
105
140
  className,
106
141
  }: ControlPanelProps<TC>) {
107
142
  const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
108
143
 
109
- const gridPack: PropertyListPack = pack === 'one-up' ? 'auto-color' : 'pairs';
144
+ // An owner that tracks the set decides what a dot means; one that does not
145
+ // gets a panel that decides for itself, because the alternative is handing it
146
+ // a sentinel it would write into the config as though it were a value.
147
+ const [ownAuto, setOwnAuto] = useState<ReadonlySet<string>>(NO_AUTO);
148
+ const auto = given ?? ownAuto;
149
+ const setRowAuto = (path: string, next: boolean, value: unknown): void => {
150
+ if (given) {
151
+ setConfig(path, next ? autoValue : value);
152
+ return;
153
+ }
154
+ setOwnAuto((prev) => {
155
+ const now = new Set(prev);
156
+ if (next) now.add(path);
157
+ else now.delete(path);
158
+ return now;
159
+ });
160
+ // Pinning keeps whatever the row was showing, the same value the controlled
161
+ // path writes back.
162
+ if (!next) setConfig(path, value);
163
+ };
164
+
165
+ // What the instrument is reading. A row draws and reports from this rather
166
+ // than calling its own resolver against the raw config, which would disagree
167
+ // with the instrument whenever a resolver depends on another auto path.
168
+ const shown = useMemo(
169
+ () => (auto && auto.size > 0 ? resolveAutoConfig(resolved, config, auto) : config),
170
+ [resolved, config, auto],
171
+ );
172
+
173
+ const listRef = useRef<HTMLDivElement>(null);
174
+ const toggles = useRef(new Map<string, () => void>());
175
+ // Capture phase, because `PropertyRow` is a <label>: a bubbled handler runs
176
+ // after the browser has already begun a range drag or a native control's
177
+ // activation, so the shift-click moves the very value it was meant to unpin.
178
+ useEffect(() => {
179
+ const host = listRef.current;
180
+ if (!host) return;
181
+ const onDown = (e: PointerEvent) => {
182
+ if (!e.shiftKey) return;
183
+ const row = (e.target as HTMLElement | null)?.closest('[data-auto-path]');
184
+ const path = row?.getAttribute('data-auto-path');
185
+ const toggle = path ? toggles.current.get(path) : undefined;
186
+ if (!toggle) return;
187
+ e.preventDefault();
188
+ e.stopPropagation();
189
+ toggle();
190
+ };
191
+ host.addEventListener('pointerdown', onDown, true);
192
+ return () => host.removeEventListener('pointerdown', onDown, true);
193
+ }, []);
194
+
195
+ const gridPack = gridOf(pack);
110
196
  // A section that declares how it opens is foldable whether or not the lab
111
197
  // asked for folds — there is nothing else for the declaration to mean.
112
198
  const folds =
@@ -116,8 +202,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
116
202
  resolved.sections.some((s) => s.collapsed !== undefined);
117
203
  const startsFolded = collapse === 'closed';
118
204
 
119
- const fold = (key: string, declared?: boolean) => ({
120
- pack: gridPack,
205
+ const fold = (key: string, declared?: boolean, grid: PropertyListPack = gridPack) => ({
206
+ pack: grid,
121
207
  collapsible: folds,
122
208
  defaultCollapsed: declared ?? startsFolded,
123
209
  collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
@@ -125,7 +211,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
125
211
  });
126
212
 
127
213
  /** One node, which is either a group to recurse into or a row to draw. */
128
- const node = (path: string): ReactNode => {
214
+ const node = (path: string, rows: Rows): ReactNode => {
129
215
  const found = schemaNodeAtPath(resolved.group, path);
130
216
  if (!found) return null;
131
217
  if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
@@ -137,53 +223,117 @@ export function ControlPanel<TC extends Record<string, unknown>>({
137
223
  leaf={found}
138
224
  resolved={resolved}
139
225
  config={config}
226
+ shown={shown}
140
227
  setConfig={setConfig}
141
228
  renderers={renderers}
142
- pack={pack}
143
- layout={layout}
229
+ pack={rows.pack}
230
+ layout={rows.layout}
231
+ auto={auto}
232
+ setRowAuto={setRowAuto}
233
+ toggles={toggles.current}
144
234
  />
145
235
  );
146
236
  }
147
237
  // A group with no name organizes without heading it — core's rule for an
148
238
  // empty `PrefGroup.name` — so it contributes its rows and no chrome.
149
- if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
239
+ if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
150
240
  return (
151
- <PropertyGroup key={path} title={found.name} {...fold(path)}>
152
- {body(found, path)}
241
+ <PropertyGroup key={path} title={found.name} {...fold(path, undefined, rows.grid)}>
242
+ {body(found, path, rows)}
153
243
  </PropertyGroup>
154
244
  );
155
245
  };
156
246
 
247
+ /**
248
+ * The leaf at `path` if it may share a row with its neighbours: `pair` is
249
+ * presentational, so a leaf whose control the lab draws itself, or one whose
250
+ * control needs the whole row, keeps its own row instead.
251
+ */
252
+ const pairable = (path: string): { leaf: PrefLeaf; pair: string } | undefined => {
253
+ const found = schemaNodeAtPath(resolved.group, path);
254
+ if (!found || !isPrefLeaf(found)) return undefined;
255
+ const pair = extra<string>(found, 'pair');
256
+ if (pair === undefined) return undefined;
257
+ if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden))
258
+ return undefined;
259
+ if (renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[found.kind]) return undefined;
260
+ if (!isBuiltinToolPref(found)) return undefined;
261
+ if (found.kind === 'paint' || found.kind === 'object' || isSliderLeaf(found)) return undefined;
262
+ return { leaf: found, pair };
263
+ };
264
+
265
+ /** A run of sibling paths as rows, merging each run of adjacent leaves
266
+ * sharing a `pair` id into one row named by the pair. */
267
+ const rowsFor = (paths: readonly string[], rows: Rows): ReactNode[] => {
268
+ const out: ReactNode[] = [];
269
+ for (let i = 0; i < paths.length; ) {
270
+ const head = pairable(paths[i]);
271
+ if (!head) {
272
+ out.push(node(paths[i], rows));
273
+ i += 1;
274
+ continue;
275
+ }
276
+ const { pair } = head;
277
+ const cells = [{ path: paths[i], leaf: head.leaf }];
278
+ let j = i + 1;
279
+ for (; j < paths.length; j += 1) {
280
+ const next = pairable(paths[j]);
281
+ if (!next || next.pair !== pair) break;
282
+ cells.push({ path: paths[j], leaf: next.leaf });
283
+ }
284
+ out.push(
285
+ <PairedRow
286
+ key={paths[i]}
287
+ label={pair}
288
+ cells={cells}
289
+ shown={shown}
290
+ setConfig={setConfig}
291
+ layout={rows.layout}
292
+ />,
293
+ );
294
+ i = j;
295
+ }
296
+ return out;
297
+ };
298
+
157
299
  /** One group's children: its loose nodes, then its sections. */
158
- const body = (group: PrefGroup, at: string): ReactNode => {
300
+ const body = (group: PrefGroup, at: string, rows: Rows): ReactNode => {
159
301
  const sections = resolved.sections.filter((s) => s.at === at);
160
302
  const sectioned = new Set(sections.flatMap((s) => s.paths));
161
303
  const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
162
304
  return (
163
305
  <>
164
- {paths.filter((p) => !sectioned.has(p)).map(node)}
165
- {sections.map((section) => (
166
- <PropertyGroup
167
- key={sectionKey(section)}
168
- title={section.label}
169
- {...fold(sectionKey(section), section.collapsed)}
170
- >
171
- {section.paths.map(node)}
172
- </PropertyGroup>
173
- ))}
306
+ {rowsFor(
307
+ paths.filter((p) => !sectioned.has(p)),
308
+ rows,
309
+ )}
310
+ {sections.map((section) => {
311
+ const inner = sectionRows(section, rows);
312
+ return (
313
+ <PropertyGroup
314
+ key={sectionKey(section)}
315
+ title={section.label}
316
+ {...fold(sectionKey(section), section.collapsed, inner.grid)}
317
+ >
318
+ {rowsFor(section.paths, inner)}
319
+ </PropertyGroup>
320
+ );
321
+ })}
174
322
  </>
175
323
  );
176
324
  };
177
325
 
178
326
  return (
179
- <PropertyList
180
- pack={gridPack}
181
- density={density}
182
- align={align}
183
- className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
184
- >
185
- {body(resolved.group, '')}
186
- </PropertyList>
327
+ <div ref={listRef}>
328
+ <PropertyList
329
+ pack={gridPack}
330
+ density={density}
331
+ align={align}
332
+ className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
333
+ >
334
+ {body(resolved.group, '', { pack, layout, grid: gridPack })}
335
+ </PropertyList>
336
+ </div>
187
337
  );
188
338
  }
189
339
 
@@ -199,10 +349,27 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
199
349
  leaf: PrefLeaf;
200
350
  resolved: ResolvedConfig;
201
351
  config: TC;
352
+ /** `config` with every auto path resolved -- what the instrument reads. */
353
+ shown: TC;
202
354
  setConfig: (path: string, value: unknown) => void;
203
355
  renderers?: Record<string, ControlRenderer>;
204
356
  pack: ControlPack;
205
357
  layout?: PropertyRowLayout;
358
+ auto?: ReadonlySet<string>;
359
+ setRowAuto: (path: string, next: boolean, value: unknown) => void;
360
+ toggles: Map<string, () => void>;
361
+ }
362
+
363
+ /** Whether this leaf draws as a slider, mirroring the condition the `number`
364
+ * arm below branches on. A slider is the one control whose value cannot be
365
+ * read off the control itself. */
366
+ function isSliderLeaf(leaf: PrefLeaf): boolean {
367
+ return (
368
+ leaf.kind === 'number' &&
369
+ extra<string>(leaf, 'control') === 'slider' &&
370
+ extra<number>(leaf, 'min') !== undefined &&
371
+ extra<number>(leaf, 'max') !== undefined
372
+ );
206
373
  }
207
374
 
208
375
  /** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
@@ -216,21 +383,62 @@ function ControlRow<TC extends Record<string, unknown>>({
216
383
  leaf,
217
384
  resolved,
218
385
  config,
386
+ shown,
219
387
  setConfig,
220
388
  renderers,
221
389
  pack,
222
390
  layout,
391
+ auto,
392
+ setRowAuto,
393
+ toggles,
223
394
  }: ControlRowProps<TC>) {
224
395
  const write = (value: unknown): void => setConfig(path, value);
225
396
  const fallback = extra<unknown>(leaf, 'default');
226
- const value = valueAtPath(config, path) ?? fallback;
397
+ const pinned = valueAtPath(config, path) ?? fallback;
398
+
399
+ const isAutoRow = auto?.has(path) ?? false;
400
+ const canAuto = !extra<boolean>(leaf, 'manual');
401
+ // Read out of the resolved config rather than re-running this leaf's own
402
+ // resolver, so the row reports the value the instrument actually got — the
403
+ // two diverge as soon as a resolver reads another auto path.
404
+ const resolvedValue = isAutoRow ? valueAtPath(shown, path) : undefined;
405
+ // An auto row draws what the resolver decided, not the value underneath it —
406
+ // a handle sitting at the pinned number while the readout says something else
407
+ // reads as a rendering bug. Pinning then keeps what you were looking at.
408
+ const value = resolvedValue ?? pinned;
409
+ const setAuto = (next: boolean): void => setRowAuto(path, next, value);
410
+ const onAutoChange = canAuto ? setAuto : undefined;
411
+ // A slider's handle is a position, not a number, so the readout is the only
412
+ // place its resolved value can be read. Every other control renders its own
413
+ // value, and repeating it there says it twice and wraps the narrow slot.
414
+ const readsItsOwnValue = !isSliderLeaf(leaf);
415
+ // A readout says what the control would say, so it reads in the display
416
+ // unit: a radian in a row that edits degrees is a different number.
417
+ const readoutValue =
418
+ leaf.kind === 'number' && typeof resolvedValue === 'number'
419
+ ? numberField(leaf, resolvedValue).shown
420
+ : resolvedValue;
421
+ const autoReadout = !isAutoRow
422
+ ? undefined
423
+ : resolvedValue === undefined || readsItsOwnValue
424
+ ? 'auto'
425
+ : `auto · ${String(readoutValue)}`;
426
+ const autoProps = { auto: isAutoRow, onAutoChange, 'data-auto-path': canAuto ? path : undefined };
427
+
428
+ useEffect(() => {
429
+ if (!canAuto) return;
430
+ toggles.set(path, () => setAuto(!isAutoRow));
431
+ return () => {
432
+ toggles.delete(path);
433
+ };
434
+ });
227
435
 
228
436
  // Most specific wins, and within a tier the lab's entry beats the
229
437
  // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
230
438
  const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
231
439
  // A custom row places itself like any other; one that needs the full width
232
440
  // says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
233
- if (custom) return custom({ path, pref: leaf, value, setValue: write });
441
+ if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
234
442
 
235
443
  const label = leaf.name;
236
444
  const description = leaf.description;
@@ -244,57 +452,61 @@ function ControlRow<TC extends Record<string, unknown>>({
244
452
 
245
453
  switch (leaf.kind) {
246
454
  case 'number': {
247
- const min = extra<number>(leaf, 'min');
248
- const max = extra<number>(leaf, 'max');
249
- const step = extra<number>(leaf, 'step');
250
- const suffix = extra<string>(leaf, 'suffix');
455
+ const field = numberField(leaf, read<number>());
456
+ const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
251
457
  const notation = extra<PrefNumberFormat>(leaf, 'format');
252
- if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
458
+ if (isSliderLeaf(leaf)) {
253
459
  return (
254
460
  <SliderRow
255
461
  label={label}
256
- value={read<number>()}
257
- min={min}
258
- max={max}
259
- step={step}
462
+ value={field.shown}
463
+ min={field.min as number}
464
+ max={field.max as number}
465
+ step={field.step}
260
466
  notation={notation}
261
467
  unit={suffix}
262
- onChange={write}
468
+ onChange={(n) => write(field.store(n))}
469
+ readout={autoReadout}
263
470
  layout={layout}
264
471
  span={wide}
265
472
  description={description}
473
+ {...autoProps}
266
474
  />
267
475
  );
268
476
  }
269
477
  // NumberRow commits every keystroke and does not clamp, so the bounds a
270
478
  // schema declares are enforced here — an instrument should never be
271
479
  // handed a config value outside the range it asked for.
272
- const lo = min ?? Number.NEGATIVE_INFINITY;
273
- const hi = max ?? Number.POSITIVE_INFINITY;
274
480
  return (
275
481
  <NumberRow
276
482
  label={label}
277
- value={read<number>()}
278
- min={min}
279
- max={max}
280
- step={step}
483
+ value={field.shown}
484
+ min={field.min}
485
+ max={field.max}
486
+ step={field.step}
281
487
  unit={suffix}
282
- onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
488
+ onChange={(n) => write(field.store(n))}
489
+ readout={autoReadout}
283
490
  layout={layout}
284
491
  description={description}
492
+ {...autoProps}
285
493
  />
286
494
  );
287
495
  }
288
- case 'boolean':
496
+ case 'boolean': {
497
+ const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
289
498
  return (
290
- <CheckboxRow
499
+ <Row
291
500
  label={label}
292
501
  value={read<boolean>()}
293
502
  onChange={write}
503
+ readout={autoReadout}
294
504
  layout={layout}
295
505
  description={description}
506
+ {...autoProps}
296
507
  />
297
508
  );
509
+ }
298
510
  case 'enum': {
299
511
  const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
300
512
  const segmented = extra<string>(leaf, 'control') === 'radio';
@@ -305,9 +517,11 @@ function ControlRow<TC extends Record<string, unknown>>({
305
517
  value={read<string>()}
306
518
  options={options}
307
519
  onChange={write}
520
+ readout={autoReadout}
308
521
  layout={layout}
309
522
  span={segmented && wide}
310
523
  description={description}
524
+ {...autoProps}
311
525
  />
312
526
  );
313
527
  }
@@ -318,21 +532,38 @@ function ControlRow<TC extends Record<string, unknown>>({
318
532
  label={label}
319
533
  value={read<string>()}
320
534
  write={write}
535
+ readout={autoReadout}
321
536
  layout={layout}
322
537
  span={wide}
323
538
  description={description}
539
+ {...autoProps}
324
540
  />
325
541
  );
326
- case 'color':
542
+ case 'color': {
543
+ // `ColorRow` takes alpha as a number beside a `#rrggbb` swatch, so an
544
+ // `#rrggbbaa` value has to be split going in and rejoined coming out —
545
+ // handed whole to the swatch, the browser cannot parse it and the first
546
+ // edit writes back black.
547
+ const common = {
548
+ label,
549
+ readout: autoReadout,
550
+ layout,
551
+ description,
552
+ ...autoProps,
553
+ };
554
+ if (extra<boolean>(leaf, 'alpha') !== true)
555
+ return <ColorRow {...common} value={read<string>()} onChange={write} />;
556
+ const { rgb, alpha } = splitHexAlpha(read<string>());
327
557
  return (
328
558
  <ColorRow
329
- label={label}
330
- value={read<string>()}
331
- onChange={write}
332
- layout={layout}
333
- description={description}
559
+ {...common}
560
+ value={rgb}
561
+ onChange={(next) => write(joinHexAlpha(next, alpha))}
562
+ alpha={alpha}
563
+ onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
334
564
  />
335
565
  );
566
+ }
336
567
  case 'paint':
337
568
  case 'object':
338
569
  // Declined: a hex swatch would write a solid over a gradient, and a flat
@@ -368,9 +599,7 @@ function UnwiredRow({
368
599
  );
369
600
  }
370
601
 
371
- /** Text writes are debounced so typing does not re-run the instrument on every
372
- * keystroke, which means the row is locally controlled between commits and has
373
- * to notice when the config changes underneath it. */
602
+ /** A text row whose writes are debounced — see {@link useDebouncedText}. */
374
603
  function DebouncedTextRow({
375
604
  leaf,
376
605
  label,
@@ -379,6 +608,10 @@ function DebouncedTextRow({
379
608
  layout,
380
609
  span,
381
610
  description,
611
+ readout,
612
+ auto,
613
+ onAutoChange,
614
+ 'data-auto-path': autoPath,
382
615
  }: {
383
616
  leaf: PrefLeaf;
384
617
  label: string;
@@ -387,8 +620,260 @@ function DebouncedTextRow({
387
620
  layout?: PropertyRowLayout;
388
621
  span?: boolean;
389
622
  description?: string;
623
+ readout?: ReactNode;
624
+ auto?: boolean;
625
+ onAutoChange?: (next: boolean) => void;
626
+ 'data-auto-path'?: string;
627
+ }) {
628
+ const text = useDebouncedText(
629
+ value,
630
+ write as (v: string) => void,
631
+ extra<number>(leaf, 'debounceMs') ?? 150,
632
+ );
633
+
634
+ return (
635
+ <TextRow
636
+ label={label}
637
+ readout={readout}
638
+ layout={layout}
639
+ span={span}
640
+ description={description}
641
+ auto={auto}
642
+ onAutoChange={onAutoChange}
643
+ data-auto-path={autoPath}
644
+ value={text.local}
645
+ placeholder={extra<string>(leaf, 'placeholder')}
646
+ maxLength={extra<number>(leaf, 'maxLength')}
647
+ onChange={text.type}
648
+ />
649
+ );
650
+ }
651
+
652
+ /**
653
+ * A number leaf's field in the unit it is edited in: what the control shows,
654
+ * the bounds it shows them against, and what a typed number stores once it is
655
+ * clamped and converted back. Without a `unit` this is what the leaf declares,
656
+ * unchanged.
657
+ */
658
+ function numberField(
659
+ leaf: PrefLeaf,
660
+ value: number,
661
+ ): {
662
+ unit: PrefNumberUnit | undefined;
663
+ min: number | undefined;
664
+ max: number | undefined;
665
+ step: number | undefined;
666
+ shown: number;
667
+ store: (shown: number) => number;
668
+ } {
669
+ const unit = extra<PrefNumberUnit>(leaf, 'unit');
670
+ // `min`, `max` and `step` are declared in the stored unit alongside the
671
+ // value, so they convert with it — a leaf storing radians and showing
672
+ // degrees would otherwise clamp typed degrees against 0..6.28.
673
+ const bounds = unit
674
+ ? prefDisplayBounds(leaf as PrefNumber)
675
+ : {
676
+ min: extra<number>(leaf, 'min'),
677
+ max: extra<number>(leaf, 'max'),
678
+ step: extra<number>(leaf, 'step'),
679
+ };
680
+ const lo = bounds.min ?? Number.NEGATIVE_INFINITY;
681
+ const hi = bounds.max ?? Number.POSITIVE_INFINITY;
682
+ return {
683
+ unit,
684
+ min: bounds.min,
685
+ max: bounds.max,
686
+ step: bounds.step,
687
+ shown: unit ? unit.toDisplay(value) : value,
688
+ store: (shown) => {
689
+ const clamped = Math.min(hi, Math.max(lo, shown));
690
+ return unit ? unit.fromDisplay(clamped) : clamped;
691
+ },
692
+ };
693
+ }
694
+
695
+ /** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` as the `#rrggbb` an
696
+ * `<input type="color">` can hold and the 0..1 alpha beside it. Anything else
697
+ * reads as opaque black, which is what the input would show for it anyway. */
698
+ function splitHexAlpha(value: string): { rgb: string; alpha: number } {
699
+ const digits = /^#([0-9a-f]{3,8})$/i.exec(value?.trim() ?? '')?.[1];
700
+ const twice = (s: string): string =>
701
+ s
702
+ .split('')
703
+ .map((c) => c + c)
704
+ .join('');
705
+ const byte = (s: string): number => Number.parseInt(s, 16) / 255;
706
+ switch (digits?.length) {
707
+ case 3:
708
+ return { rgb: `#${twice(digits)}`, alpha: 1 };
709
+ case 4:
710
+ return { rgb: `#${twice(digits.slice(0, 3))}`, alpha: byte(twice(digits.slice(3))) };
711
+ case 6:
712
+ return { rgb: `#${digits}`, alpha: 1 };
713
+ case 8:
714
+ return { rgb: `#${digits.slice(0, 6)}`, alpha: byte(digits.slice(6)) };
715
+ default:
716
+ return { rgb: '#000000', alpha: 1 };
717
+ }
718
+ }
719
+
720
+ /** A `#rrggbb` and a 0..1 alpha back into the `#rrggbbaa` the config holds. */
721
+ function joinHexAlpha(rgb: string, alpha: number): string {
722
+ const byte = Math.max(0, Math.min(255, Math.round(alpha * 255)));
723
+ return `${rgb}${byte.toString(16).padStart(2, '0')}`;
724
+ }
725
+
726
+ /** One cell of a paired row, with its path and the leaf it draws. */
727
+ interface PairCellSpec {
728
+ path: string;
729
+ leaf: PrefLeaf;
730
+ }
731
+
732
+ /**
733
+ * Leaves sharing a `pair` id, side by side on one row the pair names — the
734
+ * compact `X` / `Y` idiom `SelectionPanel` draws for the same annotation.
735
+ *
736
+ * The cells are bare controls: a row holds one label column, so each leaf's
737
+ * own name stays with its control as the accessible name and its description
738
+ * as a title. For the same reason the row carries no pin dot — a dot names one
739
+ * path, and this row has several.
740
+ */
741
+ function PairedRow<TC extends Record<string, unknown>>({
742
+ label,
743
+ cells,
744
+ shown,
745
+ setConfig,
746
+ layout,
747
+ }: {
748
+ label: string;
749
+ cells: readonly PairCellSpec[];
750
+ shown: TC;
751
+ setConfig: (path: string, value: unknown) => void;
752
+ layout?: PropertyRowLayout;
390
753
  }) {
391
- const debounceMs = extra<number>(leaf, 'debounceMs') ?? 150;
754
+ return (
755
+ <PropertyRow group label={label} layout={layout}>
756
+ {cells.map(({ path, leaf }) => (
757
+ <PairCell
758
+ key={path}
759
+ leaf={leaf}
760
+ value={valueAtPath(shown, path) ?? extra<unknown>(leaf, 'default')}
761
+ write={(value) => setConfig(path, value)}
762
+ />
763
+ ))}
764
+ </PropertyRow>
765
+ );
766
+ }
767
+
768
+ /** The control a paired cell holds, without the row chrome a whole row of its
769
+ * own would bring. */
770
+ function PairCell({
771
+ leaf,
772
+ value,
773
+ write,
774
+ }: {
775
+ leaf: PrefLeaf;
776
+ value: unknown;
777
+ write: (value: unknown) => void;
778
+ }) {
779
+ const name = leaf.name;
780
+ const title = leaf.description;
781
+ const text = useDebouncedText(
782
+ typeof value === 'string' ? value : '',
783
+ write,
784
+ extra<number>(leaf, 'debounceMs') ?? 150,
785
+ );
786
+ switch (leaf.kind) {
787
+ case 'number': {
788
+ const field = numberField(leaf, typeof value === 'number' ? value : 0);
789
+ return (
790
+ <input
791
+ type="number"
792
+ aria-label={name}
793
+ title={title}
794
+ value={field.shown}
795
+ min={field.min}
796
+ max={field.max}
797
+ step={field.step}
798
+ onChange={(e) => {
799
+ const n = Number(e.target.value);
800
+ if (e.target.value !== '' && Number.isFinite(n)) write(field.store(n));
801
+ }}
802
+ />
803
+ );
804
+ }
805
+ case 'boolean':
806
+ return extra<string>(leaf, 'control') === 'switch' ? (
807
+ <Switch aria-label={name} isSelected={value === true} onChange={write} />
808
+ ) : (
809
+ <input
810
+ type="checkbox"
811
+ aria-label={name}
812
+ title={title}
813
+ checked={value === true}
814
+ onChange={(e) => write(e.target.checked)}
815
+ />
816
+ );
817
+ case 'string':
818
+ return (
819
+ <input
820
+ type="text"
821
+ aria-label={name}
822
+ title={title}
823
+ value={text.local}
824
+ placeholder={extra<string>(leaf, 'placeholder')}
825
+ maxLength={extra<number>(leaf, 'maxLength')}
826
+ onChange={(e) => text.type(e.target.value)}
827
+ />
828
+ );
829
+ case 'color': {
830
+ // The alpha track needs a row of its own to sit under, so a paired
831
+ // swatch edits the color and carries the stored alpha through untouched.
832
+ const { rgb, alpha } = splitHexAlpha(typeof value === 'string' ? value : '');
833
+ const carriesAlpha = extra<boolean>(leaf, 'alpha') === true;
834
+ return (
835
+ <input
836
+ type="color"
837
+ aria-label={name}
838
+ title={title}
839
+ value={rgb}
840
+ onChange={(e) =>
841
+ write(carriesAlpha ? joinHexAlpha(e.target.value, alpha) : e.target.value)
842
+ }
843
+ />
844
+ );
845
+ }
846
+ case 'enum': {
847
+ const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
848
+ // `Select` takes no `title`, so the cell's help hangs off a wrapper.
849
+ return (
850
+ <span title={title}>
851
+ <Select<string>
852
+ variant="bare"
853
+ aria-label={name}
854
+ options={options}
855
+ selectedKey={typeof value === 'string' ? value : null}
856
+ onSelectionChange={write}
857
+ />
858
+ </span>
859
+ );
860
+ }
861
+ default:
862
+ // Unreachable: `pairable` admits only the kinds above.
863
+ return null;
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Live text held locally between debounced commits, so typing does not re-run
869
+ * the instrument on every keystroke. Locally controlled between commits, which
870
+ * means it has to notice the value changing underneath it.
871
+ */
872
+ function useDebouncedText(
873
+ value: string,
874
+ write: (value: string) => void,
875
+ debounceMs: number,
876
+ ): { local: string; type: (next: string) => void } {
392
877
  const [local, setLocal] = useState(value);
393
878
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
394
879
  const lastExternal = useRef(value);
@@ -407,25 +892,17 @@ function DebouncedTextRow({
407
892
  [],
408
893
  );
409
894
 
410
- return (
411
- <TextRow
412
- label={label}
413
- layout={layout}
414
- span={span}
415
- description={description}
416
- value={local}
417
- placeholder={extra<string>(leaf, 'placeholder')}
418
- maxLength={extra<number>(leaf, 'maxLength')}
419
- onChange={(next) => {
420
- setLocal(next);
421
- if (timer.current) clearTimeout(timer.current);
422
- const commit = () => {
423
- lastExternal.current = next;
424
- write(next);
425
- };
426
- if (debounceMs === 0) commit();
427
- else timer.current = setTimeout(commit, debounceMs);
428
- }}
429
- />
430
- );
895
+ return {
896
+ local,
897
+ type: (next) => {
898
+ setLocal(next);
899
+ if (timer.current) clearTimeout(timer.current);
900
+ const commit = () => {
901
+ lastExternal.current = next;
902
+ write(next);
903
+ };
904
+ if (debounceMs === 0) commit();
905
+ else timer.current = setTimeout(commit, debounceMs);
906
+ },
907
+ };
431
908
  }