@weasel-js/labkit 1.4.2 → 1.4.4

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 (117) hide show
  1. package/README.md +20 -2
  2. package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts} +11 -7
  5. package/dist/_dts/{index-CsU9WhjM.d.ts → index-Blv9uQzu.d.ts} +42 -9
  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-ULDW42CR.js → chunk-2P6PP5N4.js} +33 -23
  15. package/dist/chunk-2P6PP5N4.js.map +1 -0
  16. package/dist/{chunk-W2FJR5FF.js → chunk-64ZCN3DA.js} +74 -76
  17. package/dist/chunk-64ZCN3DA.js.map +1 -0
  18. package/dist/{chunk-NSOVI3AZ.js → chunk-BKFVHKJH.js} +6 -6
  19. package/dist/{chunk-NSOVI3AZ.js.map → chunk-BKFVHKJH.js.map} +1 -1
  20. package/dist/chunk-E44SU6XS.js +50 -0
  21. package/dist/chunk-E44SU6XS.js.map +1 -0
  22. package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
  23. package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
  24. package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
  25. package/dist/chunk-I6JCVE24.js.map +1 -0
  26. package/dist/{chunk-UDXOYZEC.js → chunk-MOM3GOVY.js} +4 -4
  27. package/dist/{chunk-UDXOYZEC.js.map → chunk-MOM3GOVY.js.map} +1 -1
  28. package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
  29. package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
  30. package/dist/{chunk-UTOEDPNU.js → chunk-SMHP6XZ4.js} +7 -6
  31. package/dist/chunk-SMHP6XZ4.js.map +1 -0
  32. package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
  33. package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
  34. package/dist/{chunk-XKENZTNE.js → chunk-U3IYHIAE.js} +71 -55
  35. package/dist/chunk-U3IYHIAE.js.map +1 -0
  36. package/dist/chunk-W3ECWC2K.js +7335 -0
  37. package/dist/chunk-W3ECWC2K.js.map +1 -0
  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 +139 -26
  43. package/dist/index.js +309 -144
  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 +98 -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 +82 -15
  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/annotations/frac.test.ts +4 -4
  66. package/src/annotations/frac.ts +3 -2
  67. package/src/annotations/index.ts +1 -1
  68. package/src/annotations/store.ts +2 -2
  69. package/src/annotations/svgNodes.ts +1 -2
  70. package/src/config/builder.test.ts +56 -2
  71. package/src/config/builder.ts +73 -13
  72. package/src/config/index.ts +16 -1
  73. package/src/config/path.test.ts +109 -0
  74. package/src/config/path.ts +76 -0
  75. package/src/config/resolve.test.ts +93 -2
  76. package/src/config/resolve.ts +72 -29
  77. package/src/config/types.ts +99 -9
  78. package/src/config/visible.ts +6 -4
  79. package/src/controls/ControlPanel.stories.tsx +38 -4
  80. package/src/controls/ControlPanel.test.tsx +114 -0
  81. package/src/controls/ControlPanel.tsx +99 -50
  82. package/src/index.ts +17 -2
  83. package/src/instrument/serializers.ts +36 -0
  84. package/src/instrument/types.ts +4 -2
  85. package/src/lab/Lab.test.tsx +29 -1
  86. package/src/lab/Lab.tsx +10 -1
  87. package/src/lab/LabShell.test.tsx +8 -0
  88. package/src/lab/LabShell.tsx +17 -3
  89. package/src/lab/LabSwitcher.less +85 -0
  90. package/src/lab/LabSwitcher.test.tsx +96 -0
  91. package/src/lab/LabSwitcher.tsx +112 -0
  92. package/src/lab/index.ts +2 -0
  93. package/src/primitives/ZoomControl.less +6 -2
  94. package/src/primitives/ZoomControl.tsx +1 -0
  95. package/src/state/helpers.test.ts +20 -0
  96. package/src/state/helpers.ts +17 -1
  97. package/src/state/store.test.ts +155 -1
  98. package/src/state/store.ts +32 -26
  99. package/src/state/types.ts +24 -3
  100. package/src/state/useTrialState.ts +1 -1
  101. package/src/styles.less +1 -0
  102. package/src/theme/base.less +4 -4
  103. package/src/trial/Trial.config.test.tsx +44 -0
  104. package/src/trial/Trial.tsx +3 -3
  105. package/src/trial/TrialChrome.tsx +10 -5
  106. package/src/trial/trialOps.ts +7 -10
  107. package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
  108. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
  109. package/dist/chunk-54ZWZ5FQ.js +0 -29997
  110. package/dist/chunk-54ZWZ5FQ.js.map +0 -1
  111. package/dist/chunk-FJG4PHTL.js.map +0 -1
  112. package/dist/chunk-ISSVF5PT.js +0 -7263
  113. package/dist/chunk-ISSVF5PT.js.map +0 -1
  114. package/dist/chunk-ULDW42CR.js.map +0 -1
  115. package/dist/chunk-UTOEDPNU.js.map +0 -1
  116. package/dist/chunk-W2FJR5FF.js.map +0 -1
  117. package/dist/chunk-XKENZTNE.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -110,15 +110,15 @@
110
110
  "dev:annotate": "LABKIT_EXAMPLE=annotate-lab vite"
111
111
  },
112
112
  "peerDependencies": {
113
+ "@weasel-js/core": "1.4.4",
113
114
  "react": "^19.0.0",
114
115
  "react-dom": "^19.0.0"
115
116
  },
116
117
  "dependencies": {
117
- "@weasel-js/core": "1.4.2",
118
- "@weasel-js/loupe": "1.4.2",
119
- "@weasel-js/svg": "1.4.2",
120
- "@weasel-js/theme": "1.4.2",
121
- "@weasel-js/ui": "1.4.2",
118
+ "@weasel-js/loupe": "1.4.4",
119
+ "@weasel-js/svg": "1.4.4",
120
+ "@weasel-js/theme": "1.4.4",
121
+ "@weasel-js/ui": "1.4.4",
122
122
  "earcut": "2.2.4",
123
123
  "polygon-clipping": "^0.15.7",
124
124
  "react-aria-components": "^1.5.0",
@@ -129,7 +129,7 @@
129
129
  "@biomejs/biome": "^2.4.12",
130
130
  "@rollup/plugin-alias": "^6.0.0",
131
131
  "@testing-library/jest-dom": "^6.6.0",
132
- "@weasel-js/modes": "1.4.2",
132
+ "@weasel-js/modes": "1.4.4",
133
133
  "less": "^4.2.0",
134
134
  "rollup-plugin-dts": "^6.4.1",
135
135
  "tsx": "^4.19.0",
@@ -75,6 +75,22 @@ describe('<ExportMenu>', () => {
75
75
  );
76
76
  });
77
77
 
78
+ // jsdom resolves neither var() nor color-mix(), so where the panel mounted
79
+ // is the proxy for whether it reads the lab's tokens at all.
80
+ it('opens inside the lab root, not on the body', () => {
81
+ const { store } = spied();
82
+ render(
83
+ <div className="lk-root" data-wzl-portal-host="" data-testid="lk-root">
84
+ <AnnotationsContext.Provider value={store}>
85
+ <ExportMenu />
86
+ </AnnotationsContext.Provider>
87
+ </div>,
88
+ );
89
+ open();
90
+ const panel = document.querySelector('.lk-export__panel');
91
+ expect(panel?.closest('[data-testid="lk-root"]')).toBe(screen.getByTestId('lk-root'));
92
+ });
93
+
78
94
  it('says so when a capture fails rather than doing nothing visible', async () => {
79
95
  const { store, capture } = spied();
80
96
  capture.mockRejectedValueOnce(new Error('the capture canvas is tainted'));
@@ -51,10 +51,6 @@ async function copy(result: CaptureResult): Promise<void> {
51
51
  */
52
52
  export function ExportMenu() {
53
53
  const marks = useAnnotationsOptional();
54
- // The anchor doubles as the portal target lookup: a React Aria popover
55
- // portals to `document.body` by default, which is outside the element
56
- // labkit paints its theme tokens onto — `--wzl-surface` does not exist
57
- // there and the panel renders unthemed.
58
54
  const [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
59
55
  const [open, setOpen] = useState(false);
60
56
  const [target, setTarget] = useState<string | null>(null);
@@ -99,7 +95,6 @@ export function ExportMenu() {
99
95
  onOpenChange={setOpen}
100
96
  onDismiss={() => setOpen(false)}
101
97
  placement="bottom end"
102
- UNSTABLE_portalContainer={anchor?.closest('.lk-root') ?? undefined}
103
98
  aria-labelledby={titleId}
104
99
  >
105
100
  <div className="lk-export__panel">
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
2
+ import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
3
3
  import type { FracRect } from './types';
4
4
 
5
5
  const CONTENT = { w: 256, h: 170 };
@@ -51,9 +51,9 @@ describe('fraction hit geometry', () => {
51
51
  expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
52
52
  });
53
53
 
54
- it('intersects only a box that wholly contains it', () => {
55
- expect(fracIntersects({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
54
+ it('encloses only a box that lies wholly inside', () => {
55
+ expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
56
56
  // Overlapping is not containing: a marquee takes what it encloses.
57
- expect(fracIntersects({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
57
+ expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
58
58
  });
59
59
  });
@@ -55,8 +55,9 @@ export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
55
55
  }
56
56
 
57
57
  /** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
58
- * not what it grazes — brushing selection is a different gesture. */
59
- export function fracIntersects(outer: FracRect, inner: FracRect): boolean {
58
+ * not what it grazes — brushing selection is a different gesture, and this
59
+ * answers false for two rects that merely overlap. */
60
+ export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
60
61
  return (
61
62
  inner.x >= outer.x &&
62
63
  inner.y >= outer.y &&
@@ -4,7 +4,7 @@ export type { MarkDrawOptions } from './drawOne';
4
4
  export { createMarkDrawOne, resolveMarkStyle } from './drawOne';
5
5
  export { ExportMenu } from './ExportMenu';
6
6
  export type { WorldRect } from './frac';
7
- export { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
7
+ export { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
8
8
  export type { HistoryScene } from './history';
9
9
  export { MarkHistory } from './history';
10
10
  export type { MarkListProps } from './MarkList';
@@ -7,7 +7,7 @@ import {
7
7
  } from '@weasel-js/core';
8
8
  import { captureTarget } from './capture';
9
9
  import type { WorldRect } from './frac';
10
- import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
10
+ import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
11
11
  import { MarkHistory } from './history';
12
12
  import { isStale as isStaleAgainst, seenFrom } from './staleness';
13
13
  import type {
@@ -197,7 +197,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
197
197
  },
198
198
 
199
199
  within(target, box: FracRect) {
200
- return marksOn(target).filter((a) => fracIntersects(box, a.frac));
200
+ return marksOn(target).filter((a) => fracEncloses(box, a.frac));
201
201
  },
202
202
 
203
203
  isStale(a, config) {
@@ -1,5 +1,4 @@
1
1
  import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
2
- import { resolveStrokeWidth } from '@weasel-js/core';
3
2
  import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
4
3
  import { type MarkStyle, markCommands, type PaintableMark } from './paint';
5
4
 
@@ -23,7 +22,7 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
23
22
  function toSvgStroke(stroke: Stroke): SvgStroke {
24
23
  return {
25
24
  paint: toSvgPaint(stroke.paint),
26
- width: resolveStrokeWidth(stroke.width ?? 1, 1),
25
+ width: stroke.width ?? 1,
27
26
  ...(stroke.cap ? { cap: stroke.cap } : {}),
28
27
  ...(stroke.join ? { join: stroke.join } : {}),
29
28
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
@@ -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
+ }