@weasel-js/labkit 1.4.1 → 1.4.3

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 (145) hide show
  1. package/README.md +28 -2
  2. package/dist/_dts/{CanvasStackContext-BIRUVVGH.d.ts → CanvasStackContext-Dde8_1my.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
  4. package/dist/_dts/{frac-X7mWgd7y.d.ts → frac-B5mB8LsJ.d.ts} +33 -13
  5. package/dist/_dts/{index-FEa1MudK.d.ts → index-DelFfeV0.d.ts} +99 -31
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BP2OCcpg.d.ts} +41 -4
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-D6Vb3T-g.d.ts} +11 -7
  10. package/dist/canvas/index.d.ts +9 -8
  11. package/dist/canvas/index.js +1 -2
  12. package/dist/chrome/index.d.ts +16 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-MSR7EVS6.js → chunk-2RQPGOQF.js} +6 -5
  15. package/dist/chunk-2RQPGOQF.js.map +1 -0
  16. package/dist/{chunk-LJSIUFYD.js → chunk-5TROQVQ2.js} +46 -21
  17. package/dist/chunk-5TROQVQ2.js.map +1 -0
  18. package/dist/{chunk-53AZ2O2N.js → chunk-ASWKLRKJ.js} +92 -45
  19. package/dist/chunk-ASWKLRKJ.js.map +1 -0
  20. package/dist/{chunk-BMQWLKOL.js → chunk-C42T6DJT.js} +94 -83
  21. package/dist/chunk-C42T6DJT.js.map +1 -0
  22. package/dist/chunk-E44SU6XS.js +50 -0
  23. package/dist/chunk-E44SU6XS.js.map +1 -0
  24. package/dist/{chunk-OFCBEZMU.js → chunk-FQMJUVHJ.js} +3 -3
  25. package/dist/{chunk-OFCBEZMU.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  26. package/dist/{chunk-PP7ZWAMV.js → chunk-I6JCVE24.js} +3 -3
  27. package/dist/chunk-I6JCVE24.js.map +1 -0
  28. package/dist/chunk-ISCOFAUO.js +170 -0
  29. package/dist/chunk-ISCOFAUO.js.map +1 -0
  30. package/dist/chunk-MHAGC6VD.js +7320 -0
  31. package/dist/chunk-MHAGC6VD.js.map +1 -0
  32. package/dist/{chunk-7TRFMIWX.js → chunk-QAGAHJKZ.js} +4 -4
  33. package/dist/{chunk-7TRFMIWX.js.map → chunk-QAGAHJKZ.js.map} +1 -1
  34. package/dist/{chunk-D7QIPGEB.js → chunk-RS3HRQWU.js} +62 -60
  35. package/dist/chunk-RS3HRQWU.js.map +1 -0
  36. package/dist/{chunk-RLOXQZW3.js → chunk-TJ7QY3OC.js} +3 -3
  37. package/dist/{chunk-RLOXQZW3.js.map → chunk-TJ7QY3OC.js.map} +1 -1
  38. package/dist/controls/index.d.ts +5 -4
  39. package/dist/controls/index.js +3 -3
  40. package/dist/dragdrop/index.d.ts +9 -8
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +152 -57
  43. package/dist/index.js +315 -200
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +27 -11
  46. package/dist/layers/index.js +2 -3
  47. package/dist/loupe/index.d.ts +8 -7
  48. package/dist/loupe/index.js +1 -2
  49. package/dist/passthrough/weasel-canvas.d.ts +1 -3
  50. package/dist/passthrough/weasel-canvas.js +1 -1
  51. package/dist/passthrough/weasel-canvas.js.map +1 -1
  52. package/dist/passthrough/weasel-ui.d.ts +171 -22
  53. package/dist/passthrough/weasel-ui.js +1 -2
  54. package/dist/primitives/index.js +3 -4
  55. package/dist/state/index.d.ts +6 -3
  56. package/dist/state/index.js +3 -2
  57. package/dist/state/index.js.map +1 -1
  58. package/dist/styles.css +21 -4
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.d.ts +22 -9
  61. package/dist/ui/layers/index.js +1 -2
  62. package/dist/undo/index.d.ts +6 -6
  63. package/package.json +7 -7
  64. package/src/annotations/AnnotationTargets.tsx +6 -1
  65. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  66. package/src/annotations/ExportMenu.test.tsx +16 -0
  67. package/src/annotations/ExportMenu.tsx +0 -5
  68. package/src/annotations/types.ts +4 -1
  69. package/src/canvas/useOrbit.test.ts +7 -3
  70. package/src/canvas/usePanZoom.test.ts +7 -3
  71. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  72. package/src/chrome/builtins.test.ts +4 -0
  73. package/src/chrome/builtins.tsx +18 -0
  74. package/src/chrome/index.ts +1 -1
  75. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  76. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  80. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  81. package/src/chrome/regions/regions.test.tsx +45 -1
  82. package/src/chrome/types.ts +21 -3
  83. package/src/config/builder.test.ts +56 -2
  84. package/src/config/builder.ts +73 -13
  85. package/src/config/index.ts +16 -1
  86. package/src/config/path.test.ts +109 -0
  87. package/src/config/path.ts +76 -0
  88. package/src/config/resolve.test.ts +93 -2
  89. package/src/config/resolve.ts +72 -29
  90. package/src/config/types.ts +99 -9
  91. package/src/config/visible.ts +6 -4
  92. package/src/controls/ControlPanel.stories.tsx +38 -4
  93. package/src/controls/ControlPanel.test.tsx +152 -0
  94. package/src/controls/ControlPanel.tsx +134 -40
  95. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  96. package/src/dragdrop/Palette.tsx +4 -3
  97. package/src/dragdrop/dragDrop.test.tsx +35 -12
  98. package/src/index.ts +18 -2
  99. package/src/instrument/serializers.ts +36 -0
  100. package/src/instrument/types.ts +8 -7
  101. package/src/lab/Lab.test.tsx +36 -1
  102. package/src/lab/Lab.tsx +12 -3
  103. package/src/lab/LabContext.ts +5 -1
  104. package/src/lab/LabShell.less +17 -2
  105. package/src/lab/LabShell.test.tsx +8 -0
  106. package/src/lab/LabShell.tsx +3 -1
  107. package/src/layers/AGENTS.md +29 -7
  108. package/src/layers/LayerList.less +16 -0
  109. package/src/layers/LayerList.stories.tsx +66 -0
  110. package/src/layers/LayerList.test.tsx +117 -5
  111. package/src/layers/LayerList.tsx +192 -53
  112. package/src/layers/index.ts +1 -1
  113. package/src/passthrough/weasel-ui.ts +5 -0
  114. package/src/primitives/FloatingPanel.test.tsx +8 -3
  115. package/src/primitives/ZoomControl.less +6 -2
  116. package/src/primitives/ZoomControl.tsx +1 -0
  117. package/src/state/helpers.ts +1 -1
  118. package/src/state/store.test.ts +233 -1
  119. package/src/state/store.ts +57 -24
  120. package/src/state/types.ts +44 -3
  121. package/src/state/useTrialState.ts +1 -1
  122. package/src/trial/Trial.config.test.tsx +44 -0
  123. package/src/trial/Trial.less +4 -0
  124. package/src/trial/Trial.targets.test.tsx +96 -0
  125. package/src/trial/Trial.test.tsx +171 -14
  126. package/src/trial/Trial.tsx +7 -4
  127. package/src/trial/TrialChrome.tsx +32 -7
  128. package/src/trial/TrialTitleBar.tsx +7 -3
  129. package/src/trial/index.ts +1 -0
  130. package/src/trial/trialOps.test.ts +49 -1
  131. package/src/trial/trialOps.ts +24 -6
  132. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  133. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  134. package/dist/chunk-53AZ2O2N.js.map +0 -1
  135. package/dist/chunk-BMQWLKOL.js.map +0 -1
  136. package/dist/chunk-D7QIPGEB.js.map +0 -1
  137. package/dist/chunk-DG2IFMLA.js +0 -29970
  138. package/dist/chunk-DG2IFMLA.js.map +0 -1
  139. package/dist/chunk-J6SBFZYT.js +0 -7121
  140. package/dist/chunk-J6SBFZYT.js.map +0 -1
  141. package/dist/chunk-LJSIUFYD.js.map +0 -1
  142. package/dist/chunk-MIT3ISW4.js +0 -100
  143. package/dist/chunk-MIT3ISW4.js.map +0 -1
  144. package/dist/chunk-MSR7EVS6.js.map +0 -1
  145. package/dist/chunk-PP7ZWAMV.js.map +0 -1
@@ -39,9 +39,11 @@ export type LeafPatch = Annotations & { kind?: string };
39
39
 
40
40
  /** What a rule is given for the leaf it is deciding about. */
41
41
  export interface ConfigRuleContext {
42
- /** The config key this leaf writes. */
42
+ /** The leaf's own key — its last path segment, and what a label is titled
43
+ * from. */
43
44
  key: string;
44
- /** Dotted path within the schema. Equal to `key` while schemas are flat. */
45
+ /** Dotted path within the schema, which is the path the value is written
46
+ * at. Equal to `key` for a leaf sitting at the root. */
45
47
  path: string;
46
48
  /** The leaf's default value. A rule may read it but never change it. */
47
49
  default: unknown;
@@ -57,17 +59,29 @@ export interface ConfigRuleContext {
57
59
  */
58
60
  export type ConfigRule = (ctx: ConfigRuleContext) => LeafPatch | null;
59
61
 
60
- /** A presentational bucket of leaves, rendered under one heading. */
62
+ /** A presentational bucket of nodes, rendered under one heading. Buckets
63
+ * siblings: a section never nests a value, which is what separates it from
64
+ * `f.group`. */
61
65
  export interface SectionSpec {
66
+ /** Dotted path of the group whose children this buckets. `''` is the root,
67
+ * which is where every section of a flat schema sits. */
68
+ at: string;
62
69
  label: string;
70
+ /** Full dotted paths, so a section under a group names its children the way
71
+ * everything else does. */
63
72
  paths: readonly string[];
73
+ /** Whether the section opens folded. Set, the panel folds this section
74
+ * whether or not it was given a panel-wide `collapse`; a fold the reader
75
+ * has since toggled outranks it. */
76
+ collapsed?: boolean;
64
77
  }
65
78
 
66
79
  /** A schema resolved against a set of rules: the vocabulary weasel-ui renders,
67
80
  * plus the three things labkit keeps on the side because `PrefLeaf` has no
68
81
  * field for them. */
69
82
  export interface ResolvedConfig {
70
- /** Flat: every leaf is a direct child, so a leaf's path is its config key. */
83
+ /** The schema as a `PrefGroup` tree: an `f.group` is a nested group, so a
84
+ * leaf's dotted path within it is the path its value is written at. */
71
85
  group: PrefGroup;
72
86
  sections: readonly SectionSpec[];
73
87
  showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
@@ -75,14 +89,34 @@ export interface ResolvedConfig {
75
89
  renderers: Readonly<Record<string, ControlRenderer>>;
76
90
  }
77
91
 
92
+ /** How a node names the section it belongs to: the heading, and how that
93
+ * section opens. Every node in one section repeats the heading; only one of
94
+ * them has to say `collapsed`. */
95
+ export interface SectionOption {
96
+ label: string;
97
+ collapsed?: boolean;
98
+ }
99
+
78
100
  /** Per-node extras that do not belong on a `PrefLeaf`. */
79
- export interface NodeOptions {
80
- section?: string;
81
- showIf?: (config: Record<string, unknown>) => boolean;
101
+ export interface NodeOptions extends BranchOptions {
82
102
  render?: ControlRenderer;
83
103
  validate?: (leaf: PrefLeaf) => string[];
84
104
  }
85
105
 
106
+ /** What a branch can say about itself, beyond its children. */
107
+ export interface BranchOptions {
108
+ section?: SectionOption;
109
+ /** Show this node only while the predicate holds. On a group it hides the
110
+ * whole subtree; the values stay in config either way. */
111
+ showIf?: (config: Record<string, unknown>) => boolean;
112
+ }
113
+
114
+ /** A group's own annotations: what it is called and, optionally, why. */
115
+ export interface BranchAnnotations {
116
+ name?: string;
117
+ description?: string;
118
+ }
119
+
86
120
  /** The builder's leaf: a kind (or null, to be decided by rules), a default,
87
121
  * an annotation bag, and the extras above. */
88
122
  export interface ConfigNode<T = unknown> {
@@ -92,15 +126,71 @@ export interface ConfigNode<T = unknown> {
92
126
  readonly options: Readonly<NodeOptions>;
93
127
  }
94
128
 
129
+ /** The builder's branch: named children, nested as deeply as the schema
130
+ * wants. A branch nests the value too — `grid: f.group({ size })` puts the
131
+ * value at `grid.size`, where `.section('Grid')` would have left it at
132
+ * `size`. */
133
+ export interface ConfigBranch<S extends ConfigShape = ConfigShape> {
134
+ readonly children: S;
135
+ readonly annotations: Readonly<BranchAnnotations>;
136
+ readonly options: Readonly<BranchOptions>;
137
+ }
138
+
139
+ /** Either half of a schema tree. */
140
+ export type ConfigEntry = ConfigNode | ConfigBranch;
141
+
142
+ /** The children of a schema or a group. */
143
+ export type ConfigShape = { readonly [key: string]: ConfigEntry };
144
+
95
145
  /** The value type a node produces. */
96
146
  export type NodeValue<N> = N extends ConfigNode<infer T> ? T : never;
97
147
 
148
+ /** The value type a schema entry produces — a leaf's own, or a branch's
149
+ * nested record. */
150
+ export type EntryValue<E> =
151
+ E extends ConfigBranch<infer S> ? InferConfig<S> : E extends ConfigNode<infer T> ? T : never;
152
+
98
153
  /** The config type a builder shape produces. */
99
- export type InferConfig<S> = { [K in keyof S]: NodeValue<S[K]> };
154
+ export type InferConfig<S> = { [K in keyof S]: EntryValue<S[K]> };
155
+
156
+ /** Whether a config value has children a path can descend into. Arrays and
157
+ * functions are values, not branches. */
158
+ type Branching<V> = V extends readonly unknown[]
159
+ ? false
160
+ : V extends (...args: never[]) => unknown
161
+ ? false
162
+ : V extends object
163
+ ? true
164
+ : false;
165
+
166
+ /**
167
+ * Every dotted path a config offers, a group's own path included. A config
168
+ * whose shape is not known — `unknown`, or a bare record — gives `string`,
169
+ * which is what keeps a generic instrument writable.
170
+ */
171
+ export type ConfigPath<T> = unknown extends T
172
+ ? string
173
+ : T extends object
174
+ ? {
175
+ [K in keyof T & string]: Branching<T[K]> extends true ? K | `${K}.${ConfigPath<T[K]>}` : K;
176
+ }[keyof T & string]
177
+ : never;
178
+
179
+ /** The type at a dotted path, or `unknown` where the path is not one the
180
+ * config's type spells out. */
181
+ export type ValueAtPath<T, P extends string> = unknown extends T
182
+ ? unknown
183
+ : P extends `${infer Head}.${infer Rest}`
184
+ ? Head extends keyof T
185
+ ? ValueAtPath<T[Head], Rest>
186
+ : unknown
187
+ : P extends keyof T
188
+ ? T[P]
189
+ : unknown;
100
190
 
101
191
  /** An instrument's config, declared once. */
102
192
  export interface ConfigSchema<TC> {
103
- readonly nodes: Readonly<Record<string, ConfigNode>>;
193
+ readonly nodes: ConfigShape;
104
194
  /** The starting config — what `defaultConfig()` would have returned. */
105
195
  defaults(): TC;
106
196
  }
@@ -1,9 +1,11 @@
1
+ import { schemaNodeAtPath } from './path';
1
2
  import type { ResolvedConfig } from './types';
2
3
 
3
4
  /**
4
- * Whether a leaf's row should be drawn right now.
5
+ * Whether the row at a dotted path should be drawn right now. A group answers
6
+ * for its whole subtree — hiding one hides everything under it.
5
7
  *
6
- * `hidden` is the leaf's own static flag; `showIf` is the schema's predicate
8
+ * `hidden` is the node's own static flag; `showIf` is the schema's predicate
7
9
  * over the live config. Both are presentational — a hidden leaf keeps its
8
10
  * value and the instrument still reads it.
9
11
  */
@@ -13,8 +15,8 @@ export function isLeafVisible(
13
15
  config: Record<string, unknown>,
14
16
  showHidden = false,
15
17
  ): boolean {
16
- const leaf = resolved.group.children[path];
17
- if (leaf && 'hidden' in leaf && leaf.hidden === true && !showHidden) return false;
18
+ const node = schemaNodeAtPath(resolved.group, path);
19
+ if (node && 'hidden' in node && node.hidden === true && !showHidden) return false;
18
20
  const predicate = resolved.showIf.get(path);
19
21
  return predicate ? predicate(config) : true;
20
22
  }
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { PropertyRow } from '@weasel-js/ui';
3
3
  import { useState } from 'react';
4
4
  import { f } from '../config/builder';
5
+ import { withValueAtPath } from '../config/path';
5
6
  import { resolveConfigSchema } from '../config/resolve';
6
7
  import type { ConfigRule, ControlRenderer } from '../config/types';
7
8
  import { ControlPanel } from './ControlPanel';
@@ -60,7 +61,7 @@ function Harness({ fields }: { fields: ConfigField[] }) {
60
61
  <ControlPanel
61
62
  fields={fields}
62
63
  config={config}
63
- setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
64
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
64
65
  />
65
66
  );
66
67
  }
@@ -102,7 +103,7 @@ function SchemaHarness({
102
103
  <ControlPanel
103
104
  schema={schema}
104
105
  config={config}
105
- setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
106
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
106
107
  renderers={renderers}
107
108
  />
108
109
  );
@@ -169,7 +170,7 @@ export const Described: Story = {
169
170
  <ControlPanel
170
171
  schema={schema}
171
172
  config={config}
172
- setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
173
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
173
174
  />
174
175
  );
175
176
  },
@@ -195,8 +196,41 @@ export const Conditional: Story = {
195
196
  <ControlPanel
196
197
  schema={schema}
197
198
  config={config}
198
- setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
199
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
199
200
  />
200
201
  );
201
202
  },
202
203
  };
204
+
205
+ const nested = f.schema({
206
+ showGrid: f.boolean(true),
207
+ grid: f
208
+ .group({
209
+ size: f.number(20).range(5, 80).step(5).label('Cell size'),
210
+ color: f.color('#3a86ff'),
211
+ })
212
+ .describe('Written at config.grid.size and config.grid.color.'),
213
+ export: f.group({
214
+ format: f.enum('png', ['png', 'svg']),
215
+ scale: f.number(2).range(1, 4).step(1),
216
+ }),
217
+ });
218
+
219
+ /** `f.group` nests the value as well as the heading: these rows write to
220
+ * `grid.size` and `export.scale`, not to `size` and `scale`. */
221
+ export const Nested: Story = {
222
+ render: () => {
223
+ const schema = resolveConfigSchema(nested, []);
224
+ const [config, setConfig] = useState<Record<string, unknown>>(nested.defaults());
225
+ return (
226
+ <>
227
+ <ControlPanel
228
+ schema={schema}
229
+ config={config}
230
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
231
+ />
232
+ <pre>{JSON.stringify(config, null, 2)}</pre>
233
+ </>
234
+ );
235
+ },
236
+ };
@@ -443,3 +443,155 @@ describe('<ControlPanel> suffix', () => {
443
443
  expect(container.textContent).toContain('px');
444
444
  });
445
445
  });
446
+
447
+ describe('<ControlPanel> collapsible sections', () => {
448
+ const schema = resolveConfigSchema(
449
+ f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
450
+ [],
451
+ );
452
+ const config = { showGrid: true, seed: 0 };
453
+
454
+ it('draws no twisty by default', () => {
455
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
456
+ expect(screen.queryByRole('button', { name: 'Advanced' })).toBeNull();
457
+ expect(screen.getByLabelText('Seed')).toBeVisible();
458
+ });
459
+
460
+ it('folds a section away when the panel starts closed', () => {
461
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} collapse="closed" />);
462
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
463
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
464
+ expect(screen.getByLabelText('Seed')).toBeVisible();
465
+ });
466
+
467
+ it('hands a controlled panel its toggles instead of acting on them', () => {
468
+ const onCollapse = vi.fn();
469
+ render(
470
+ <ControlPanel
471
+ schema={schema}
472
+ config={config}
473
+ setConfig={vi.fn()}
474
+ collapsed={{ Advanced: true }}
475
+ onCollapse={onCollapse}
476
+ />,
477
+ );
478
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
479
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
480
+ expect(onCollapse).toHaveBeenCalledWith('Advanced', false);
481
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
482
+ });
483
+
484
+ describe('a section the schema declares closed', () => {
485
+ const closedSchema = resolveConfigSchema(
486
+ f.schema({
487
+ showGrid: f.boolean(true),
488
+ seed: f.number(0).section('Advanced', { collapsed: true }),
489
+ }),
490
+ [],
491
+ );
492
+
493
+ it('opens folded, and is foldable, with no collapse props at all', () => {
494
+ render(<ControlPanel schema={closedSchema} config={config} setConfig={vi.fn()} />);
495
+ expect(screen.getByLabelText('Seed')).not.toBeVisible();
496
+ fireEvent.click(screen.getByRole('button', { name: 'Advanced' }));
497
+ expect(screen.getByLabelText('Seed')).toBeVisible();
498
+ });
499
+
500
+ it('yields to a remembered fold, so a toggle the reader made survives', () => {
501
+ render(
502
+ <ControlPanel
503
+ schema={closedSchema}
504
+ config={config}
505
+ setConfig={vi.fn()}
506
+ collapsed={{ Advanced: false }}
507
+ onCollapse={vi.fn()}
508
+ />,
509
+ );
510
+ expect(screen.getByLabelText('Seed')).toBeVisible();
511
+ });
512
+ });
513
+ });
514
+
515
+ describe('<ControlPanel> nested groups', () => {
516
+ const nested = () =>
517
+ resolveConfigSchema(
518
+ f.schema({
519
+ showGrid: f.boolean(true),
520
+ grid: f.group({ size: f.number(20).label('Cell size'), color: f.color('#ffffff') }),
521
+ }),
522
+ [],
523
+ );
524
+
525
+ it('renders a nested group under its own heading', () => {
526
+ render(
527
+ <ControlPanel
528
+ schema={nested()}
529
+ config={{ showGrid: true, grid: { size: 20, color: '#ffffff' } }}
530
+ setConfig={vi.fn()}
531
+ />,
532
+ );
533
+ const group = screen.getByRole('heading', { name: 'Grid' }).parentElement;
534
+ expect(group?.textContent).toContain('Cell size');
535
+ });
536
+
537
+ it('reads a nested leaf value out of the config tree', () => {
538
+ render(
539
+ <ControlPanel
540
+ schema={nested()}
541
+ config={{ showGrid: true, grid: { size: 44, color: '#ffffff' } }}
542
+ setConfig={vi.fn()}
543
+ />,
544
+ );
545
+ expect((screen.getByLabelText('Cell size') as HTMLInputElement).value).toBe('44');
546
+ });
547
+
548
+ it('falls back to the leaf default when the branch is missing', () => {
549
+ render(<ControlPanel schema={nested()} config={{ showGrid: true }} setConfig={vi.fn()} />);
550
+ expect((screen.getByLabelText('Cell size') as HTMLInputElement).value).toBe('20');
551
+ });
552
+
553
+ it('writes back with the full dotted path', () => {
554
+ const setConfig = vi.fn();
555
+ render(
556
+ <ControlPanel
557
+ schema={nested()}
558
+ config={{ showGrid: true, grid: { size: 20, color: '#ffffff' } }}
559
+ setConfig={setConfig}
560
+ />,
561
+ );
562
+ fireEvent.change(screen.getByLabelText('Cell size'), { target: { value: '40' } });
563
+ expect(setConfig).toHaveBeenCalledWith('grid.size', 40);
564
+ });
565
+
566
+ it('hides a whole group whose showIf is false', () => {
567
+ const schema = resolveConfigSchema(
568
+ f.schema({
569
+ showGrid: f.boolean(true),
570
+ grid: f.group({ size: f.number(20).label('Cell size') }).showIf((c) => c.showGrid === true),
571
+ }),
572
+ [],
573
+ );
574
+ render(
575
+ <ControlPanel
576
+ schema={schema}
577
+ config={{ showGrid: false, grid: { size: 20 } }}
578
+ setConfig={vi.fn()}
579
+ />,
580
+ );
581
+ expect(screen.queryByLabelText('Cell size')).not.toBeInTheDocument();
582
+ expect(screen.queryByRole('heading', { name: 'Grid' })).not.toBeInTheDocument();
583
+ });
584
+
585
+ it('takes a renderer keyed by the nested path', () => {
586
+ const schema = nested();
587
+ render(
588
+ <ControlPanel
589
+ schema={schema}
590
+ config={{ showGrid: true, grid: { size: 44, color: '#ffffff' } }}
591
+ setConfig={vi.fn()}
592
+ renderers={{ 'grid.size': (ctx) => <span>size:{String(ctx.value)}</span> }}
593
+ />,
594
+ );
595
+ expect(screen.getByText('size:44')).toBeInTheDocument();
596
+ });
597
+ });
@@ -2,10 +2,15 @@ import { isBuiltinToolPref } from '@weasel-js/core';
2
2
  import {
3
3
  CheckboxRow,
4
4
  ColorRow,
5
+ isPrefLeaf,
5
6
  NumberRow,
7
+ type PrefGroup,
6
8
  type PrefLeaf,
9
+ type PropertyAlign,
10
+ type PropertyDensity,
7
11
  PropertyGroup,
8
12
  PropertyList,
13
+ type PropertyListPack,
9
14
  PropertyRow,
10
15
  type PropertyRowLayout,
11
16
  SelectRow,
@@ -13,9 +18,10 @@ import {
13
18
  TextRow,
14
19
  ToggleRow,
15
20
  } from '@weasel-js/ui';
16
- import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
21
+ import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
17
22
  import { fromConfigFields } from '../config/fromConfigField';
18
- import type { ControlRenderer, ResolvedConfig } from '../config/types';
23
+ import { schemaNodeAtPath, valueAtPath } from '../config/path';
24
+ import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
19
25
  import { isLeafVisible } from '../config/visible';
20
26
  import type { ConfigField } from './types';
21
27
 
@@ -35,7 +41,9 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
35
41
  /** @deprecated Pass `schema`. A field list is adapted into one internally. */
36
42
  fields?: ConfigField[];
37
43
  config: TC;
38
- setConfig: (key: keyof TC, value: unknown) => void;
44
+ /** Writes one value. The path is dotted for a leaf inside an `f.group`, and
45
+ * the bare key for one at the root. */
46
+ setConfig: (path: string, value: unknown) => void;
39
47
  /**
40
48
  * Control overrides and app-defined kinds, PrefsForm-style. Keys are config
41
49
  * paths (checked first) or leaf kinds. A renderer returning `null` collapses
@@ -44,9 +52,33 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
44
52
  renderers?: Record<string, ControlRenderer>;
45
53
  /** How rows pack into the two-column grid. Defaults to `'pairs'`. */
46
54
  pack?: ControlPack;
47
- /** Where a row's label sits relative to its control. Defaults to `'block'`
48
- * — above it, which is what leaves a paired row room for its value. */
55
+ /**
56
+ * Where a row's label sits relative to its control. Unset takes each row's
57
+ * own orientation — `block` (label above, which is what leaves a paired row
58
+ * room for its value) for most, `inline` for colors and checkboxes.
59
+ */
49
60
  layout?: PropertyRowLayout;
61
+ /** Room the rows get. Passed straight to the property list. */
62
+ density?: PropertyDensity;
63
+ /** Cross-axis alignment of an inline row's label and control. */
64
+ align?: PropertyAlign;
65
+ /**
66
+ * Fold each section away behind a twisty, starting `'open'` or `'closed'`.
67
+ * Unset draws the heading alone, as the panel always has — unless a section
68
+ * in the schema declares how it opens, which makes that section foldable on
69
+ * its own and outranks this for that one section.
70
+ */
71
+ collapse?: 'open' | 'closed';
72
+ /**
73
+ * Which sections are folded, keyed as `onCollapse` reports them. Given, the
74
+ * panel keeps no state of its own: every toggle arrives at `onCollapse`
75
+ * instead, which is where a lab that remembers a trial's sections writes
76
+ * them.
77
+ */
78
+ collapsed?: Readonly<Record<string, boolean>>;
79
+ /** A fold moved. The key is a section's label — prefixed by its group's
80
+ * dotted path when the section sits inside one — or a group's own path. */
81
+ onCollapse?: (key: string, collapsed: boolean) => void;
50
82
  /** Draw leaves marked `hidden`. */
51
83
  showHidden?: boolean;
52
84
  className?: string;
@@ -62,55 +94,114 @@ export function ControlPanel<TC extends Record<string, unknown>>({
62
94
  setConfig,
63
95
  renderers,
64
96
  pack = 'pairs',
65
- layout = 'block',
97
+ layout,
98
+ density,
99
+ align,
100
+ collapse,
101
+ collapsed,
102
+ onCollapse,
66
103
  showHidden = false,
67
104
  className,
68
105
  }: ControlPanelProps<TC>) {
69
106
  const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
70
107
 
71
- const paths = Object.keys(resolved.group.children);
72
- const sectioned = new Set(resolved.sections.flatMap((s) => s.paths));
73
- const loose = paths.filter((p) => !sectioned.has(p));
108
+ const gridPack: PropertyListPack = pack === 'one-up' ? 'auto-color' : 'pairs';
109
+ // A section that declares how it opens is foldable whether or not the lab
110
+ // asked for folds — there is nothing else for the declaration to mean.
111
+ const folds =
112
+ collapse !== undefined ||
113
+ collapsed !== undefined ||
114
+ onCollapse !== undefined ||
115
+ resolved.sections.some((s) => s.collapsed !== undefined);
116
+ const startsFolded = collapse === 'closed';
74
117
 
75
- const row = (path: string): ReactNode => (
76
- <ControlRow
77
- key={path}
78
- path={path}
79
- resolved={resolved}
80
- config={config}
81
- setConfig={setConfig}
82
- renderers={renderers}
83
- pack={pack}
84
- layout={layout}
85
- showHidden={showHidden}
86
- />
87
- );
118
+ const fold = (key: string, declared?: boolean) => ({
119
+ pack: gridPack,
120
+ collapsible: folds,
121
+ defaultCollapsed: declared ?? startsFolded,
122
+ collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
123
+ onCollapsedChange: onCollapse ? (next: boolean) => onCollapse(key, next) : undefined,
124
+ });
125
+
126
+ /** One node, which is either a group to recurse into or a row to draw. */
127
+ const node = (path: string): ReactNode => {
128
+ const found = schemaNodeAtPath(resolved.group, path);
129
+ if (!found) return null;
130
+ if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
131
+ if (isPrefLeaf(found)) {
132
+ return (
133
+ <ControlRow
134
+ key={path}
135
+ path={path}
136
+ leaf={found}
137
+ resolved={resolved}
138
+ config={config}
139
+ setConfig={setConfig}
140
+ renderers={renderers}
141
+ pack={pack}
142
+ layout={layout}
143
+ />
144
+ );
145
+ }
146
+ // A group with no name organizes without heading it — core's rule for an
147
+ // empty `PrefGroup.name` — so it contributes its rows and no chrome.
148
+ if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
149
+ return (
150
+ <PropertyGroup key={path} title={found.name} {...fold(path)}>
151
+ {body(found, path)}
152
+ </PropertyGroup>
153
+ );
154
+ };
155
+
156
+ /** One group's children: its loose nodes, then its sections. */
157
+ const body = (group: PrefGroup, at: string): ReactNode => {
158
+ const sections = resolved.sections.filter((s) => s.at === at);
159
+ const sectioned = new Set(sections.flatMap((s) => s.paths));
160
+ const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
161
+ return (
162
+ <>
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
+ ))}
173
+ </>
174
+ );
175
+ };
88
176
 
89
- const gridPack = pack === 'one-up' ? 'auto-color' : 'pairs';
90
177
  return (
91
178
  <PropertyList
92
179
  pack={gridPack}
180
+ density={density}
181
+ align={align}
93
182
  className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
94
183
  >
95
- {loose.map(row)}
96
- {resolved.sections.map((section) => (
97
- <PropertyGroup key={section.label} title={section.label} pack={gridPack}>
98
- {section.paths.map(row)}
99
- </PropertyGroup>
100
- ))}
184
+ {body(resolved.group, '')}
101
185
  </PropertyList>
102
186
  );
103
187
  }
104
188
 
189
+ /** How a section is keyed for folding: its label at the root, and its group's
190
+ * path in front of it anywhere else, so two groups may both have an
191
+ * `Advanced`. */
192
+ function sectionKey(section: SectionSpec): string {
193
+ return section.at === '' ? section.label : `${section.at}.${section.label}`;
194
+ }
195
+
105
196
  interface ControlRowProps<TC extends Record<string, unknown>> {
106
197
  path: string;
198
+ leaf: PrefLeaf;
107
199
  resolved: ResolvedConfig;
108
200
  config: TC;
109
- setConfig: (key: keyof TC, value: unknown) => void;
201
+ setConfig: (path: string, value: unknown) => void;
110
202
  renderers?: Record<string, ControlRenderer>;
111
203
  pack: ControlPack;
112
- layout: PropertyRowLayout;
113
- showHidden: boolean;
204
+ layout?: PropertyRowLayout;
114
205
  }
115
206
 
116
207
  /** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
@@ -121,21 +212,17 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
121
212
 
122
213
  function ControlRow<TC extends Record<string, unknown>>({
123
214
  path,
215
+ leaf,
124
216
  resolved,
125
217
  config,
126
218
  setConfig,
127
219
  renderers,
128
220
  pack,
129
221
  layout,
130
- showHidden,
131
222
  }: ControlRowProps<TC>) {
132
- const leaf = resolved.group.children[path] as PrefLeaf | undefined;
133
- if (!leaf || !('kind' in leaf)) return null;
134
- if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
135
-
136
- const write = (value: unknown): void => setConfig(path as keyof TC, value);
223
+ const write = (value: unknown): void => setConfig(path, value);
137
224
  const fallback = extra<unknown>(leaf, 'default');
138
- const value = config[path] ?? fallback;
225
+ const value = valueAtPath(config, path) ?? fallback;
139
226
 
140
227
  // Most specific wins, and within a tier the lab's entry beats the
141
228
  // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
@@ -201,6 +288,7 @@ function ControlRow<TC extends Record<string, unknown>>({
201
288
  label={label}
202
289
  value={read<boolean>()}
203
290
  onChange={write}
291
+ layout={layout}
204
292
  description={description}
205
293
  />
206
294
  );
@@ -234,7 +322,13 @@ function ControlRow<TC extends Record<string, unknown>>({
234
322
  );
235
323
  case 'color':
236
324
  return (
237
- <ColorRow label={label} value={read<string>()} onChange={write} description={description} />
325
+ <ColorRow
326
+ label={label}
327
+ value={read<string>()}
328
+ onChange={write}
329
+ layout={layout}
330
+ description={description}
331
+ />
238
332
  );
239
333
  case 'paint':
240
334
  case 'object':