@weasel-js/labkit 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +177 -53
  51. package/dist/index.js +1333 -1683
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +26 -202
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +153 -40
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. package/src/theme/interstellar.tokens.json +0 -61
@@ -0,0 +1,153 @@
1
+ import { isPrefLeaf, type PrefGroup, type PrefLeaf } from '@weasel-js/ui';
2
+ import { isAuto } from './auto';
3
+ import { isRecord, withValueAtPath } from './path';
4
+ import type { ResolvedConfig } from './types';
5
+
6
+ type Resolver = (config: Record<string, unknown>) => unknown;
7
+
8
+ /** Reads a labkit-only extra off a resolved leaf. `PrefLeaf` has no field for
9
+ * these; they ride as extra keys and survive the resolve walk. */
10
+ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
11
+ return (leaf as unknown as Record<string, T | undefined>)[key];
12
+ }
13
+
14
+ /** Every leaf in a resolved schema, in schema order, with its dotted path. */
15
+ function* leaves(group: PrefGroup, at = ''): Generator<[string, PrefLeaf]> {
16
+ for (const [key, child] of Object.entries(group.children)) {
17
+ const path = at === '' ? key : `${at}.${key}`;
18
+ if (isPrefLeaf(child)) yield [path, child];
19
+ else yield* leaves(child, path);
20
+ }
21
+ }
22
+
23
+ /** The dotted paths a schema declares as starting auto — every leaf that said
24
+ * `.initial(auto)`. This is what seeds a new trial's unpinned set. */
25
+ export function autoPathsOf(resolved: ResolvedConfig): string[] {
26
+ const out: string[] = [];
27
+ for (const [path, leaf] of leaves(resolved.group)) {
28
+ if (extra<boolean>(leaf, 'unpinned')) out.push(path);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The config an instrument reads: the stored one, with every unpinned path
35
+ * either computed by its resolver or removed.
36
+ *
37
+ * Resolution is demand-driven — a resolver reading another unpinned path
38
+ * forces that one first, whatever order the schema declared them in — so a
39
+ * cycle is a real cycle and not an ordering accident.
40
+ */
41
+ export function resolveAutoConfig<TC>(
42
+ resolved: ResolvedConfig,
43
+ config: TC,
44
+ autoPaths: ReadonlySet<string>,
45
+ ): TC {
46
+ if (autoPaths.size === 0) return config;
47
+
48
+ const resolvers = new Map<string, Resolver | undefined>();
49
+ for (const [path, leaf] of leaves(resolved.group)) {
50
+ if (autoPaths.has(path)) resolvers.set(path, extra<Resolver>(leaf, 'autoResolve'));
51
+ }
52
+ if (resolvers.size === 0) return config;
53
+
54
+ // Drop first, so a resolver reading a still-unresolved auto path sees
55
+ // `undefined` rather than a stale pinned value it would silently believe.
56
+ let out = config as unknown as Record<string, unknown>;
57
+ for (const path of resolvers.keys()) out = dropAtPath(out, path);
58
+
59
+ const inFlight = new Set<string>();
60
+ const done = new Set<string>();
61
+
62
+ const need = (path: string): void => {
63
+ if (done.has(path)) return;
64
+ if (inFlight.has(path)) {
65
+ throw new Error(`[labkit] auto resolver cycle at "${path}"`);
66
+ }
67
+ const resolve = resolvers.get(path);
68
+ if (!resolve) {
69
+ done.add(path);
70
+ return;
71
+ }
72
+ inFlight.add(path);
73
+ try {
74
+ // Read `out` only after the resolver has run: it forces its own
75
+ // dependencies, and each of those replaced `out`.
76
+ const value = resolve(demand(() => out, resolvers, need));
77
+ out = withValueAtPath(out, path, value);
78
+ } finally {
79
+ inFlight.delete(path);
80
+ }
81
+ done.add(path);
82
+ };
83
+
84
+ for (const path of resolvers.keys()) need(path);
85
+ return out as unknown as TC;
86
+ }
87
+
88
+ /**
89
+ * The config a resolver reads. A top-level key that is itself unpinned is
90
+ * forced before it is handed back, which is what makes declaration order
91
+ * irrelevant and turns a genuine loop into a named error.
92
+ *
93
+ * Only top-level keys are trapped: a resolver reaching `c.grid.size` gets the
94
+ * already-dropped branch, and a nested auto path it depends on resolves by
95
+ * schema order alone.
96
+ */
97
+ function demand(
98
+ read: () => Record<string, unknown>,
99
+ resolvers: ReadonlyMap<string, Resolver | undefined>,
100
+ need: (path: string) => void,
101
+ ): Record<string, unknown> {
102
+ return new Proxy(read(), {
103
+ get(_target, key) {
104
+ if (typeof key === 'string' && resolvers.has(key)) need(key);
105
+ return Reflect.get(read(), key);
106
+ },
107
+ has(_target, key) {
108
+ return Reflect.has(read(), key);
109
+ },
110
+ ownKeys() {
111
+ return Reflect.ownKeys(read());
112
+ },
113
+ getOwnPropertyDescriptor(_target, key) {
114
+ return Reflect.getOwnPropertyDescriptor(read(), key);
115
+ },
116
+ });
117
+ }
118
+
119
+ /** A copy of `config` with the dotted path removed, copying every record on
120
+ * the way down. A path that is not there comes back unchanged. */
121
+ function dropAtPath(config: Record<string, unknown>, path: string): Record<string, unknown> {
122
+ const [head, ...rest] = path.split('.');
123
+ if (head === undefined || !(head in config)) return config;
124
+ if (rest.length === 0) {
125
+ const { [head]: _dropped, ...kept } = config;
126
+ return kept;
127
+ }
128
+ const child = config[head];
129
+ if (!isRecord(child)) return config;
130
+ return { ...config, [head]: dropAtPath(child, rest.join('.')) };
131
+ }
132
+
133
+ /**
134
+ * What writing `value` at `path` makes of a trial's raw config and its set of
135
+ * unpinned paths. `auto` pins nothing and joins the set; any other value is
136
+ * written and leaves it. Both the store and the chrome go through this, so
137
+ * they cannot disagree about what a write meant.
138
+ */
139
+ export function applyConfigWrite<TC>(
140
+ config: TC,
141
+ autoPaths: readonly string[],
142
+ path: string,
143
+ value: unknown,
144
+ ): { config: TC; autoPaths: readonly string[] } {
145
+ if (isAuto(value)) {
146
+ if (autoPaths.includes(path)) return { config, autoPaths };
147
+ return { config, autoPaths: [...autoPaths, path] };
148
+ }
149
+ return {
150
+ config: withValueAtPath(config, path, value),
151
+ autoPaths: autoPaths.includes(path) ? autoPaths.filter((p) => p !== path) : autoPaths,
152
+ };
153
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { auto } from './auto';
2
3
  import { f } from './builder';
3
4
  import type { ConfigOf, ConfigPath, ValueAtPath } from './types';
4
5
 
@@ -154,3 +155,40 @@ describe('builder / nested types', () => {
154
155
  expect([size, path, value]).toEqual([20, 'grid.size', 40]);
155
156
  });
156
157
  });
158
+
159
+ describe('auto on a node', () => {
160
+ it('attaches a resolver without changing the default', () => {
161
+ const n = f.number(12).auto((c) => (c.width as number) / 24);
162
+ expect(n.default).toBe(12);
163
+ expect(n.options.autoResolve?.({ width: 432 })).toBe(18);
164
+ });
165
+
166
+ it('.initial(auto) marks the node as starting unpinned', () => {
167
+ expect(f.number(3).initial(auto).options.unpinned).toBe(true);
168
+ expect(f.number(3).options.unpinned).toBeUndefined();
169
+ });
170
+
171
+ it('.manual() opts the node out of ever being auto', () => {
172
+ expect(f.number(1).manual().options.manual).toBe(true);
173
+ });
174
+
175
+ it('clones rather than mutating, like every other chaining method', () => {
176
+ const base = f.number(3);
177
+ const unpinned = base.initial(auto);
178
+ expect(base.options.unpinned).toBeUndefined();
179
+ expect(unpinned).not.toBe(base);
180
+ });
181
+
182
+ it('keeps the subclass, so kind-specific methods still chain after', () => {
183
+ const n = f.number(3).initial(auto).range(0, 10).suffix('px');
184
+ expect(n.annotations.min).toBe(0);
185
+ expect(n.annotations.suffix).toBe('px');
186
+ expect(n.options.unpinned).toBe(true);
187
+ });
188
+
189
+ it('rejects anything but the sentinel, for a caller TypeScript is not checking', () => {
190
+ const n = f.number(3) as unknown as { initial: (value: unknown) => unknown };
191
+ expect(() => n.initial(5)).toThrow(/takes `auto`/);
192
+ expect(() => n.initial(Symbol('auto'))).toThrow(/takes `auto`/);
193
+ });
194
+ });
@@ -1,4 +1,5 @@
1
- import type { PrefLeaf, PrefNumberFormat } from '@weasel-js/ui';
1
+ import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
2
+ import { type Auto, isAuto } from './auto';
2
3
  import type {
3
4
  Annotations,
4
5
  BranchAnnotations,
@@ -12,6 +13,7 @@ import type {
12
13
  ControlRenderer,
13
14
  InferConfig,
14
15
  NodeOptions,
16
+ SectionOption,
15
17
  } from './types';
16
18
 
17
19
  /** Whether a schema entry is a branch rather than a leaf. */
@@ -72,8 +74,9 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
72
74
  }
73
75
 
74
76
  /** 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
+ * folded, and `layout` and `pack` lay out its rows — say each on any one of
78
+ * the section's leaves. */
79
+ section(label: string, opts: Omit<SectionOption, 'label'> = {}): this {
77
80
  return this.opt({ section: { label, ...opts } });
78
81
  }
79
82
 
@@ -87,6 +90,30 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
87
90
  render(renderer: ControlRenderer): this {
88
91
  return this.opt({ render: renderer });
89
92
  }
93
+
94
+ /**
95
+ * Compute this leaf's value while it is auto, instead of leaving it
96
+ * `undefined`. The resolver is given the config with every other auto path
97
+ * already resolved.
98
+ */
99
+ auto(resolve: (config: Record<string, unknown>) => T): this {
100
+ return this.opt({ autoResolve: resolve as (c: Record<string, unknown>) => unknown });
101
+ }
102
+
103
+ /**
104
+ * Start this leaf auto rather than pinned at its default. Takes `auto` and
105
+ * nothing else — the constructor argument already declares the value, and a
106
+ * second way to say it would fight with the first.
107
+ */
108
+ initial(value: Auto): this {
109
+ if (!isAuto(value)) throw new Error('[labkit] .initial() takes `auto` and nothing else');
110
+ return this.opt({ unpinned: true });
111
+ }
112
+
113
+ /** Never auto. The row takes no pin dot and ignores the gesture. */
114
+ manual(): this {
115
+ return this.opt({ manual: true });
116
+ }
90
117
  }
91
118
 
92
119
  export class NumberNode extends BaseNode<number> {
@@ -113,6 +140,16 @@ export class NumberNode extends BaseNode<number> {
113
140
  return this.ann({ format });
114
141
  }
115
142
 
143
+ /**
144
+ * Store the value in one unit and edit it in another — radians stored,
145
+ * degrees typed. `min`, `max` and `step` are declared in the stored unit
146
+ * alongside the value, and convert with it. `prefUnit` builds one from a
147
+ * `UnitSystem`.
148
+ */
149
+ unit(unit: PrefNumberUnit): this {
150
+ return this.ann({ unit });
151
+ }
152
+
116
153
  /** Force a slider even without both bounds. */
117
154
  slider(): this {
118
155
  return this.ann({ control: 'slider' });
@@ -127,8 +164,7 @@ export class NumberNode extends BaseNode<number> {
127
164
  export class BooleanNode extends BaseNode<boolean> {
128
165
  readonly kind = 'boolean';
129
166
 
130
- /** Ask for a switch. `ControlPanel` still draws a checkbox; weasel-ui's
131
- * `PrefsForm` honors the distinction. */
167
+ /** Ask for a switch rather than a checkbox. */
132
168
  toggle(): this {
133
169
  return this.ann({ control: 'switch' });
134
170
  }
@@ -153,6 +189,12 @@ export class StringNode extends BaseNode<string> {
153
189
 
154
190
  export class ColorNode extends BaseNode<string> {
155
191
  readonly kind = 'color';
192
+
193
+ /** The value carries alpha as `#rrggbbaa`, and the row gets an opacity
194
+ * track beside the swatch. */
195
+ alpha(): this {
196
+ return this.ann({ alpha: true });
197
+ }
156
198
  }
157
199
 
158
200
  export class EnumNode<T extends string> extends BaseNode<T> {
@@ -220,7 +262,7 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
220
262
  }
221
263
 
222
264
  /** Render this whole group under a named section heading. */
223
- section(label: string, opts: { collapsed?: boolean } = {}): GroupNode<S> {
265
+ section(label: string, opts: Omit<SectionOption, 'label'> = {}): GroupNode<S> {
224
266
  return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
225
267
  }
226
268
 
@@ -282,8 +324,11 @@ export const f = {
282
324
  group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
283
325
 
284
326
  /** A leaf of a kind a lab supplies the control for, through `controls`. */
285
- custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
286
- new CustomNode(kind, def, {}, validate ? { validate } : {}),
327
+ custom: <T>(
328
+ kind: string,
329
+ def: T,
330
+ validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[],
331
+ ): CustomNode<T> => new CustomNode(kind, def, {}, validate ? { validate } : {}),
287
332
 
288
333
  /** Collect leaves and groups into an instrument's config. */
289
334
  schema<S extends ConfigShape>(nodes: S): ConfigSchema<InferConfig<S>> {
@@ -1,3 +1,5 @@
1
+ export { type Auto, auto, isAuto } from './auto';
2
+ export { autoPathsOf, resolveAutoConfig } from './autoConfig';
1
3
  export {
2
4
  BaseNode,
3
5
  BooleanNode,
@@ -46,4 +48,5 @@ export type {
46
48
  ValueAtPath,
47
49
  } from './types';
48
50
  export { useConfigSchema } from './useConfigSchema';
51
+ export { useResolvedConfig } from './useResolvedConfig';
49
52
  export { isLeafVisible } from './visible';
@@ -6,8 +6,13 @@ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
6
6
  * reached by exactly the same call.
7
7
  */
8
8
 
9
- function isRecord(value: unknown): value is Record<string, unknown> {
10
- return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ /** A plain object a config tree can be walked into. An array, a `Date`, a
10
+ * `Map` or a class instance is a leaf value: a walk that descended into one
11
+ * would rebuild it as a bare `{}`. */
12
+ export function isRecord(value: unknown): value is Record<string, unknown> {
13
+ if (typeof value !== 'object' || value === null) return false;
14
+ const proto = Object.getPrototypeOf(value);
15
+ return proto === Object.prototype || proto === null;
11
16
  }
12
17
 
13
18
  /** The value at a dotted path. `undefined` when a segment is missing or the
@@ -1,5 +1,6 @@
1
1
  import type { PrefGroup } from '@weasel-js/ui';
2
2
  import { describe, expect, it } from 'vitest';
3
+ import { auto } from './auto';
3
4
  import { f } from './builder';
4
5
  import { resolveConfigSchema } from './resolve';
5
6
  import { titleCase } from './rules';
@@ -207,4 +208,28 @@ describe('resolveConfigSchema / nested groups', () => {
207
208
  );
208
209
  expect(r.sections).toEqual([{ at: '', label: 'Advanced', paths: ['grid'] }]);
209
210
  });
211
+
212
+ it('rejects a leaf that is both manual and starts auto', () => {
213
+ const schema = f.schema({ seed: f.number(1).manual().initial(auto) });
214
+ expect(() => resolveConfigSchema(schema, [])).toThrow(/seed/);
215
+ expect(() => resolveConfigSchema(schema, [])).toThrow(/manual/);
216
+ });
217
+
218
+ it('carries autoResolve, unpinned and manual onto the resolved leaf', () => {
219
+ const r = resolveConfigSchema(
220
+ f.schema({
221
+ gap: f.number(12).auto(() => 18),
222
+ cols: f.number(3).initial(auto),
223
+ seed: f.number(1).manual(),
224
+ plain: f.number(0),
225
+ }),
226
+ [],
227
+ );
228
+ expect(typeof leafAt(r, 'gap').autoResolve).toBe('function');
229
+ expect(leafAt(r, 'cols').unpinned).toBe(true);
230
+ expect(leafAt(r, 'seed').manual).toBe(true);
231
+ expect(leafAt(r, 'plain').autoResolve).toBeUndefined();
232
+ expect(leafAt(r, 'plain').unpinned).toBeUndefined();
233
+ expect(leafAt(r, 'plain').manual).toBeUndefined();
234
+ });
210
235
  });
@@ -76,6 +76,8 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
76
76
  if (section.collapsed !== undefined) {
77
77
  spec.collapsed = (spec.collapsed ?? false) || section.collapsed;
78
78
  }
79
+ if (section.layout !== undefined && spec.layout === undefined) spec.layout = section.layout;
80
+ if (section.pack !== undefined && spec.pack === undefined) spec.pack = section.pack;
79
81
  }
80
82
  if (predicate) sink.showIf.set(path, predicate);
81
83
  if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
@@ -101,10 +103,24 @@ function resolveEntry(
101
103
  ? group
102
104
  : { ...group, description: entry.annotations.description };
103
105
  }
106
+ if (entry.options.manual && entry.options.unpinned) {
107
+ throw new Error(
108
+ `[labkit] "${path}" is both .manual() and .initial(auto) — a field that can never be auto cannot start auto`,
109
+ );
110
+ }
104
111
  const seed: LeafPatch = defined({
105
112
  ...entry.annotations,
106
113
  ...(entry.kind === null ? {} : { kind: entry.kind }),
107
114
  });
108
115
  const patch = applyRules(seed, { key, path, default: entry.default }, sink.chain);
109
- return { ...patch, default: entry.default } as PrefLeaf;
116
+ const { autoResolve, unpinned, manual } = entry.options;
117
+ return {
118
+ ...patch,
119
+ default: entry.default,
120
+ // Extra keys survive onto the leaf at runtime; `ControlPanel` reads them
121
+ // back with its `extra<T>` helper, the same as `min`, `step` and the rest.
122
+ ...(autoResolve === undefined ? {} : { autoResolve }),
123
+ ...(unpinned === undefined ? {} : { unpinned }),
124
+ ...(manual === undefined ? {} : { manual }),
125
+ } as PrefLeaf;
110
126
  }
@@ -1,4 +1,10 @@
1
- import type { PrefGroup, PrefLeaf, PrefNumberFormat, PrefRenderer } from '@weasel-js/ui';
1
+ import type {
2
+ PrefGroup,
3
+ PrefLeaf,
4
+ PrefNumberFormat,
5
+ PrefNumberUnit,
6
+ PrefRenderer,
7
+ } from '@weasel-js/ui';
2
8
 
3
9
  /**
4
10
  * Renders the control cell for one config leaf. Identical to weasel-ui's
@@ -27,6 +33,11 @@ export interface Annotations {
27
33
  suffix?: string;
28
34
  /** How a number's value is shown. `compact` abbreviates from a thousand up. */
29
35
  format?: PrefNumberFormat;
36
+ /** Display-unit conversion for a number stored in a canonical unit — the
37
+ * value, its bounds and its step all convert at the control's edge. */
38
+ unit?: PrefNumberUnit;
39
+ /** A color leaf's value carries alpha (`#rrggbbaa`) and its row edits it. */
40
+ alpha?: boolean;
30
41
  control?: string;
31
42
  options?: readonly ConfigOption[];
32
43
  placeholder?: string;
@@ -76,6 +87,10 @@ export interface SectionSpec {
76
87
  * whether or not it was given a panel-wide `collapse`; a fold the reader
77
88
  * has since toggled outranks it. */
78
89
  collapsed?: boolean;
90
+ /** Where the section's rows put their labels, over the panel's `layout`. */
91
+ layout?: 'block' | 'inline';
92
+ /** How the section packs its rows, over the panel's `pack`. */
93
+ pack?: 'auto' | 'pairs' | 'one-up';
79
94
  }
80
95
 
81
96
  /** A schema resolved against a set of rules: the vocabulary weasel-ui renders,
@@ -97,12 +112,27 @@ export interface ResolvedConfig {
97
112
  export interface SectionOption {
98
113
  label: string;
99
114
  collapsed?: boolean;
115
+ /** Where the section's rows put their labels, over the panel's `layout`. */
116
+ layout?: 'block' | 'inline';
117
+ /** How the section packs its rows, over the panel's `pack`. */
118
+ pack?: 'auto' | 'pairs' | 'one-up';
100
119
  }
101
120
 
102
121
  /** Per-node extras that do not belong on a `PrefLeaf`. */
103
122
  export interface NodeOptions extends BranchOptions {
104
123
  render?: ControlRenderer;
105
- validate?: (leaf: PrefLeaf) => string[];
124
+ /** Errors for this leaf under `config`, the instrument's whole config. */
125
+ validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[];
126
+ /** Computes this leaf's value while it is auto. Attaching one is what lets
127
+ * the ghosted control draw a real value and keeps the instrument off
128
+ * `?? compute()`. */
129
+ autoResolve?: (config: Record<string, unknown>) => unknown;
130
+ /** The leaf starts auto rather than pinned at its default. Set by
131
+ * `.initial(auto)`. */
132
+ unpinned?: boolean;
133
+ /** The leaf can never be auto: the row takes no pin dot and ignores the
134
+ * gesture. For a value the instrument cannot receive as `undefined`. */
135
+ manual?: boolean;
106
136
  }
107
137
 
108
138
  /** What a branch can say about itself, beyond its children. */
@@ -0,0 +1,26 @@
1
+ import { useMemo } from 'react';
2
+ import { resolveAutoConfig } from './autoConfig';
3
+ import type { ResolvedConfig } from './types';
4
+
5
+ const NONE: ReadonlySet<string> = new Set();
6
+
7
+ /**
8
+ * The config an instrument reads: the stored one with every unpinned path
9
+ * resolved. The single place resolution happens, so `Trial` and
10
+ * `useTrialState` cannot disagree about what an instrument is looking at.
11
+ */
12
+ export function useResolvedConfig<TC>(
13
+ schema: ResolvedConfig | undefined,
14
+ config: TC,
15
+ autoPaths: readonly string[] | undefined,
16
+ ): { config: TC; auto: ReadonlySet<string> } {
17
+ const auto = useMemo(
18
+ () => (autoPaths && autoPaths.length > 0 ? new Set(autoPaths) : NONE),
19
+ [autoPaths],
20
+ );
21
+ const resolvedConfig = useMemo(
22
+ () => (schema ? resolveAutoConfig(schema, config, auto) : config),
23
+ [schema, config, auto],
24
+ );
25
+ return { config: resolvedConfig, auto };
26
+ }
@@ -0,0 +1,88 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { useState } from 'react';
3
+ import { applyConfigWrite } from '../config/autoConfig';
4
+ import { f } from '../config/builder';
5
+ import { resolveConfigSchema } from '../config/resolve';
6
+ import { ControlPanel } from './ControlPanel';
7
+
8
+ /**
9
+ * The ghosting and the muted accent are CSS, and jsdom resolves neither
10
+ * `var()` nor `color-mix()`. This story is the only place they can be checked.
11
+ */
12
+ const meta: Meta<typeof ControlPanel> = {
13
+ title: 'labkit/Controls/AutoControls',
14
+ component: ControlPanel,
15
+ parameters: { layout: 'padded' },
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof ControlPanel>;
20
+
21
+ const schema = f.schema({
22
+ width: f.number(432).range(64, 1024).label('Width'),
23
+ gap: f
24
+ .number(12)
25
+ .range(0, 48)
26
+ .suffix('px')
27
+ .auto((c) => Math.round((c.width as number) / 24))
28
+ .label('Gap'),
29
+ cols: f.number(3).range(1, 12).label('Columns'),
30
+ wave: f.enum('sine', ['sine', 'square', 'triangle']).label('Wave'),
31
+ mode: f.enum('fit', ['fit', 'fill']).radio().label('Mode'),
32
+ showGrid: f.boolean(true).label('Show grid'),
33
+ title: f.string('My experiment').placeholder('Title…').label('Title'),
34
+ tint: f.color('#3a86ff').label('Tint'),
35
+ seed: f.number(1).range(0, 99).manual().label('Seed'),
36
+ });
37
+
38
+ const resolved = resolveConfigSchema(schema, []);
39
+
40
+ /** Every path the panel will draw ghosted, except the `.manual()` one. */
41
+ const EVERY_AUTO = ['gap', 'cols', 'wave', 'mode', 'showGrid', 'title', 'tint'];
42
+
43
+ function Panel({ start }: { start: readonly string[] }) {
44
+ const [state, setState] = useState(() => ({
45
+ config: schema.defaults() as Record<string, unknown>,
46
+ autoPaths: start,
47
+ }));
48
+ return (
49
+ <ControlPanel
50
+ schema={resolved}
51
+ config={state.config}
52
+ auto={new Set(state.autoPaths)}
53
+ setConfig={(path, value) =>
54
+ setState((prev) => applyConfigWrite(prev.config, prev.autoPaths, path, value))
55
+ }
56
+ />
57
+ );
58
+ }
59
+
60
+ function Pair() {
61
+ return (
62
+ <div style={{ display: 'flex', gap: 32, alignItems: 'flex-start' }}>
63
+ <div style={{ flex: '1 1 0', minWidth: 0 }}>
64
+ <h3>Pinned</h3>
65
+ <Panel start={[]} />
66
+ </div>
67
+ <div style={{ flex: '1 1 0', minWidth: 0 }}>
68
+ <h3>Auto</h3>
69
+ <Panel start={EVERY_AUTO} />
70
+ </div>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Both states of every row kind, side by side. What to look at: an auto row's
77
+ * control keeps no accent color, its readout reads `auto · 18 px` rather than a
78
+ * number, and the pin dot appears only under the pointer — except on an auto
79
+ * row, where it stays. `Seed` is `.manual()` and takes no dot at all.
80
+ */
81
+ export const BothStates: Story = {
82
+ render: () => <Pair />,
83
+ };
84
+
85
+ /** One panel to shift-click around in: the handle must not move as it toggles. */
86
+ export const Interactive: Story = {
87
+ render: () => <Panel start={['gap']} />,
88
+ };
@@ -1,4 +1,5 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import { ANGLE_RADIANS, prefUnit } from '@weasel-js/core';
2
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
3
  import { PropertyRow } from '@weasel-js/ui';
3
4
  import { useState } from 'react';
4
5
  import { f } from '../config/builder';
@@ -234,3 +235,43 @@ export const Nested: Story = {
234
235
  );
235
236
  },
236
237
  };
238
+
239
+ const degrees = prefUnit(ANGLE_RADIANS, 'deg', { precision: 1 });
240
+
241
+ const presentation = f.schema({
242
+ x: f.number(120).label('X').pair('Offset'),
243
+ y: f.number(-40).label('Y').pair('Offset'),
244
+ spin: f
245
+ .number(Math.PI / 4)
246
+ .range(0, Math.PI * 2)
247
+ .unit(degrees)
248
+ .label('Rotation'),
249
+ nudge: f
250
+ .number(Math.PI / 180)
251
+ .input()
252
+ .unit(degrees)
253
+ .label('Nudge'),
254
+ tint: f.color('#3a86ffcc').alpha().label('Tint'),
255
+ opaque: f.color('#3a86ff').label('Grid'),
256
+ });
257
+
258
+ /** The three presentation fields a leaf can declare: `pair` puts `X` and `Y`
259
+ * on one row the pair names, `unit` stores radians and edits degrees, and
260
+ * `alpha` gives the swatch an opacity track and stores `#rrggbbaa`. The dump
261
+ * shows what each control actually wrote. */
262
+ export const Presentation: Story = {
263
+ render: () => {
264
+ const schema = resolveConfigSchema(presentation, []);
265
+ const [config, setConfig] = useState<Record<string, unknown>>(presentation.defaults());
266
+ return (
267
+ <>
268
+ <ControlPanel
269
+ schema={schema}
270
+ config={config}
271
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
272
+ />
273
+ <pre>{JSON.stringify(config, null, 2)}</pre>
274
+ </>
275
+ );
276
+ },
277
+ };