@weasel-js/labkit 1.4.4 → 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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  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-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -6,6 +6,9 @@ import {
6
6
  NumberRow,
7
7
  type PrefGroup,
8
8
  type PrefLeaf,
9
+ type PrefNumber,
10
+ type PrefNumberFormat,
11
+ type PrefNumberUnit,
9
12
  type PropertyAlign,
10
13
  type PropertyDensity,
11
14
  PropertyGroup,
@@ -13,12 +16,18 @@ import {
13
16
  type PropertyListPack,
14
17
  PropertyRow,
15
18
  type PropertyRowLayout,
19
+ prefDisplayBounds,
20
+ Select,
16
21
  SelectRow,
17
22
  SliderRow,
23
+ Switch,
24
+ SwitchRow,
18
25
  TextRow,
19
26
  ToggleRow,
20
27
  } from '@weasel-js/ui';
21
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';
22
31
  import { fromConfigFields } from '../config/fromConfigField';
23
32
  import { schemaNodeAtPath, valueAtPath } from '../config/path';
24
33
  import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
@@ -26,15 +35,33 @@ import { isLeafVisible } from '../config/visible';
26
35
  import type { ConfigField } from './types';
27
36
 
28
37
  /** How a panel packs its rows into the two-column property grid.
29
- * - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colours) sit
38
+ * - `'auto'`: narrow controls (numbers, checkboxes, dropdowns, colors) sit
30
39
  * two per row; the ones that need the width — text, sliders, segmented
31
40
  * toggles — span it.
32
41
  * - `'pairs'`: every row pairs. A control that needs the full width says so
33
42
  * itself, with `<PropertyRow span>`.
34
- * - `'one-up'`: one control per row, colours excepted.
43
+ * - `'one-up'`: one control per row, colors excepted.
35
44
  */
36
45
  export type ControlPack = 'auto' | 'pairs' | 'one-up';
37
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
+
38
65
  export interface ControlPanelProps<TC extends Record<string, unknown>> {
39
66
  /** The instrument's resolved config schema. */
40
67
  schema?: ResolvedConfig;
@@ -79,11 +106,19 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
79
106
  /** A fold moved. The key is a section's label — prefixed by its group's
80
107
  * dotted path when the section sits inside one — or a group's own path. */
81
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>;
82
115
  /** Draw leaves marked `hidden`. */
83
116
  showHidden?: boolean;
84
117
  className?: string;
85
118
  }
86
119
 
120
+ const NO_AUTO: ReadonlySet<string> = new Set();
121
+
87
122
  /** Render an instrument's config schema as a stack of controls, each writing
88
123
  * back through `setConfig`. Built on the property rows, so a lab's controls
89
124
  * are the same aligned, themed rows the rest of the kit uses. */
@@ -100,12 +135,64 @@ export function ControlPanel<TC extends Record<string, unknown>>({
100
135
  collapse,
101
136
  collapsed,
102
137
  onCollapse,
138
+ auto: given,
103
139
  showHidden = false,
104
140
  className,
105
141
  }: ControlPanelProps<TC>) {
106
142
  const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
107
143
 
108
- 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);
109
196
  // A section that declares how it opens is foldable whether or not the lab
110
197
  // asked for folds — there is nothing else for the declaration to mean.
111
198
  const folds =
@@ -115,8 +202,8 @@ export function ControlPanel<TC extends Record<string, unknown>>({
115
202
  resolved.sections.some((s) => s.collapsed !== undefined);
116
203
  const startsFolded = collapse === 'closed';
117
204
 
118
- const fold = (key: string, declared?: boolean) => ({
119
- pack: gridPack,
205
+ const fold = (key: string, declared?: boolean, grid: PropertyListPack = gridPack) => ({
206
+ pack: grid,
120
207
  collapsible: folds,
121
208
  defaultCollapsed: declared ?? startsFolded,
122
209
  collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
@@ -124,7 +211,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
124
211
  });
125
212
 
126
213
  /** One node, which is either a group to recurse into or a row to draw. */
127
- const node = (path: string): ReactNode => {
214
+ const node = (path: string, rows: Rows): ReactNode => {
128
215
  const found = schemaNodeAtPath(resolved.group, path);
129
216
  if (!found) return null;
130
217
  if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
@@ -136,53 +223,117 @@ export function ControlPanel<TC extends Record<string, unknown>>({
136
223
  leaf={found}
137
224
  resolved={resolved}
138
225
  config={config}
226
+ shown={shown}
139
227
  setConfig={setConfig}
140
228
  renderers={renderers}
141
- pack={pack}
142
- layout={layout}
229
+ pack={rows.pack}
230
+ layout={rows.layout}
231
+ auto={auto}
232
+ setRowAuto={setRowAuto}
233
+ toggles={toggles.current}
143
234
  />
144
235
  );
145
236
  }
146
237
  // A group with no name organizes without heading it — core's rule for an
147
238
  // empty `PrefGroup.name` — so it contributes its rows and no chrome.
148
- if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
239
+ if (found.name === '') return <Fragment key={path}>{body(found, path, rows)}</Fragment>;
149
240
  return (
150
- <PropertyGroup key={path} title={found.name} {...fold(path)}>
151
- {body(found, path)}
241
+ <PropertyGroup key={path} title={found.name} {...fold(path, undefined, rows.grid)}>
242
+ {body(found, path, rows)}
152
243
  </PropertyGroup>
153
244
  );
154
245
  };
155
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
+
156
299
  /** One group's children: its loose nodes, then its sections. */
157
- const body = (group: PrefGroup, at: string): ReactNode => {
300
+ const body = (group: PrefGroup, at: string, rows: Rows): ReactNode => {
158
301
  const sections = resolved.sections.filter((s) => s.at === at);
159
302
  const sectioned = new Set(sections.flatMap((s) => s.paths));
160
303
  const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
161
304
  return (
162
305
  <>
163
- {paths.filter((p) => !sectioned.has(p)).map(node)}
164
- {sections.map((section) => (
165
- <PropertyGroup
166
- key={sectionKey(section)}
167
- title={section.label}
168
- {...fold(sectionKey(section), section.collapsed)}
169
- >
170
- {section.paths.map(node)}
171
- </PropertyGroup>
172
- ))}
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
+ })}
173
322
  </>
174
323
  );
175
324
  };
176
325
 
177
326
  return (
178
- <PropertyList
179
- pack={gridPack}
180
- density={density}
181
- align={align}
182
- className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
183
- >
184
- {body(resolved.group, '')}
185
- </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>
186
337
  );
187
338
  }
188
339
 
@@ -198,10 +349,27 @@ interface ControlRowProps<TC extends Record<string, unknown>> {
198
349
  leaf: PrefLeaf;
199
350
  resolved: ResolvedConfig;
200
351
  config: TC;
352
+ /** `config` with every auto path resolved -- what the instrument reads. */
353
+ shown: TC;
201
354
  setConfig: (path: string, value: unknown) => void;
202
355
  renderers?: Record<string, ControlRenderer>;
203
356
  pack: ControlPack;
204
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
+ );
205
373
  }
206
374
 
207
375
  /** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
@@ -215,21 +383,62 @@ function ControlRow<TC extends Record<string, unknown>>({
215
383
  leaf,
216
384
  resolved,
217
385
  config,
386
+ shown,
218
387
  setConfig,
219
388
  renderers,
220
389
  pack,
221
390
  layout,
391
+ auto,
392
+ setRowAuto,
393
+ toggles,
222
394
  }: ControlRowProps<TC>) {
223
395
  const write = (value: unknown): void => setConfig(path, value);
224
396
  const fallback = extra<unknown>(leaf, 'default');
225
- 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
+ });
226
435
 
227
436
  // Most specific wins, and within a tier the lab's entry beats the
228
437
  // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
229
438
  const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
230
439
  // A custom row places itself like any other; one that needs the full width
231
440
  // says so with `<PropertyRow span>`, which is the same opt-out a built-in has.
232
- if (custom) return custom({ path, pref: leaf, value, setValue: write });
441
+ if (custom) return custom({ path, pref: leaf, value, setValue: write, auto: isAutoRow, setAuto });
233
442
 
234
443
  const label = leaf.name;
235
444
  const description = leaf.description;
@@ -243,55 +452,61 @@ function ControlRow<TC extends Record<string, unknown>>({
243
452
 
244
453
  switch (leaf.kind) {
245
454
  case 'number': {
246
- const min = extra<number>(leaf, 'min');
247
- const max = extra<number>(leaf, 'max');
248
- const step = extra<number>(leaf, 'step');
249
- const suffix = extra<string>(leaf, 'suffix');
250
- if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
455
+ const field = numberField(leaf, read<number>());
456
+ const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
457
+ const notation = extra<PrefNumberFormat>(leaf, 'format');
458
+ if (isSliderLeaf(leaf)) {
251
459
  return (
252
460
  <SliderRow
253
461
  label={label}
254
- value={read<number>()}
255
- min={min}
256
- max={max}
257
- step={step}
462
+ value={field.shown}
463
+ min={field.min as number}
464
+ max={field.max as number}
465
+ step={field.step}
466
+ notation={notation}
258
467
  unit={suffix}
259
- onChange={write}
468
+ onChange={(n) => write(field.store(n))}
469
+ readout={autoReadout}
260
470
  layout={layout}
261
471
  span={wide}
262
472
  description={description}
473
+ {...autoProps}
263
474
  />
264
475
  );
265
476
  }
266
477
  // NumberRow commits every keystroke and does not clamp, so the bounds a
267
478
  // schema declares are enforced here — an instrument should never be
268
479
  // handed a config value outside the range it asked for.
269
- const lo = min ?? Number.NEGATIVE_INFINITY;
270
- const hi = max ?? Number.POSITIVE_INFINITY;
271
480
  return (
272
481
  <NumberRow
273
482
  label={label}
274
- value={read<number>()}
275
- min={min}
276
- max={max}
277
- step={step}
483
+ value={field.shown}
484
+ min={field.min}
485
+ max={field.max}
486
+ step={field.step}
278
487
  unit={suffix}
279
- onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
488
+ onChange={(n) => write(field.store(n))}
489
+ readout={autoReadout}
280
490
  layout={layout}
281
491
  description={description}
492
+ {...autoProps}
282
493
  />
283
494
  );
284
495
  }
285
- case 'boolean':
496
+ case 'boolean': {
497
+ const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
286
498
  return (
287
- <CheckboxRow
499
+ <Row
288
500
  label={label}
289
501
  value={read<boolean>()}
290
502
  onChange={write}
503
+ readout={autoReadout}
291
504
  layout={layout}
292
505
  description={description}
506
+ {...autoProps}
293
507
  />
294
508
  );
509
+ }
295
510
  case 'enum': {
296
511
  const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
297
512
  const segmented = extra<string>(leaf, 'control') === 'radio';
@@ -302,9 +517,11 @@ function ControlRow<TC extends Record<string, unknown>>({
302
517
  value={read<string>()}
303
518
  options={options}
304
519
  onChange={write}
520
+ readout={autoReadout}
305
521
  layout={layout}
306
522
  span={segmented && wide}
307
523
  description={description}
524
+ {...autoProps}
308
525
  />
309
526
  );
310
527
  }
@@ -315,21 +532,38 @@ function ControlRow<TC extends Record<string, unknown>>({
315
532
  label={label}
316
533
  value={read<string>()}
317
534
  write={write}
535
+ readout={autoReadout}
318
536
  layout={layout}
319
537
  span={wide}
320
538
  description={description}
539
+ {...autoProps}
321
540
  />
322
541
  );
323
- 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>());
324
557
  return (
325
558
  <ColorRow
326
- label={label}
327
- value={read<string>()}
328
- onChange={write}
329
- layout={layout}
330
- description={description}
559
+ {...common}
560
+ value={rgb}
561
+ onChange={(next) => write(joinHexAlpha(next, alpha))}
562
+ alpha={alpha}
563
+ onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
331
564
  />
332
565
  );
566
+ }
333
567
  case 'paint':
334
568
  case 'object':
335
569
  // Declined: a hex swatch would write a solid over a gradient, and a flat
@@ -365,9 +599,7 @@ function UnwiredRow({
365
599
  );
366
600
  }
367
601
 
368
- /** Text writes are debounced so typing does not re-run the instrument on every
369
- * keystroke, which means the row is locally controlled between commits and has
370
- * to notice when the config changes underneath it. */
602
+ /** A text row whose writes are debounced — see {@link useDebouncedText}. */
371
603
  function DebouncedTextRow({
372
604
  leaf,
373
605
  label,
@@ -376,6 +608,10 @@ function DebouncedTextRow({
376
608
  layout,
377
609
  span,
378
610
  description,
611
+ readout,
612
+ auto,
613
+ onAutoChange,
614
+ 'data-auto-path': autoPath,
379
615
  }: {
380
616
  leaf: PrefLeaf;
381
617
  label: string;
@@ -384,8 +620,260 @@ function DebouncedTextRow({
384
620
  layout?: PropertyRowLayout;
385
621
  span?: boolean;
386
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;
387
753
  }) {
388
- 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 } {
389
877
  const [local, setLocal] = useState(value);
390
878
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
391
879
  const lastExternal = useRef(value);
@@ -404,25 +892,17 @@ function DebouncedTextRow({
404
892
  [],
405
893
  );
406
894
 
407
- return (
408
- <TextRow
409
- label={label}
410
- layout={layout}
411
- span={span}
412
- description={description}
413
- value={local}
414
- placeholder={extra<string>(leaf, 'placeholder')}
415
- maxLength={extra<number>(leaf, 'maxLength')}
416
- onChange={(next) => {
417
- setLocal(next);
418
- if (timer.current) clearTimeout(timer.current);
419
- const commit = () => {
420
- lastExternal.current = next;
421
- write(next);
422
- };
423
- if (debounceMs === 0) commit();
424
- else timer.current = setTimeout(commit, debounceMs);
425
- }}
426
- />
427
- );
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
+ };
428
908
  }