@weasel-js/labkit 1.4.2 → 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 (105) hide show
  1. package/README.md +20 -2
  2. package/dist/_dts/{CanvasStackContext-kjILVnPj.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-A4R6v4ld.d.ts → frac-B5mB8LsJ.d.ts} +7 -4
  5. package/dist/_dts/{index-CsU9WhjM.d.ts → index-DelFfeV0.d.ts} +15 -7
  6. package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
  7. package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
  8. package/dist/_dts/types-x92Kfeme.d.ts +62 -0
  9. package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -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 +7 -6
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-UTOEDPNU.js → chunk-2RQPGOQF.js} +7 -6
  15. package/dist/chunk-2RQPGOQF.js.map +1 -0
  16. package/dist/{chunk-ULDW42CR.js → chunk-5TROQVQ2.js} +25 -21
  17. package/dist/chunk-5TROQVQ2.js.map +1 -0
  18. package/dist/{chunk-XKENZTNE.js → chunk-ASWKLRKJ.js} +65 -49
  19. package/dist/chunk-ASWKLRKJ.js.map +1 -0
  20. package/dist/{chunk-W2FJR5FF.js → chunk-C42T6DJT.js} +74 -76
  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-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
  25. package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  26. package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
  27. package/dist/chunk-I6JCVE24.js.map +1 -0
  28. package/dist/{chunk-NSOVI3AZ.js → chunk-ISCOFAUO.js} +6 -6
  29. package/dist/{chunk-NSOVI3AZ.js.map → chunk-ISCOFAUO.js.map} +1 -1
  30. package/dist/chunk-MHAGC6VD.js +7320 -0
  31. package/dist/chunk-MHAGC6VD.js.map +1 -0
  32. package/dist/{chunk-UDXOYZEC.js → chunk-QAGAHJKZ.js} +4 -4
  33. package/dist/{chunk-UDXOYZEC.js.map → chunk-QAGAHJKZ.js.map} +1 -1
  34. package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
  35. package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
  36. package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
  37. package/dist/{chunk-D5KQ5OY6.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 +6 -5
  41. package/dist/dragdrop/index.js +1 -2
  42. package/dist/index.d.ts +90 -23
  43. package/dist/index.js +199 -117
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +7 -6
  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 +88 -20
  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 +2 -2
  59. package/dist/surface/index.js +1 -2
  60. package/dist/ui/layers/index.js +1 -2
  61. package/dist/undo/index.d.ts +6 -6
  62. package/package.json +7 -7
  63. package/src/annotations/ExportMenu.test.tsx +16 -0
  64. package/src/annotations/ExportMenu.tsx +0 -5
  65. package/src/config/builder.test.ts +56 -2
  66. package/src/config/builder.ts +73 -13
  67. package/src/config/index.ts +16 -1
  68. package/src/config/path.test.ts +109 -0
  69. package/src/config/path.ts +76 -0
  70. package/src/config/resolve.test.ts +93 -2
  71. package/src/config/resolve.ts +72 -29
  72. package/src/config/types.ts +99 -9
  73. package/src/config/visible.ts +6 -4
  74. package/src/controls/ControlPanel.stories.tsx +38 -4
  75. package/src/controls/ControlPanel.test.tsx +114 -0
  76. package/src/controls/ControlPanel.tsx +99 -50
  77. package/src/index.ts +16 -1
  78. package/src/instrument/serializers.ts +36 -0
  79. package/src/instrument/types.ts +4 -2
  80. package/src/lab/Lab.test.tsx +29 -1
  81. package/src/lab/Lab.tsx +10 -1
  82. package/src/lab/LabShell.test.tsx +8 -0
  83. package/src/lab/LabShell.tsx +3 -1
  84. package/src/primitives/ZoomControl.less +6 -2
  85. package/src/primitives/ZoomControl.tsx +1 -0
  86. package/src/state/helpers.ts +1 -1
  87. package/src/state/store.test.ts +155 -1
  88. package/src/state/store.ts +30 -24
  89. package/src/state/types.ts +24 -3
  90. package/src/state/useTrialState.ts +1 -1
  91. package/src/trial/Trial.config.test.tsx +44 -0
  92. package/src/trial/Trial.tsx +3 -3
  93. package/src/trial/TrialChrome.tsx +10 -5
  94. package/src/trial/trialOps.ts +4 -8
  95. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  96. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  97. package/dist/chunk-54ZWZ5FQ.js +0 -29997
  98. package/dist/chunk-54ZWZ5FQ.js.map +0 -1
  99. package/dist/chunk-FJG4PHTL.js.map +0 -1
  100. package/dist/chunk-ISSVF5PT.js +0 -7263
  101. package/dist/chunk-ISSVF5PT.js.map +0 -1
  102. package/dist/chunk-ULDW42CR.js.map +0 -1
  103. package/dist/chunk-UTOEDPNU.js.map +0 -1
  104. package/dist/chunk-W2FJR5FF.js.map +0 -1
  105. package/dist/chunk-XKENZTNE.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { f } from './builder';
3
+ import type { ConfigOf, ConfigPath, ValueAtPath } from './types';
3
4
 
4
5
  describe('builder', () => {
5
6
  it('carries kind and default', () => {
@@ -63,7 +64,7 @@ describe('builder', () => {
63
64
  .section('Advanced')
64
65
  .showIf((c) => c.showGrid === true)
65
66
  .render(() => null);
66
- expect(n.options.section).toBe('Advanced');
67
+ expect(n.options.section).toEqual({ label: 'Advanced' });
67
68
  expect(n.options.showIf?.({ showGrid: true })).toBe(true);
68
69
  expect(n.options.render).toBeTypeOf('function');
69
70
  expect(n.annotations).toEqual({});
@@ -91,8 +92,61 @@ describe('builder / node option collisions', () => {
91
92
  ];
92
93
  for (const node of nodes) {
93
94
  expect(typeof node.options).toBe('object');
94
- expect(node.section('S').options.section).toBe('S');
95
+ expect(node.section('S').options.section).toEqual({ label: 'S' });
95
96
  expect(node.render(() => null).options.render).toBeTypeOf('function');
96
97
  }
97
98
  });
98
99
  });
100
+
101
+ describe('builder / groups', () => {
102
+ it('f.group nests its children under the key', () => {
103
+ const s = f.schema({
104
+ showGrid: f.boolean(true),
105
+ grid: f.group({ size: f.number(20), color: f.color('#fff') }),
106
+ });
107
+ expect(s.defaults()).toEqual({
108
+ showGrid: true,
109
+ grid: { size: 20, color: '#fff' },
110
+ });
111
+ });
112
+
113
+ it('nests groups within groups', () => {
114
+ const s = f.schema({ a: f.group({ b: f.group({ c: f.number(1) }) }) });
115
+ expect(s.defaults()).toEqual({ a: { b: { c: 1 } } });
116
+ });
117
+
118
+ it('chains a group label and description immutably', () => {
119
+ const base = f.group({ size: f.number(20) });
120
+ const a = base.label('Grid').describe('How the grid is drawn');
121
+ expect(a.annotations.name).toBe('Grid');
122
+ expect(a.annotations.description).toBe('How the grid is drawn');
123
+ expect(base.annotations.name).toBeUndefined();
124
+ });
125
+
126
+ it('holds a group section and showIf off the annotation bag', () => {
127
+ const g = f
128
+ .group({ size: f.number(20) })
129
+ .section('Advanced')
130
+ .showIf((c) => c.showGrid === true);
131
+ expect(g.options.section).toEqual({ label: 'Advanced' });
132
+ expect(g.options.showIf?.({ showGrid: true })).toBe(true);
133
+ expect(g.annotations).toEqual({});
134
+ });
135
+ });
136
+
137
+ describe('builder / nested types', () => {
138
+ // Compile-level: these annotations are the assertion, and tsc is what runs
139
+ // it. A flat `InferConfig` would not give `c.grid.size` a type at all.
140
+ it('infers a nested config type and the paths into it', () => {
141
+ const schema = f.schema({
142
+ showGrid: f.boolean(true),
143
+ grid: f.group({ size: f.number(20), color: f.color('#fff') }),
144
+ });
145
+ type Config = ConfigOf<typeof schema>;
146
+ const c: Config = schema.defaults();
147
+ const size: number = c.grid.size;
148
+ const path: ConfigPath<Config> = 'grid.size';
149
+ const value: ValueAtPath<Config, 'grid.size'> = 40;
150
+ expect([size, path, value]).toEqual([20, 'grid.size', 40]);
151
+ });
152
+ });
@@ -1,14 +1,24 @@
1
1
  import type { PrefLeaf } from '@weasel-js/ui';
2
2
  import type {
3
3
  Annotations,
4
+ BranchAnnotations,
5
+ BranchOptions,
6
+ ConfigBranch,
7
+ ConfigEntry,
4
8
  ConfigNode,
5
9
  ConfigOption,
6
10
  ConfigSchema,
11
+ ConfigShape,
7
12
  ControlRenderer,
8
13
  InferConfig,
9
14
  NodeOptions,
10
15
  } from './types';
11
16
 
17
+ /** Whether a schema entry is a branch rather than a leaf. */
18
+ export function isConfigBranch(entry: ConfigEntry): entry is ConfigBranch {
19
+ return 'children' in entry;
20
+ }
21
+
12
22
  /** Shared chaining surface. Every method clones, so a node can be reused as a
13
23
  * base for several leaves without one bleeding into the next. */
14
24
  abstract class BaseNode<T> implements ConfigNode<T> {
@@ -61,9 +71,10 @@ abstract class BaseNode<T> implements ConfigNode<T> {
61
71
  return this.ann({ pair });
62
72
  }
63
73
 
64
- /** Render under a named section heading. */
65
- section(section: string): this {
66
- return this.opt({ section });
74
+ /** Render under a named section heading. `collapsed` opens the section
75
+ * folded — say it on any one of the section's leaves. */
76
+ section(label: string, opts: { collapsed?: boolean } = {}): this {
77
+ return this.opt({ section: { label, ...opts } });
67
78
  }
68
79
 
69
80
  /** Show this row only while the predicate holds. Presentational — the value
@@ -174,6 +185,55 @@ class CustomNode<T> extends BaseNode<T> {
174
185
  }
175
186
  }
176
187
 
188
+ /**
189
+ * A branch of the schema: its children nest under its key, so a leaf inside
190
+ * one is read and written at a dotted path. Distinct from `.section()`, which
191
+ * puts a heading over sibling leaves and leaves their paths alone.
192
+ */
193
+ class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
194
+ constructor(
195
+ readonly children: S,
196
+ readonly annotations: Readonly<BranchAnnotations> = {},
197
+ readonly options: Readonly<BranchOptions> = {},
198
+ ) {}
199
+
200
+ private with(annotations: BranchAnnotations, options: BranchOptions): GroupNode<S> {
201
+ return new GroupNode(this.children, annotations, options);
202
+ }
203
+
204
+ /** Heading for the group's rows. Defaults to the key, title-cased. */
205
+ label(name: string): GroupNode<S> {
206
+ return this.with({ ...this.annotations, name }, this.options);
207
+ }
208
+
209
+ /** Longer help text. Carried on the resolved `PrefGroup`, where weasel-ui's
210
+ * `PrefsForm` draws it under the heading; `ControlPanel` has no place for
211
+ * it yet and shows the heading alone. */
212
+ describe(description: string): GroupNode<S> {
213
+ return this.with({ ...this.annotations, description }, this.options);
214
+ }
215
+
216
+ /** Render this whole group under a named section heading. */
217
+ section(label: string, opts: { collapsed?: boolean } = {}): GroupNode<S> {
218
+ return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
219
+ }
220
+
221
+ /** Show this group only while the predicate holds. Presentational — every
222
+ * value beneath it stays in config and the instrument still reads it. */
223
+ showIf(predicate: (config: Record<string, unknown>) => boolean): GroupNode<S> {
224
+ return this.with(this.annotations, { ...this.options, showIf: predicate });
225
+ }
226
+ }
227
+
228
+ /** Every default in a shape, nested the way the shape is. */
229
+ function shapeDefaults(shape: ConfigShape): Record<string, unknown> {
230
+ const out: Record<string, unknown> = {};
231
+ for (const [key, entry] of Object.entries(shape)) {
232
+ out[key] = isConfigBranch(entry) ? shapeDefaults(entry.children) : entry.default;
233
+ }
234
+ return out;
235
+ }
236
+
177
237
  const expand = <T extends string>(
178
238
  options: readonly T[] | readonly ConfigOption[],
179
239
  ): readonly ConfigOption[] =>
@@ -187,6 +247,8 @@ const expand = <T extends string>(
187
247
  * const config = f.schema({
188
248
  * showGrid: f.boolean(true),
189
249
  * cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
250
+ * // Nested: read and written at `grid.color`.
251
+ * grid: f.group({ color: f.color('#ffffff') }),
190
252
  * })
191
253
  * ```
192
254
  */
@@ -208,19 +270,17 @@ export const f = {
208
270
  * states a convention like "every `*Color` key is a color picker". */
209
271
  value: <T>(def: T): ValueNode<T> => new ValueNode(def),
210
272
 
273
+ /** A branch: its children nest under this key, so `grid: f.group({ size })`
274
+ * puts the value at `config.grid.size` and addresses it as `'grid.size'`.
275
+ * Groups nest as deeply as the schema wants. */
276
+ group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
277
+
211
278
  /** A leaf of a kind a lab supplies the control for, through `controls`. */
212
279
  custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
213
280
  new CustomNode(kind, def, {}, validate ? { validate } : {}),
214
281
 
215
- /** Collect leaves into an instrument's config. */
216
- schema<S extends Record<string, ConfigNode>>(nodes: S): ConfigSchema<InferConfig<S>> {
217
- return {
218
- nodes,
219
- defaults: () => {
220
- const out: Record<string, unknown> = {};
221
- for (const [key, node] of Object.entries(nodes)) out[key] = node.default;
222
- return out as InferConfig<S>;
223
- },
224
- };
282
+ /** Collect leaves and groups into an instrument's config. */
283
+ schema<S extends ConfigShape>(nodes: S): ConfigSchema<InferConfig<S>> {
284
+ return { nodes, defaults: () => shapeDefaults(nodes) as InferConfig<S> };
225
285
  },
226
286
  };
@@ -1,22 +1,37 @@
1
- export { f } from './builder';
1
+ export { f, isConfigBranch } from './builder';
2
2
  export { fromConfigFields } from './fromConfigField';
3
+ export {
4
+ fillConfigDefaults,
5
+ hasConfigPath,
6
+ schemaNodeAtPath,
7
+ valueAtPath,
8
+ withValueAtPath,
9
+ } from './path';
3
10
  export { resolveConfigSchema } from './resolve';
4
11
  export { applyRules, builtinRules, titleCase } from './rules';
5
12
  export type {
6
13
  Annotations,
14
+ BranchAnnotations,
15
+ BranchOptions,
16
+ ConfigBranch,
17
+ ConfigEntry,
7
18
  ConfigNode,
8
19
  ConfigOf,
9
20
  ConfigOption,
21
+ ConfigPath,
10
22
  ConfigRule,
11
23
  ConfigRuleContext,
12
24
  ConfigSchema,
25
+ ConfigShape,
13
26
  ControlRenderer,
27
+ EntryValue,
14
28
  InferConfig,
15
29
  LeafPatch,
16
30
  NodeOptions,
17
31
  NodeValue,
18
32
  ResolvedConfig,
19
33
  SectionSpec,
34
+ ValueAtPath,
20
35
  } from './types';
21
36
  export { useConfigSchema } from './useConfigSchema';
22
37
  export { isLeafVisible } from './visible';
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fillConfigDefaults, hasConfigPath, valueAtPath, withValueAtPath } from './path';
3
+
4
+ describe('valueAtPath', () => {
5
+ it('reads a single segment', () => {
6
+ expect(valueAtPath({ a: 1 }, 'a')).toBe(1);
7
+ });
8
+
9
+ it('reads down a nested path', () => {
10
+ expect(valueAtPath({ grid: { size: 20 } }, 'grid.size')).toBe(20);
11
+ });
12
+
13
+ it('returns undefined for a missing segment', () => {
14
+ expect(valueAtPath({ grid: {} }, 'grid.size')).toBeUndefined();
15
+ expect(valueAtPath({}, 'grid.size')).toBeUndefined();
16
+ });
17
+
18
+ it('returns undefined when the walk hits a non-object', () => {
19
+ expect(valueAtPath({ grid: 3 }, 'grid.size')).toBeUndefined();
20
+ expect(valueAtPath(null, 'a')).toBeUndefined();
21
+ });
22
+ });
23
+
24
+ describe('hasConfigPath', () => {
25
+ it('tells a missing key from one holding undefined', () => {
26
+ expect(hasConfigPath({ a: undefined }, 'a')).toBe(true);
27
+ expect(hasConfigPath({}, 'a')).toBe(false);
28
+ });
29
+
30
+ it('walks a nested path', () => {
31
+ expect(hasConfigPath({ grid: { size: 20 } }, 'grid.size')).toBe(true);
32
+ expect(hasConfigPath({ grid: { size: 20 } }, 'grid.color')).toBe(false);
33
+ expect(hasConfigPath({ grid: 3 }, 'grid.size')).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe('withValueAtPath', () => {
38
+ it('writes a single segment without touching the input', () => {
39
+ const before = { a: 1, b: 2 };
40
+ const after = withValueAtPath(before, 'a', 99);
41
+ expect(after).toEqual({ a: 99, b: 2 });
42
+ expect(before).toEqual({ a: 1, b: 2 });
43
+ });
44
+
45
+ it('copies every object on the way down', () => {
46
+ const before = { grid: { size: 20, color: '#fff' }, other: { keep: true } };
47
+ const after = withValueAtPath(before, 'grid.size', 40);
48
+ expect(after.grid).toEqual({ size: 40, color: '#fff' });
49
+ expect(before.grid.size).toBe(20);
50
+ expect(after.grid).not.toBe(before.grid);
51
+ // Untouched branches keep their identity, so a memo keyed on one holds.
52
+ expect(after.other).toBe(before.other);
53
+ });
54
+
55
+ it('creates a missing intermediate branch', () => {
56
+ expect(withValueAtPath({} as Record<string, unknown>, 'grid.size', 40)).toEqual({
57
+ grid: { size: 40 },
58
+ });
59
+ });
60
+
61
+ it('replaces a non-object standing where a branch belongs', () => {
62
+ expect(withValueAtPath({ grid: 3 } as Record<string, unknown>, 'grid.size', 40)).toEqual({
63
+ grid: { size: 40 },
64
+ });
65
+ });
66
+ });
67
+
68
+ describe('fillConfigDefaults', () => {
69
+ it('takes the stored value wherever it has one', () => {
70
+ expect(fillConfigDefaults({ a: 1 }, { a: 0, b: 2 })).toEqual({ a: 1, b: 2 });
71
+ });
72
+
73
+ it('fills a whole branch a stored config never had', () => {
74
+ expect(fillConfigDefaults({ showGrid: false }, { showGrid: true, grid: { size: 20 } })).toEqual(
75
+ {
76
+ showGrid: false,
77
+ grid: { size: 20 },
78
+ },
79
+ );
80
+ });
81
+
82
+ it('fills a gap inside a branch the stored config half-holds', () => {
83
+ expect(
84
+ fillConfigDefaults({ grid: { size: 40 } }, { grid: { size: 20, color: '#fff' } }),
85
+ ).toEqual({ grid: { size: 40, color: '#fff' } });
86
+ });
87
+
88
+ it('keeps a key the defaults no longer mention', () => {
89
+ expect(fillConfigDefaults({ gridSize: 40 }, { grid: { size: 20 } })).toEqual({
90
+ gridSize: 40,
91
+ grid: { size: 20 },
92
+ });
93
+ });
94
+
95
+ it('leaves the stored config untouched', () => {
96
+ const stored = { grid: { size: 40 } };
97
+ fillConfigDefaults(stored, { grid: { size: 20, color: '#fff' } });
98
+ expect(stored).toEqual({ grid: { size: 40 } });
99
+ });
100
+
101
+ it('takes a stored array whole rather than merging it element-wise', () => {
102
+ expect(fillConfigDefaults({ tags: ['a'] }, { tags: ['x', 'y'] })).toEqual({ tags: ['a'] });
103
+ });
104
+
105
+ it('fills from defaults when the stored config is not a record', () => {
106
+ expect(fillConfigDefaults(undefined, { a: 1 })).toEqual({ a: 1 });
107
+ expect(fillConfigDefaults(7, { a: 1 })).toEqual({ a: 1 });
108
+ });
109
+ });
@@ -0,0 +1,76 @@
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
+
3
+ /**
4
+ * Reading and writing a config tree by dotted path. A schema's leaves address
5
+ * their values this way, so a leaf nested under `f.group` and a flat one are
6
+ * reached by exactly the same call.
7
+ */
8
+
9
+ function isRecord(value: unknown): value is Record<string, unknown> {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ /** The value at a dotted path. `undefined` when a segment is missing or the
14
+ * walk hits something that is not a record. */
15
+ export function valueAtPath(config: unknown, path: string): unknown {
16
+ let cur = config;
17
+ for (const segment of path.split('.')) {
18
+ if (!isRecord(cur)) return undefined;
19
+ cur = cur[segment];
20
+ }
21
+ return cur;
22
+ }
23
+
24
+ /** Whether a dotted path is actually present, which a `undefined` value is
25
+ * not enough to tell you. */
26
+ export function hasConfigPath(config: unknown, path: string): boolean {
27
+ let cur = config;
28
+ for (const segment of path.split('.')) {
29
+ if (!isRecord(cur) || !(segment in cur)) return false;
30
+ cur = cur[segment];
31
+ }
32
+ return true;
33
+ }
34
+
35
+ function writeIn(node: unknown, segments: readonly string[], value: unknown): unknown {
36
+ const [head, ...rest] = segments;
37
+ if (head === undefined) return value;
38
+ const base = isRecord(node) ? node : {};
39
+ return { ...base, [head]: rest.length === 0 ? value : writeIn(base[head], rest, value) };
40
+ }
41
+
42
+ /** A copy of `config` with `value` written at a dotted path. Every record on
43
+ * the way down is copied and the input is left alone; a missing — or
44
+ * non-record — intermediate is replaced with the branch the path implies. */
45
+ export function withValueAtPath<T>(config: T, path: string, value: unknown): T {
46
+ return writeIn(config, path.split('.'), value) as T;
47
+ }
48
+
49
+ /**
50
+ * `stored` with every gap filled from `defaults`, down the whole tree.
51
+ *
52
+ * This is how a config written before its schema grew a branch still loads:
53
+ * the branch arrives at its defaults instead of `undefined`, and a key the
54
+ * defaults no longer mention is kept rather than dropped, so nothing is lost
55
+ * to a schema change the author has not finished making.
56
+ */
57
+ export function fillConfigDefaults<T>(stored: unknown, defaults: T): T {
58
+ if (!isRecord(defaults)) return (stored === undefined ? defaults : stored) as T;
59
+ const base = isRecord(stored) ? stored : {};
60
+ const out: Record<string, unknown> = { ...base };
61
+ for (const [key, value] of Object.entries(defaults)) {
62
+ out[key] = fillConfigDefaults(base[key], value);
63
+ }
64
+ return out as T;
65
+ }
66
+
67
+ /** A node in a resolved schema tree, by dotted path. Structural rather than
68
+ * `isPrefLeaf` so this stays free of a runtime import. */
69
+ export function schemaNodeAtPath(group: PrefGroup, path: string): PrefLeaf | PrefGroup | undefined {
70
+ let node: PrefLeaf | PrefGroup | undefined = group;
71
+ for (const segment of path.split('.')) {
72
+ if (node === undefined || !('children' in node)) return undefined;
73
+ node = node.children[segment];
74
+ }
75
+ return node;
76
+ }
@@ -1,3 +1,4 @@
1
+ import type { PrefGroup } from '@weasel-js/ui';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { f } from './builder';
3
4
  import { resolveConfigSchema } from './resolve';
@@ -88,8 +89,23 @@ describe('resolveConfigSchema', () => {
88
89
  [],
89
90
  );
90
91
  expect(r.sections).toEqual([
91
- { label: 'Two', paths: ['b', 'c'] },
92
- { label: 'One', paths: ['a'] },
92
+ { at: '', label: 'Two', paths: ['b', 'c'] },
93
+ { at: '', label: 'One', paths: ['a'] },
94
+ ]);
95
+ });
96
+
97
+ it('takes `collapsed` from any one leaf under the heading', () => {
98
+ const r = resolveConfigSchema(
99
+ f.schema({
100
+ a: f.number(1).section('Advanced'),
101
+ b: f.number(1).section('Advanced', { collapsed: true }),
102
+ c: f.number(1).section('Plain'),
103
+ }),
104
+ [],
105
+ );
106
+ expect(r.sections).toEqual([
107
+ { at: '', label: 'Advanced', paths: ['a', 'b'], collapsed: true },
108
+ { at: '', label: 'Plain', paths: ['c'] },
93
109
  ]);
94
110
  });
95
111
 
@@ -117,3 +133,78 @@ describe('resolveConfigSchema', () => {
117
133
  expect(Object.keys(r.group.children)).toEqual(['z', 'a']);
118
134
  });
119
135
  });
136
+
137
+ describe('resolveConfigSchema / nested groups', () => {
138
+ const nested = () =>
139
+ resolveConfigSchema(
140
+ f.schema({
141
+ showGrid: f.boolean(true),
142
+ grid: f
143
+ .group({
144
+ size: f.number(20).range(5, 80),
145
+ color: f.color('#ffffff').showIf((c) => c.showGrid === true),
146
+ })
147
+ .label('Grid'),
148
+ }),
149
+ [],
150
+ );
151
+
152
+ it('emits a nested PrefGroup, not a flattened child', () => {
153
+ const r = nested();
154
+ const grid = r.group.children.grid as PrefGroup;
155
+ expect('children' in grid).toBe(true);
156
+ expect(Object.keys(grid.children)).toEqual(['size', 'color']);
157
+ expect(grid.name).toBe('Grid');
158
+ });
159
+
160
+ it('titleCases a group with no label', () => {
161
+ const r = resolveConfigSchema(f.schema({ gridStyle: f.group({ a: f.number(1) }) }), []);
162
+ expect((r.group.children.gridStyle as PrefGroup).name).toBe('Grid style');
163
+ });
164
+
165
+ it('runs the rule chain on a nested leaf, keyed by its own segment', () => {
166
+ const r = nested();
167
+ const grid = r.group.children.grid as PrefGroup;
168
+ const size = grid.children.size as unknown as Record<string, unknown>;
169
+ expect(size.name).toBe('Size');
170
+ expect(size.control).toBe('slider');
171
+ });
172
+
173
+ it('gives a rule the full dotted path alongside the key', () => {
174
+ const seen: { key: string; path: string }[] = [];
175
+ const spy: ConfigRule = (ctx) => {
176
+ seen.push({ key: ctx.key, path: ctx.path });
177
+ return null;
178
+ };
179
+ resolveConfigSchema(f.schema({ grid: f.group({ size: f.number(1) }) }), [spy]);
180
+ expect(seen).toContainEqual({ key: 'size', path: 'grid.size' });
181
+ });
182
+
183
+ it('keys showIf and renderers by the full dotted path', () => {
184
+ const r = nested();
185
+ expect(r.showIf.get('grid.color')?.({ showGrid: false })).toBe(false);
186
+ expect(r.showIf.get('color')).toBeUndefined();
187
+ });
188
+
189
+ it('a section inside a group buckets the children of that group', () => {
190
+ const r = resolveConfigSchema(
191
+ f.schema({
192
+ top: f.number(1).section('Outer'),
193
+ grid: f.group({ size: f.number(1).section('Inner'), color: f.color('#fff') }),
194
+ }),
195
+ [],
196
+ );
197
+ expect(r.sections).toEqual([
198
+ { at: '', label: 'Outer', paths: ['top'] },
199
+ { at: 'grid', label: 'Inner', paths: ['grid.size'] },
200
+ ]);
201
+ });
202
+
203
+ it('a group can itself sit under a section heading', () => {
204
+ const r = resolveConfigSchema(
205
+ f.schema({ grid: f.group({ size: f.number(1) }).section('Advanced') }),
206
+ [],
207
+ );
208
+ expect(r.sections).toEqual([{ at: '', label: 'Advanced', paths: ['grid'] }]);
209
+ });
210
+ });
@@ -1,8 +1,11 @@
1
1
  import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
- import { applyRules, builtinRules } from './rules';
2
+ import { isConfigBranch } from './builder';
3
+ import { applyRules, builtinRules, titleCase } from './rules';
3
4
  import type {
5
+ ConfigEntry,
4
6
  ConfigRule,
5
7
  ConfigSchema,
8
+ ConfigShape,
6
9
  ControlRenderer,
7
10
  LeafPatch,
8
11
  ResolvedConfig,
@@ -17,51 +20,91 @@ function defined(annotations: Record<string, unknown>): LeafPatch {
17
20
  return out as LeafPatch;
18
21
  }
19
22
 
23
+ /** What the walk collects on the side of the `PrefGroup` tree. */
24
+ interface Sink {
25
+ sections: SectionSpec[];
26
+ showIf: Map<string, (config: Record<string, unknown>) => boolean>;
27
+ renderers: Record<string, ControlRenderer>;
28
+ chain: readonly ConfigRule[];
29
+ }
30
+
20
31
  /**
21
32
  * Resolve a schema into the vocabulary weasel-ui renders, running each leaf
22
33
  * through the consumer's rules and then labkit's own.
23
34
  *
24
- * The group is flat — every leaf is a direct child — so a leaf's path is its
25
- * config key and both `ControlPanel` and `PrefsForm` address it identically.
35
+ * The group mirrors the schema's own nesting: an `f.group` becomes a nested
36
+ * `PrefGroup`, so a leaf's dotted path within the tree is the path its value
37
+ * is written at, and both `ControlPanel` and `PrefsForm` address it
38
+ * identically.
26
39
  */
27
40
  export function resolveConfigSchema<TC>(
28
41
  schema: ConfigSchema<TC>,
29
42
  rules: readonly ConfigRule[] = [],
30
43
  ): ResolvedConfig {
31
- const children: Record<string, PrefLeaf> = {};
32
- const sectionOrder: string[] = [];
33
- const sectionPaths = new Map<string, string[]>();
34
- const showIf = new Map<string, (config: Record<string, unknown>) => boolean>();
35
- const renderers: Record<string, ControlRenderer> = {};
44
+ const sink: Sink = {
45
+ sections: [],
46
+ showIf: new Map(),
47
+ renderers: {},
48
+ chain: [...rules, ...builtinRules],
49
+ };
50
+ const group = resolveShape(schema.nodes, '', '', sink);
51
+ return { group, sections: sink.sections, showIf: sink.showIf, renderers: sink.renderers };
52
+ }
36
53
 
37
- const chain = [...rules, ...builtinRules];
54
+ /** A section while it is still being filled. */
55
+ type OpenSection = Omit<SectionSpec, 'paths'> & { paths: string[] };
38
56
 
39
- for (const [key, node] of Object.entries(schema.nodes)) {
40
- const seed: LeafPatch = defined({
41
- ...node.annotations,
42
- ...(node.kind === null ? {} : { kind: node.kind }),
43
- });
57
+ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink): PrefGroup {
58
+ const children: Record<string, PrefLeaf | PrefGroup> = {};
59
+ const sections = new Map<string, OpenSection>();
44
60
 
45
- const patch = applyRules(seed, { key, path: key, default: node.default }, chain);
46
- children[key] = { ...patch, default: node.default } as PrefLeaf;
61
+ for (const [key, entry] of Object.entries(shape)) {
62
+ const path = at === '' ? key : `${at}.${key}`;
63
+ children[key] = resolveEntry(entry, key, path, sink);
47
64
 
48
- const { section, showIf: predicate, render } = node.options;
65
+ const { section, showIf: predicate } = entry.options;
49
66
  if (section !== undefined) {
50
- if (!sectionPaths.has(section)) {
51
- sectionOrder.push(section);
52
- sectionPaths.set(section, []);
67
+ let spec = sections.get(section.label);
68
+ if (!spec) {
69
+ spec = { at, label: section.label, paths: [] };
70
+ sections.set(section.label, spec);
71
+ sink.sections.push(spec);
72
+ }
73
+ spec.paths.push(path);
74
+ // One node saying `collapsed` settles the section, so a schema does not
75
+ // have to repeat it on every node under the heading.
76
+ if (section.collapsed !== undefined) {
77
+ spec.collapsed = (spec.collapsed ?? false) || section.collapsed;
53
78
  }
54
- sectionPaths.get(section)?.push(key);
55
79
  }
56
- if (predicate) showIf.set(key, predicate);
57
- if (render) renderers[key] = render;
80
+ if (predicate) sink.showIf.set(path, predicate);
81
+ if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
58
82
  }
59
83
 
60
- const group: PrefGroup = { name: '', children };
61
- const sections: SectionSpec[] = sectionOrder.map((label) => ({
62
- label,
63
- paths: sectionPaths.get(label) ?? [],
64
- }));
84
+ return { name, children };
85
+ }
65
86
 
66
- return { group, sections, showIf, renderers };
87
+ function resolveEntry(
88
+ entry: ConfigEntry,
89
+ key: string,
90
+ path: string,
91
+ sink: Sink,
92
+ ): PrefLeaf | PrefGroup {
93
+ if (isConfigBranch(entry)) {
94
+ const group = resolveShape(
95
+ entry.children,
96
+ path,
97
+ entry.annotations.name ?? titleCase(key),
98
+ sink,
99
+ );
100
+ return entry.annotations.description === undefined
101
+ ? group
102
+ : { ...group, description: entry.annotations.description };
103
+ }
104
+ const seed: LeafPatch = defined({
105
+ ...entry.annotations,
106
+ ...(entry.kind === null ? {} : { kind: entry.kind }),
107
+ });
108
+ const patch = applyRules(seed, { key, path, default: entry.default }, sink.chain);
109
+ return { ...patch, default: entry.default } as PrefLeaf;
67
110
  }