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