@weasel-js/labkit 1.6.1 → 1.7.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 (256) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
  4. package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
  7. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +6 -4
  11. package/dist/chrome/index.js +11 -10
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/chunk-CZZPXUOU.js +3 -0
  21. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  22. package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
  23. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  24. package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
  25. package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
  26. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  27. package/dist/chunk-EESJUSYR.js.map +1 -0
  28. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  29. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  30. package/dist/chunk-F4ACIK7Z.js +210 -0
  31. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  32. package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
  33. package/dist/chunk-FB4MIZOO.js.map +1 -0
  34. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  35. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  36. package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
  37. package/dist/chunk-GAHELLJT.js.map +1 -0
  38. package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
  39. package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
  40. package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
  41. package/dist/chunk-JFOJESJF.js.map +1 -0
  42. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  43. package/dist/chunk-PMLZP7RT.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
  45. package/dist/chunk-READTDQX.js.map +1 -0
  46. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  47. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
  49. package/dist/chunk-T6DLGEJB.js.map +1 -0
  50. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  51. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  52. package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
  53. package/dist/chunk-VYVGVBGY.js.map +1 -0
  54. package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
  55. package/dist/chunk-WLMDTI2Q.js.map +1 -0
  56. package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
  57. package/dist/chunk-XE5HJOPL.js.map +1 -0
  58. package/dist/chunk-XVUKDSYV.js +38 -0
  59. package/dist/chunk-XVUKDSYV.js.map +1 -0
  60. package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +28 -13
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +4 -7
  67. package/dist/controls/index.js +6 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +72 -487
  72. package/dist/index.js +603 -441
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +20 -18
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2701
  86. package/dist/passthrough/weasel-ui.js +4 -3
  87. package/dist/primitives/index.d.ts +37 -2
  88. package/dist/primitives/index.js +9 -8
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +150 -126
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +14 -13
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +52 -33
  98. package/src/annotations/Annotations.less +0 -10
  99. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  100. package/src/annotations/MarkList.tsx +4 -8
  101. package/src/annotations/capture.browser.test.ts +41 -0
  102. package/src/annotations/capture.ts +14 -7
  103. package/src/annotations/drawOne.ts +2 -2
  104. package/src/annotations/frac.test.ts +44 -1
  105. package/src/annotations/frac.ts +46 -18
  106. package/src/annotations/index.ts +1 -0
  107. package/src/annotations/paint.test.ts +76 -46
  108. package/src/annotations/paint.ts +65 -43
  109. package/src/annotations/store.test.ts +212 -2
  110. package/src/annotations/store.ts +130 -27
  111. package/src/annotations/svgNodes.test.ts +28 -19
  112. package/src/annotations/svgNodes.ts +6 -6
  113. package/src/annotations/types.ts +29 -10
  114. package/src/canvas/AGENTS.md +1 -0
  115. package/src/canvas/CameraInput.test.tsx +30 -0
  116. package/src/canvas/CameraInput.tsx +60 -48
  117. package/src/canvas/CanvasStack.tsx +11 -6
  118. package/src/canvas/CanvasStackContext.ts +4 -1
  119. package/src/canvas/LinkedCursor.less +1 -1
  120. package/src/canvas/Stage.test.tsx +33 -4
  121. package/src/canvas/Stage.tsx +9 -4
  122. package/src/canvas/cameraRegistry.test.ts +55 -0
  123. package/src/canvas/cameraRegistry.ts +87 -0
  124. package/src/canvas/cameraZoom.test.ts +46 -0
  125. package/src/canvas/cameraZoom.ts +24 -0
  126. package/src/canvas/index.ts +14 -2
  127. package/src/canvas/sharedScope.test.tsx +94 -0
  128. package/src/canvas/worldSpec.ts +3 -1
  129. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  130. package/src/chrome/builtins.test.ts +7 -6
  131. package/src/chrome/builtins.tsx +5 -1
  132. package/src/chrome/index.ts +1 -1
  133. package/src/chrome/merge.test.ts +5 -0
  134. package/src/chrome/merge.ts +4 -2
  135. package/src/chrome/regions/PaletteRegion.less +0 -7
  136. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  137. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  138. package/src/chrome/regions/SidebarRegion.less +12 -2
  139. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  140. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  141. package/src/chrome/regions/ViewportRegion.less +6 -0
  142. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  143. package/src/chrome/types.ts +4 -2
  144. package/src/config/auto.ts +1 -0
  145. package/src/config/builder.test.ts +2 -1
  146. package/src/config/builder.ts +18 -5
  147. package/src/config/fromConfigField.test.ts +14 -0
  148. package/src/config/fromConfigField.ts +18 -4
  149. package/src/config/path.ts +2 -5
  150. package/src/config/resolve.ts +4 -0
  151. package/src/config/types.ts +14 -9
  152. package/src/controls/ControlPanel.less +1 -1
  153. package/src/controls/ControlPanel.stories.tsx +3 -2
  154. package/src/controls/ControlPanel.test.tsx +98 -11
  155. package/src/controls/ControlPanel.tsx +61 -2
  156. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  157. package/src/dragdrop/DragGhost.tsx +3 -0
  158. package/src/dragdrop/Palette.tsx +2 -0
  159. package/src/index.test.ts +5 -0
  160. package/src/index.ts +16 -2
  161. package/src/instrument/types.ts +0 -6
  162. package/src/job/useJob.ts +14 -9
  163. package/src/lab/Lab.tsx +88 -73
  164. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  165. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  166. package/src/lab/LabFullChrome.stories.tsx +1 -0
  167. package/src/lab/LabHeader.test.tsx +24 -14
  168. package/src/lab/LabHeader.tsx +11 -12
  169. package/src/lab/LabShell.less +9 -17
  170. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  171. package/src/lab/LabSwitcher.less +8 -6
  172. package/src/lab/LabZoom.test.tsx +223 -0
  173. package/src/lab/LabZoom.tsx +170 -0
  174. package/src/lab/Workspace.tsx +48 -52
  175. package/src/lab/index.ts +1 -0
  176. package/src/loupe/AGENTS.md +28 -15
  177. package/src/loupe/CanvasLoupe.tsx +2 -3
  178. package/src/loupe/DomLoupe.tsx +1 -7
  179. package/src/loupe/Loupe.less +16 -0
  180. package/src/loupe/LoupeGestures.tsx +1 -1
  181. package/src/loupe/TrialLoupe.tsx +120 -57
  182. package/src/loupe/index.ts +1 -6
  183. package/src/loupe/types.test.ts +3 -3
  184. package/src/loupe/types.ts +24 -27
  185. package/src/loupe/useLoupe.test.tsx +29 -4
  186. package/src/loupe/useLoupe.ts +20 -17
  187. package/src/overview/OverviewMarks.tsx +12 -4
  188. package/src/overview/TrialOverview.tsx +10 -8
  189. package/src/passthrough/weasel-ui.test.ts +2 -0
  190. package/src/passthrough/weasel-ui.ts +17 -5
  191. package/src/primitives/FloatingPanel.test.tsx +11 -0
  192. package/src/primitives/FloatingPanel.tsx +8 -7
  193. package/src/primitives/JobProgress.less +2 -1
  194. package/src/primitives/Legend.stories.tsx +1 -0
  195. package/src/primitives/Readout.browser.test.tsx +137 -0
  196. package/src/primitives/Readout.less +12 -0
  197. package/src/primitives/Readout.stories.tsx +58 -0
  198. package/src/primitives/Readout.test.tsx +93 -0
  199. package/src/primitives/Readout.tsx +56 -0
  200. package/src/primitives/Split.tsx +69 -41
  201. package/src/primitives/Toolbar.tsx +3 -3
  202. package/src/primitives/ZoomControl.less +1 -2
  203. package/src/primitives/index.ts +2 -0
  204. package/src/specimen/Specimen.stories.tsx +1 -0
  205. package/src/specimen/Specimen.tsx +2 -3
  206. package/src/state/Persistence.browser.test.tsx +47 -0
  207. package/src/state/store.test.ts +5 -1
  208. package/src/state/undock.ts +3 -0
  209. package/src/state/useOpenOnce.ts +20 -16
  210. package/src/state/usePersistedState.test.tsx +1 -1
  211. package/src/state/view.ts +1 -0
  212. package/src/styles.less +1 -0
  213. package/src/surface/index.ts +1 -1
  214. package/src/surface/useTiledSurface.test.tsx +14 -0
  215. package/src/surface/useTiledSurface.ts +11 -3
  216. package/src/theme/Interstellar.stories.tsx +3 -1
  217. package/src/theme/base.less +21 -47
  218. package/src/theme/interstellar.test.ts +1 -1
  219. package/src/theme/interstellar.theme.json +10 -3
  220. package/src/trial/Trial.annotations.test.tsx +73 -0
  221. package/src/trial/Trial.less +0 -29
  222. package/src/trial/Trial.loupe.test.tsx +28 -11
  223. package/src/trial/Trial.tsx +85 -67
  224. package/src/trial/TrialChrome.tsx +27 -6
  225. package/src/trial/index.ts +2 -0
  226. package/src/trial/loupeSwitch.ts +15 -0
  227. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
  228. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  229. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  230. package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
  231. package/dist/chunk-4TT3PNPR.js.map +0 -1
  232. package/dist/chunk-6FLXL3I7.js.map +0 -1
  233. package/dist/chunk-ASOK76ZH.js.map +0 -1
  234. package/dist/chunk-CGHF6RZV.js.map +0 -1
  235. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  236. package/dist/chunk-DWMSB4CA.js.map +0 -1
  237. package/dist/chunk-HXXTULDN.js.map +0 -1
  238. package/dist/chunk-IJJUK4FU.js.map +0 -1
  239. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  240. package/dist/chunk-KHK5FGER.js +0 -145
  241. package/dist/chunk-KHK5FGER.js.map +0 -1
  242. package/dist/chunk-NEXR37YJ.js.map +0 -1
  243. package/dist/chunk-O6FFFOHS.js.map +0 -1
  244. package/dist/chunk-QGMOA2YE.js.map +0 -1
  245. package/dist/chunk-RWNDDKOH.js.map +0 -1
  246. package/dist/chunk-S4NCI32V.js +0 -549
  247. package/dist/chunk-S4NCI32V.js.map +0 -1
  248. package/dist/chunk-SBXNQW4G.js +0 -3
  249. package/dist/chunk-SLAQPUI4.js.map +0 -1
  250. package/dist/chunk-TJ77A4A3.js.map +0 -1
  251. package/dist/chunk-TYN6MLXX.js +0 -13864
  252. package/dist/chunk-TYN6MLXX.js.map +0 -1
  253. package/dist/chunk-WPLAANH4.js.map +0 -1
  254. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  255. package/src/primitives/useRovingTabIndex.ts +0 -88
  256. package/src/state/Persistence.stories.tsx +0 -75
@@ -162,8 +162,10 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
162
162
  undo: () => void;
163
163
  redo: () => void;
164
164
 
165
- /** Whether the trial's loupe is turned on. False for an instrument that
166
- * declares none, whose chrome offers no way to turn one on. */
165
+ /** Whether a `<TrialLoupe>` following the trial's toggle is mounted in it.
166
+ * The loupe toggle is offered only while one is. */
167
+ hasLoupe: boolean;
168
+ /** Whether the trial's loupe is turned on. */
167
169
  loupeOn: boolean;
168
170
  toggleLoupe: () => void;
169
171
 
@@ -10,6 +10,7 @@ export const auto: unique symbol = Symbol('weasel.auto');
10
10
  /** The type of the `auto` sentinel, for widening a value parameter. */
11
11
  export type Auto = typeof auto;
12
12
 
13
+ /** Whether `value` is the `auto` sentinel. */
13
14
  export function isAuto(value: unknown): value is Auto {
14
15
  return value === auto;
15
16
  }
@@ -1,3 +1,4 @@
1
+ import { compact } from '@weasel-js/quantity';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { auto } from './auto';
3
4
  import { f } from './builder';
@@ -37,7 +38,7 @@ describe('builder', () => {
37
38
  });
38
39
 
39
40
  it('annotates a number with a display format', () => {
40
- expect(f.number(0).format('compact').annotations.format).toBe('compact');
41
+ expect(f.number(0).display(compact()).annotations.display).toEqual(compact());
41
42
  });
42
43
 
43
44
  it('keeps a custom node kind across a chain', () => {
@@ -1,4 +1,5 @@
1
- import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
1
+ import type { Display } from '@weasel-js/quantity';
2
+ import type { PrefLeaf, PrefNumberUnit } from '@weasel-js/ui';
2
3
  import { type Auto, isAuto } from './auto';
3
4
  import type {
4
5
  Annotations,
@@ -123,6 +124,7 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
123
124
  }
124
125
  }
125
126
 
127
+ /** A numeric leaf, built by `f.number`. */
126
128
  export class NumberNode extends BaseNode<number> {
127
129
  readonly kind = 'number';
128
130
 
@@ -141,10 +143,10 @@ export class NumberNode extends BaseNode<number> {
141
143
  return this.ann({ suffix });
142
144
  }
143
145
 
144
- /** Show the value in a named format — `'compact'` reads `2.00M`. Presentation
145
- * only: the stored value stays a plain number, and a typed `2.5m` reads back. */
146
- format(format: PrefNumberFormat): this {
147
- return this.ann({ format });
146
+ /** Show, speak and read the value through a display — `compact()` reads
147
+ * `2.00M`. Presentation only: the stored value stays a plain number. */
148
+ display(display: Display): this {
149
+ return this.ann({ display });
148
150
  }
149
151
 
150
152
  /**
@@ -168,6 +170,7 @@ export class NumberNode extends BaseNode<number> {
168
170
  }
169
171
  }
170
172
 
173
+ /** A true/false leaf, built by `f.boolean`; a checkbox unless `.toggle()`. */
171
174
  export class BooleanNode extends BaseNode<boolean> {
172
175
  readonly kind = 'boolean';
173
176
 
@@ -177,6 +180,7 @@ export class BooleanNode extends BaseNode<boolean> {
177
180
  }
178
181
  }
179
182
 
183
+ /** A text leaf, built by `f.string`. */
180
184
  export class StringNode extends BaseNode<string> {
181
185
  readonly kind = 'string';
182
186
 
@@ -194,6 +198,7 @@ export class StringNode extends BaseNode<string> {
194
198
  }
195
199
  }
196
200
 
201
+ /** A string-list leaf, built by `f.list`. */
197
202
  export class ListNode extends BaseNode<string[]> {
198
203
  readonly kind = 'list';
199
204
 
@@ -203,6 +208,7 @@ export class ListNode extends BaseNode<string[]> {
203
208
  }
204
209
  }
205
210
 
211
+ /** A color leaf holding a hex string, built by `f.color`. */
206
212
  export class ColorNode extends BaseNode<string> {
207
213
  readonly kind = 'color';
208
214
 
@@ -213,6 +219,7 @@ export class ColorNode extends BaseNode<string> {
213
219
  }
214
220
  }
215
221
 
222
+ /** A fixed-choice leaf, built by `f.enum`; a select unless `.radio()`. */
216
223
  export class EnumNode<T extends string> extends BaseNode<T> {
217
224
  readonly kind = 'enum';
218
225
 
@@ -286,6 +293,12 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
286
293
  showIf(predicate: (config: Record<string, unknown>) => boolean): GroupNode<S> {
287
294
  return this.with(this.annotations, { ...this.options, showIf: predicate });
288
295
  }
296
+
297
+ /** Put a button on the group's heading that returns every value beneath it
298
+ * to its default, leaving the rest of the config alone. */
299
+ resettable(): GroupNode<S> {
300
+ return this.with(this.annotations, { ...this.options, resettable: true });
301
+ }
289
302
  }
290
303
 
291
304
  /** Every default in a shape, nested the way the shape is. */
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import { fromConfigFields } from './fromConfigField';
4
+ import { schemaNodeAtPath } from './path';
4
5
  import { isLeafVisible } from './visible';
5
6
 
6
7
  const leaf = (fields: ConfigField[], k: string) =>
@@ -76,6 +77,19 @@ describe('fromConfigFields', () => {
76
77
  ]);
77
78
  expect(Object.keys(r.group.children)).toEqual(['z', 'a']);
78
79
  });
80
+
81
+ it('files a dotted key at the path it names, the one config reads and writes use', () => {
82
+ const r = fromConfigFields([
83
+ { type: 'number', key: 'pose.x', label: 'X', default: 0 },
84
+ { type: 'number', key: 'pose.y', label: 'Y', default: 0 },
85
+ { type: 'checkbox', key: 'on', label: 'On', default: true },
86
+ ]);
87
+ expect(schemaNodeAtPath(r.group, 'pose.x')).toMatchObject({ kind: 'number', name: 'X' });
88
+ expect(schemaNodeAtPath(r.group, 'pose.y')).toMatchObject({ kind: 'number', name: 'Y' });
89
+ // The intermediate is headless: a flat field list never asked for a heading.
90
+ expect(schemaNodeAtPath(r.group, 'pose')).toMatchObject({ name: '' });
91
+ expect(Object.keys(r.group.children)).toEqual(['pose', 'on']);
92
+ });
79
93
  });
80
94
 
81
95
  describe('isLeafVisible', () => {
@@ -1,4 +1,4 @@
1
- import type { PrefLeaf } from '@weasel-js/ui';
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import type { LeafPatch, ResolvedConfig } from './types';
4
4
 
@@ -53,9 +53,13 @@ function toLeaf(field: ConfigField): LeafPatch {
53
53
  *
54
54
  * Rules do not run here: a hand-written `ConfigField` already states its label
55
55
  * and control, which is everything the rule chain would have inferred.
56
+ *
57
+ * A dotted `key` is a config path, as it is to every read and write, so its
58
+ * leaf is filed under headless groups at that path rather than as one child
59
+ * whose name contains dots — which no path lookup would find.
56
60
  */
57
61
  export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig {
58
- const children: Record<string, PrefLeaf> = {};
62
+ const root: PrefGroup = { name: '', children: {} };
59
63
  for (const field of fields) {
60
64
  const patch = toLeaf(field);
61
65
  for (const key of Object.keys(patch)) {
@@ -63,10 +67,20 @@ export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig
63
67
  delete (patch as Record<string, unknown>)[key];
64
68
  }
65
69
  }
66
- children[field.key] = { ...patch, default: field.default } as PrefLeaf;
70
+ const segments = field.key.split('.');
71
+ const leafKey = segments.pop() as string;
72
+ let group = root;
73
+ for (const segment of segments) {
74
+ const existing = group.children[segment];
75
+ if (existing === undefined || !('children' in existing) || 'kind' in existing) {
76
+ group.children[segment] = { name: '', children: {} };
77
+ }
78
+ group = group.children[segment] as PrefGroup;
79
+ }
80
+ group.children[leafKey] = { ...patch, default: field.default } as PrefLeaf;
67
81
  }
68
82
  return {
69
- group: { name: '', children },
83
+ group: root,
70
84
  sections: [],
71
85
  showIf: new Map(),
72
86
  renderers: {},
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from '@weasel-js/core';
1
2
  import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
3
 
3
4
  /**
@@ -9,11 +10,7 @@ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
9
10
  /** A plain object a config tree can be walked into. An array, a `Date`, a
10
11
  * `Map` or a class instance is a leaf value: a walk that descended into one
11
12
  * would rebuild it as a bare `{}`. */
12
- export function isRecord(value: unknown): value is Record<string, unknown> {
13
- if (typeof value !== 'object' || value === null) return false;
14
- const proto = Object.getPrototypeOf(value);
15
- return proto === Object.prototype || proto === null;
16
- }
13
+ export const isRecord = isPlainObject;
17
14
 
18
15
  /** The value at a dotted path. `undefined` when a segment is missing or the
19
16
  * walk hits something that is not a record. */
@@ -27,6 +27,7 @@ interface Sink {
27
27
  showIf: Map<string, (config: Record<string, unknown>) => boolean>;
28
28
  renderers: Record<string, ControlRenderer>;
29
29
  dialogs: Record<string, DialogSpec>;
30
+ resettable: Set<string>;
30
31
  chain: readonly ConfigRule[];
31
32
  }
32
33
 
@@ -48,6 +49,7 @@ export function resolveConfigSchema<TC>(
48
49
  showIf: new Map(),
49
50
  renderers: {},
50
51
  dialogs: {},
52
+ resettable: new Set(),
51
53
  chain: [...rules, ...builtinRules],
52
54
  };
53
55
  const group = resolveShape(schema.nodes, '', '', sink);
@@ -57,6 +59,7 @@ export function resolveConfigSchema<TC>(
57
59
  showIf: sink.showIf,
58
60
  renderers: sink.renderers,
59
61
  dialogs: sink.dialogs,
62
+ resettable: sink.resettable,
60
63
  };
61
64
  }
62
65
 
@@ -89,6 +92,7 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
89
92
  if (section.pack !== undefined && spec.pack === undefined) spec.pack = section.pack;
90
93
  }
91
94
  if (predicate) sink.showIf.set(path, predicate);
95
+ if (isConfigBranch(entry) && entry.options.resettable) sink.resettable.add(path);
92
96
  if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
93
97
  if (!isConfigBranch(entry) && entry.options.dialog) sink.dialogs[path] = entry.options.dialog;
94
98
  }
@@ -1,10 +1,5 @@
1
- import type {
2
- PrefGroup,
3
- PrefLeaf,
4
- PrefNumberFormat,
5
- PrefNumberUnit,
6
- PrefRenderer,
7
- } from '@weasel-js/ui';
1
+ import type { Display } from '@weasel-js/quantity';
2
+ import type { PrefGroup, PrefLeaf, PrefNumberUnit, PrefRenderer } from '@weasel-js/ui';
8
3
  import type { ReactNode } from 'react';
9
4
 
10
5
  /**
@@ -18,6 +13,10 @@ export type ControlRenderer = PrefRenderer;
18
13
  export interface ConfigOption {
19
14
  value: string;
20
15
  label: string;
16
+ /** A glyph in weasel-ui's icon set (`IconName`). A segmented row draws it in
17
+ * place of `label`, which stays the accessible name and becomes the tooltip;
18
+ * a select draws it beside `label`. */
19
+ icon?: string;
21
20
  }
22
21
 
23
22
  /** Everything a leaf can carry beyond its kind and default. A flat union of
@@ -32,8 +31,8 @@ export interface Annotations {
32
31
  max?: number;
33
32
  step?: number;
34
33
  suffix?: string;
35
- /** How a number's value is shown. `compact` abbreviates from a thousand up. */
36
- format?: PrefNumberFormat;
34
+ /** How a number's value shows, speaks and reads back — `compact()` reads `2.00M`. */
35
+ display?: Display;
37
36
  /** Display-unit conversion for a number stored in a canonical unit — the
38
37
  * value, its bounds and its step all convert at the control's edge. */
39
38
  unit?: PrefNumberUnit;
@@ -107,6 +106,9 @@ export interface ResolvedConfig {
107
106
  renderers: Readonly<Record<string, ControlRenderer>>;
108
107
  /** Node-level `.dialog` rows, keyed by path. */
109
108
  dialogs: Readonly<Record<string, DialogSpec>>;
109
+ /** Paths of the groups marked `.resettable()`, whose heading carries a
110
+ * button that puts every value beneath it back to its default. */
111
+ resettable?: ReadonlySet<string>;
110
112
  }
111
113
 
112
114
  /** Options for a row drawn as a button that opens a dialog. */
@@ -162,6 +164,9 @@ export interface BranchOptions {
162
164
  /** Show this node only while the predicate holds. On a group it hides the
163
165
  * whole subtree; the values stay in config either way. */
164
166
  showIf?: (config: Record<string, unknown>) => boolean;
167
+ /** Put a button on this group's heading that returns every value beneath it
168
+ * to its default. Groups only. */
169
+ resettable?: boolean;
165
170
  }
166
171
 
167
172
  /** A group's own annotations: what it is called and, optionally, why. */
@@ -17,7 +17,7 @@
17
17
  .lk-pair-cell {
18
18
  display: inline-flex;
19
19
  align-items: center;
20
- gap: var(--wzl-space-3, 6px);
20
+ gap: var(--wzl-space-3);
21
21
  min-width: 0;
22
22
  }
23
23
 
@@ -1,5 +1,6 @@
1
1
  import { ANGLE_RADIANS, prefUnit } from '@weasel-js/core';
2
2
  import type { Meta, StoryObj } from '@weasel-js/forge';
3
+ import { compact } from '@weasel-js/quantity';
3
4
  import { type PrefNumberUnit, PropertyRow } from '@weasel-js/ui';
4
5
  import { useState } from 'react';
5
6
  import { f } from '../config/builder';
@@ -278,7 +279,7 @@ export const Presentation: Story = {
278
279
 
279
280
  const inline = f.schema({
280
281
  folderPad: f.number(1.1).range(0, 4).step(0.1).label('Folder pad'),
281
- glyphs: f.number(1_000_000).range(0, 2_000_000).format('compact'),
282
+ glyphs: f.number(1_000_000).range(0, 2_000_000).display(compact()),
282
283
  names: f.number(60).range(0, 100),
283
284
  labels: f.number(0).range(0, 100),
284
285
  placement: f.enum('spread', ['spread', 'stack', 'ring']),
@@ -337,7 +338,7 @@ const sidebarSliders = f.schema({
337
338
  pad: f.number(0.5).range(0, 4).step(0.25).label('Window pad'),
338
339
  folderPad: f.number(1).range(0, 4).step(0.1).label('Folder pad'),
339
340
  levels: f.number(2).range(0, 6).step(1).label('Levels'),
340
- glyphs: f.number(1_000_000).range(0, 2_000_000).step(2000).format('compact').label('Glyphs'),
341
+ glyphs: f.number(1_000_000).range(0, 2_000_000).step(2000).display(compact()).label('Glyphs'),
341
342
  names: f.number(40).range(0, 200).step(5).label('Names'),
342
343
  labels: f.number(0).range(0, 30).step(1).label('Labels'),
343
344
  text: f.number(100).range(50, 250).step(5).unit(shownIn('%')).label('Text'),
@@ -1,6 +1,7 @@
1
1
  import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
4
+ import { compact } from '@weasel-js/quantity';
4
5
  import type { PrefLeaf } from '@weasel-js/ui';
5
6
  import { useState } from 'react';
6
7
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -88,7 +89,7 @@ describe('<ControlPanel> number', () => {
88
89
  ];
89
90
  render(<ControlPanel fields={fields} config={{ n: 42 }} setConfig={vi.fn()} />);
90
91
  const input = screen.getByLabelText('N') as HTMLInputElement;
91
- expect(input.type).toBe('number');
92
+ expect(input).toHaveAttribute('role', 'spinbutton');
92
93
  expect(input.value).toBe('42');
93
94
  });
94
95
 
@@ -143,7 +144,7 @@ describe('<ControlPanel> color', () => {
143
144
  const input = screen.getByLabelText('Color') as HTMLInputElement;
144
145
  expect(input.type).toBe('color');
145
146
  expect(input.value).toBe('#ff0000');
146
- fireEvent.change(input, { target: { value: '#00ff00' } });
147
+ fireEvent.input(input, { target: { value: '#00ff00' } });
147
148
  expect(setConfig).toHaveBeenCalledWith('c', '#00ff00');
148
149
  });
149
150
  });
@@ -206,7 +207,7 @@ describe('<ControlPanel> schema', () => {
206
207
  [],
207
208
  );
208
209
  render(<ControlPanel schema={schema} config={{ a: 5, b: 5 }} setConfig={vi.fn()} />);
209
- expect(screen.getByLabelText('B')).toHaveAttribute('type', 'number');
210
+ expect(screen.getByLabelText('B')).toHaveAttribute('role', 'spinbutton');
210
211
  });
211
212
 
212
213
  it('falls back to the leaf default when config holds no value', () => {
@@ -466,7 +467,7 @@ describe('<ControlPanel> suffix', () => {
466
467
 
467
468
  it('suffixes a typed number with the suffix the leaf declares', () => {
468
469
  const { container } = panel(f.number(20).input().suffix('px'));
469
- expect(container.querySelector('input[type="number"]')).not.toBeNull();
470
+ expect(container.querySelector('input[role="spinbutton"]')).not.toBeNull();
470
471
  expect(container.textContent).toContain('px');
471
472
  });
472
473
  });
@@ -476,7 +477,7 @@ describe('<ControlPanel> format', () => {
476
477
  render(
477
478
  <ControlPanel
478
479
  schema={resolveConfigSchema(
479
- f.schema({ glyphs: f.number(0).range(0, 2_000_000).format('compact') }),
480
+ f.schema({ glyphs: f.number(0).range(0, 2_000_000).display(compact()) }),
480
481
  [],
481
482
  )}
482
483
  config={{ glyphs: 2_000_000 }}
@@ -898,6 +899,52 @@ describe('<ControlPanel> auto', () => {
898
899
  });
899
900
  });
900
901
 
902
+ describe('<ControlPanel> option icons', () => {
903
+ it("draws an option's icon in its segment and keeps the label as its name", () => {
904
+ const status = f
905
+ .enum('ok', [
906
+ { value: 'ok', label: 'ok', icon: 'statusSuccess' },
907
+ { value: 'bad', label: 'bad' },
908
+ ])
909
+ .radio();
910
+ render(
911
+ <ControlPanel
912
+ schema={resolveConfigSchema(f.schema({ status }), [])}
913
+ config={{ status: 'ok' }}
914
+ setConfig={() => {}}
915
+ />,
916
+ );
917
+ const ok = screen.getByRole('radio', { name: 'ok' });
918
+ expect(ok.querySelector('svg')).not.toBeNull();
919
+ expect(ok.textContent).toBe('');
920
+ expect(screen.getByRole('radio', { name: 'bad' }).textContent).toBe('bad');
921
+ });
922
+
923
+ it("draws an option's icon beside its label in a select's trigger and list", () => {
924
+ const status = f.enum('ok', [
925
+ { value: 'ok', label: 'ok', icon: 'statusSuccess' },
926
+ { value: 'bad', label: 'bad' },
927
+ ]);
928
+ render(
929
+ <ControlPanel
930
+ schema={resolveConfigSchema(f.schema({ status }), [])}
931
+ config={{ status: 'ok' }}
932
+ setConfig={() => {}}
933
+ />,
934
+ );
935
+ const trigger = screen.getByRole('button', { name: /ok/ });
936
+ expect(trigger.querySelector('[aria-hidden="true"] > svg')).not.toBeNull();
937
+ expect(trigger).toHaveTextContent('ok');
938
+ act(() => {
939
+ fireEvent.click(trigger);
940
+ });
941
+ expect(
942
+ screen.getByRole('option', { name: 'ok' }).querySelector('[aria-hidden="true"] > svg'),
943
+ ).not.toBeNull();
944
+ expect(screen.getByRole('option', { name: 'bad' }).querySelectorAll('svg')).toHaveLength(1);
945
+ });
946
+ });
947
+
901
948
  describe('<ControlPanel> pair', () => {
902
949
  const paired = () =>
903
950
  resolveConfigSchema(
@@ -913,8 +960,8 @@ describe('<ControlPanel> pair', () => {
913
960
  it('draws two leaves sharing a pair id as one row named by the pair', () => {
914
961
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
915
962
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
916
- expect(within(row).getByLabelText('X')).toHaveValue(10);
917
- expect(within(row).getByLabelText('Y')).toHaveValue(20);
963
+ expect(within(row).getByLabelText('X')).toHaveValue('10');
964
+ expect(within(row).getByLabelText('Y')).toHaveValue('20');
918
965
  });
919
966
 
920
967
  it('captions each cell of a pair with its own name, so a paired switch is not left unnamed', () => {
@@ -952,7 +999,7 @@ describe('<ControlPanel> pair', () => {
952
999
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
953
1000
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
954
1001
  expect(within(row).queryByLabelText('Scale')).toBeNull();
955
- expect(screen.getByLabelText('Scale')).toHaveValue(1);
1002
+ expect(screen.getByLabelText('Scale')).toHaveValue('1');
956
1003
  });
957
1004
  });
958
1005
 
@@ -1045,18 +1092,18 @@ describe('<ControlPanel> color alpha', () => {
1045
1092
  it('splits the stored hex into a swatch and an alpha track', () => {
1046
1093
  panel();
1047
1094
  expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
1048
- expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
1095
+ expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('80');
1049
1096
  });
1050
1097
 
1051
1098
  it('keeps the stored alpha when the swatch moves', () => {
1052
1099
  const setConfig = panel();
1053
- fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1100
+ fireEvent.input(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1054
1101
  expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
1055
1102
  });
1056
1103
 
1057
1104
  it('keeps the stored color when the alpha track moves', () => {
1058
1105
  const setConfig = panel();
1059
- fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
1106
+ fireEvent.input(screen.getByLabelText('Tint opacity'), { target: { value: '50' } });
1060
1107
  expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
1061
1108
  });
1062
1109
 
@@ -1101,3 +1148,43 @@ describe('<ControlPanel> title, stance and tone', () => {
1101
1148
  expect(panel.querySelector('.lk-control-panel')).not.toBeNull();
1102
1149
  });
1103
1150
  });
1151
+
1152
+ describe('<ControlPanel> resettable group', () => {
1153
+ const schema = resolveConfigSchema(
1154
+ f.schema({
1155
+ name: f.string('a'),
1156
+ modes: f
1157
+ .group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) })
1158
+ .resettable(),
1159
+ plain: f.group({ four: f.number(0) }),
1160
+ }),
1161
+ );
1162
+
1163
+ it('puts a reset button on the group heading, disabled while nothing has changed', () => {
1164
+ render(
1165
+ <ControlPanel
1166
+ schema={schema}
1167
+ config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }}
1168
+ setConfig={vi.fn()}
1169
+ />,
1170
+ );
1171
+ expect(screen.getByRole('button', { name: 'Reset Modes' })).toBeDisabled();
1172
+ expect(screen.queryByRole('button', { name: 'Reset Plain' })).toBeNull();
1173
+ });
1174
+
1175
+ it('writes the default of every changed value beneath the group and nothing else', () => {
1176
+ const setConfig = vi.fn();
1177
+ render(
1178
+ <ControlPanel
1179
+ schema={schema}
1180
+ config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }}
1181
+ setConfig={setConfig}
1182
+ />,
1183
+ );
1184
+ fireEvent.click(screen.getByRole('button', { name: 'Reset Modes' }));
1185
+ expect(setConfig.mock.calls).toEqual([
1186
+ ['modes.one', 0],
1187
+ ['modes.inner.three', 1],
1188
+ ]);
1189
+ });
1190
+ });
@@ -1,5 +1,6 @@
1
1
  import { getAlpha01, isBuiltinToolPref, toHex8, withAlpha01 } from '@weasel-js/core';
2
2
  import {
3
+ Button,
3
4
  DialogRow,
4
5
  isPrefLeaf,
5
6
  ListEditor,
@@ -17,6 +18,7 @@ import {
17
18
  PropertyRow,
18
19
  type PropertyRowLayout,
19
20
  prefFieldProps,
21
+ ResetIcon,
20
22
  type StanceProps,
21
23
  } from '@weasel-js/ui';
22
24
  import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -117,6 +119,49 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> extends S
117
119
 
118
120
  const NO_AUTO: ReadonlySet<string> = new Set();
119
121
 
122
+ /** Every leaf beneath a group, as its dotted path and its default. */
123
+ function leavesUnder(group: PrefGroup, at: string): [string, unknown][] {
124
+ return Object.entries(group.children).flatMap(([key, child]) => {
125
+ const path = at === '' ? key : `${at}.${key}`;
126
+ return isPrefLeaf(child)
127
+ ? [[path, child.default] as [string, unknown]]
128
+ : leavesUnder(child, path);
129
+ });
130
+ }
131
+
132
+ /** The heading button of a `.resettable()` group: writes each changed leaf's
133
+ * default, and is disabled while nothing has changed. */
134
+ function ResetGroup({
135
+ group,
136
+ path,
137
+ config,
138
+ setConfig,
139
+ }: {
140
+ group: PrefGroup;
141
+ path: string;
142
+ config: Record<string, unknown>;
143
+ setConfig: (path: string, value: unknown) => void;
144
+ }) {
145
+ const changed = leavesUnder(group, path).filter(
146
+ ([p, d]) => !Object.is(valueAtPath(config, p), d),
147
+ );
148
+ return (
149
+ <Button
150
+ iconOnly
151
+ variant="ghost"
152
+ size="sm"
153
+ ariaLabel={`Reset ${group.name}`}
154
+ tooltip={`Reset ${group.name} to defaults`}
155
+ disabled={changed.length === 0}
156
+ onClick={() => {
157
+ for (const [p, d] of changed) setConfig(p, d);
158
+ }}
159
+ >
160
+ <ResetIcon />
161
+ </Button>
162
+ );
163
+ }
164
+
120
165
  /** Render an instrument's config schema as a stack of controls, each writing
121
166
  * back through `setConfig`. Built on the property rows, so a lab's controls
122
167
  * are the same aligned, themed rows the rest of the kit uses. */
@@ -224,6 +269,16 @@ export function ControlPanel<TC extends Record<string, unknown>>({
224
269
  // two-column grid leaves it laying its own rows out inside a cell,
225
270
  // which overlaps them.
226
271
  span
272
+ actions={
273
+ resolved.resettable?.has(path) ? (
274
+ <ResetGroup
275
+ group={found}
276
+ path={path}
277
+ config={config as Record<string, unknown>}
278
+ setConfig={setConfig}
279
+ />
280
+ ) : undefined
281
+ }
227
282
  {...fold(path, undefined, rows.grid)}
228
283
  >
229
284
  {body(found, path, rows)}
@@ -461,7 +516,7 @@ function ControlRow<TC extends Record<string, unknown>>({
461
516
  case 'number':
462
517
  return <PropertyField {...field} {...row} span={field.control === 'slider' && wide} />;
463
518
  case 'enum':
464
- return <PropertyField {...field} {...row} span={field.control === 'radio' && wide} />;
519
+ return <PropertyField {...field} {...row} span={field.control === 'toggle' && wide} />;
465
520
  default:
466
521
  return <PropertyField {...field} {...row} />;
467
522
  }
@@ -496,7 +551,11 @@ function labField(
496
551
  control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox',
497
552
  };
498
553
  case 'enum':
499
- return { ...field, control: extra<string>(leaf, 'control') === 'radio' ? 'radio' : 'select' };
554
+ // `.radio()` asks for every option at once, as segments.
555
+ return {
556
+ ...field,
557
+ control: extra<string>(leaf, 'control') === 'radio' ? 'toggle' : 'select',
558
+ };
500
559
  case 'string':
501
560
  return {
502
561
  ...field,