@weasel-js/labkit 1.5.0 → 1.5.2

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 (206) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CMMo180l.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-ZTiwzYyT.d.ts +479 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
  6. package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.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-6BUQ77ZV.js +18 -0
  16. package/dist/chunk-6BUQ77ZV.js.map +1 -0
  17. package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
  18. package/dist/chunk-753Y6HE6.js.map +1 -0
  19. package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
  20. package/dist/chunk-A2ACAGW6.js.map +1 -0
  21. package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
  22. package/dist/chunk-CKUKF72R.js.map +1 -0
  23. package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
  24. package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
  25. package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
  26. package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  27. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  28. package/dist/chunk-ERFMC6WU.js.map +1 -0
  29. package/dist/chunk-FKLT3ITC.js +590 -0
  30. package/dist/chunk-FKLT3ITC.js.map +1 -0
  31. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  32. package/dist/chunk-IJJUK4FU.js.map +1 -0
  33. package/dist/{chunk-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
  34. package/dist/chunk-KJCUERQF.js.map +1 -0
  35. package/dist/chunk-LASCBWW7.js +13456 -0
  36. package/dist/chunk-LASCBWW7.js.map +1 -0
  37. package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
  38. package/dist/chunk-LRI3MBGR.js.map +1 -0
  39. package/dist/chunk-REOBGYMC.js +150 -0
  40. package/dist/chunk-REOBGYMC.js.map +1 -0
  41. package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
  42. package/dist/chunk-TT5C5S6R.js.map +1 -0
  43. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  44. package/dist/chunk-Z76KEZAX.js.map +1 -0
  45. package/dist/config/index.d.ts +62 -19
  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 +230 -62
  51. package/dist/index.js +1367 -1698
  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 +57 -230
  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 +266 -57
  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/index.ts +0 -9
  77. package/src/canvas/usePanZoom.test.ts +28 -0
  78. package/src/canvas/usePanZoom.ts +11 -4
  79. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  80. package/src/chrome/LabChrome.tsx +21 -5
  81. package/src/chrome/builtins.test.ts +1 -0
  82. package/src/chrome/builtins.tsx +1 -0
  83. package/src/chrome/index.ts +1 -0
  84. package/src/chrome/labTypes.ts +3 -3
  85. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  86. package/src/chrome/regions/PaletteRegion.tsx +6 -10
  87. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  88. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  89. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  90. package/src/chrome/types.ts +11 -2
  91. package/src/config/auto.test.ts +12 -0
  92. package/src/config/auto.ts +15 -0
  93. package/src/config/autoConfig.test.ts +107 -0
  94. package/src/config/autoConfig.ts +153 -0
  95. package/src/config/builder.test.ts +38 -0
  96. package/src/config/builder.ts +55 -11
  97. package/src/config/index.ts +3 -0
  98. package/src/config/path.ts +7 -2
  99. package/src/config/resolve.test.ts +25 -0
  100. package/src/config/resolve.ts +17 -1
  101. package/src/config/types.ts +32 -2
  102. package/src/config/useResolvedConfig.ts +26 -0
  103. package/src/controls/AutoControls.stories.tsx +88 -0
  104. package/src/controls/ControlPanel.less +23 -0
  105. package/src/controls/ControlPanel.stories.tsx +83 -1
  106. package/src/controls/ControlPanel.test.tsx +457 -4
  107. package/src/controls/ControlPanel.tsx +534 -73
  108. package/src/index.test.ts +0 -2
  109. package/src/index.ts +33 -11
  110. package/src/instrument/serializers.ts +29 -1
  111. package/src/lab/Lab.chrome.test.tsx +16 -0
  112. package/src/lab/Lab.stories.tsx +1 -1
  113. package/src/lab/Lab.surface.test.tsx +7 -2
  114. package/src/lab/Lab.test.tsx +79 -1
  115. package/src/lab/Lab.tsx +124 -16
  116. package/src/lab/LabContext.ts +8 -0
  117. package/src/lab/LabFit.stories.tsx +4 -4
  118. package/src/lab/LabFullChrome.stories.tsx +1 -1
  119. package/src/lab/LabHeader.tsx +0 -1
  120. package/src/lab/LabRoot.test.tsx +56 -0
  121. package/src/lab/LabRoot.tsx +47 -0
  122. package/src/lab/LabShell.less +24 -3
  123. package/src/lab/LabShell.stories.tsx +50 -1
  124. package/src/lab/LabShell.tsx +18 -27
  125. package/src/lab/LabSwitcher.less +10 -10
  126. package/src/lab/LabSwitcher.test.tsx +14 -9
  127. package/src/lab/LabSwitcher.tsx +8 -4
  128. package/src/lab/Workspace.less +1 -1
  129. package/src/lab/Workspace.stories.tsx +1 -1
  130. package/src/lab/Workspace.surface.test.tsx +1 -1
  131. package/src/lab/index.ts +2 -0
  132. package/src/layers/LayerList.less +1 -1
  133. package/src/layers/LayerList.stories.tsx +2 -2
  134. package/src/loupe/AGENTS.md +16 -4
  135. package/src/loupe/LoupeGestures.tsx +67 -0
  136. package/src/loupe/TrialLoupe.tsx +43 -28
  137. package/src/loupe/index.ts +4 -0
  138. package/src/loupe/loupeActions.ts +98 -0
  139. package/src/loupe/useLoupe.test.tsx +59 -2
  140. package/src/loupe/useLoupe.ts +28 -48
  141. package/src/passthrough/weasel-ui.ts +3 -3
  142. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  143. package/src/primitives/FpsMeter.less +6 -1
  144. package/src/primitives/FpsMeter.stories.tsx +1 -1
  145. package/src/primitives/FpsMeter.test.tsx +12 -4
  146. package/src/primitives/FpsMeter.tsx +6 -1
  147. package/src/primitives/JobProgress.stories.tsx +2 -2
  148. package/src/primitives/Legend.stories.tsx +1 -1
  149. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  150. package/src/primitives/Sidebar.less +7 -0
  151. package/src/primitives/Sidebar.stories.tsx +1 -1
  152. package/src/primitives/Split.test.tsx +25 -0
  153. package/src/primitives/Split.tsx +36 -20
  154. package/src/primitives/StatusBar.less +6 -0
  155. package/src/primitives/StatusBar.stories.tsx +1 -1
  156. package/src/primitives/Toolbar.stories.tsx +1 -1
  157. package/src/specimen/Specimen.less +95 -0
  158. package/src/specimen/Specimen.stories.tsx +13 -0
  159. package/src/specimen/Specimen.test.tsx +13 -0
  160. package/src/specimen/Specimen.tsx +888 -0
  161. package/src/state/Persistence.stories.tsx +4 -4
  162. package/src/state/store.test.ts +65 -0
  163. package/src/state/store.ts +32 -8
  164. package/src/state/types.ts +31 -2
  165. package/src/state/useTrialState.test.tsx +85 -0
  166. package/src/state/useTrialState.ts +9 -1
  167. package/src/state/view.test.ts +4 -4
  168. package/src/styles.less +1 -0
  169. package/src/surface/index.ts +1 -1
  170. package/src/surface/useSurfaceTile.test.tsx +1 -1
  171. package/src/surface/useTiledSurface.test.tsx +65 -10
  172. package/src/surface/useTiledSurface.ts +23 -11
  173. package/src/theme/Interstellar.stories.tsx +2 -2
  174. package/src/theme/interstellar.test.ts +16 -5
  175. package/src/theme/interstellar.theme.json +180 -0
  176. package/src/theme/interstellar.ts +8 -5
  177. package/src/tools/labTool.ts +3 -3
  178. package/src/trial/Trial.annotations.test.tsx +13 -4
  179. package/src/trial/Trial.canvas.test.tsx +36 -0
  180. package/src/trial/Trial.config.test.tsx +75 -2
  181. package/src/trial/Trial.less +4 -0
  182. package/src/trial/Trial.stories.tsx +4 -1
  183. package/src/trial/Trial.test.tsx +3 -0
  184. package/src/trial/Trial.tsx +21 -14
  185. package/src/trial/TrialChrome.tsx +17 -3
  186. package/src/trial/trialOps.test.ts +101 -1
  187. package/src/trial/trialOps.ts +62 -4
  188. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  189. package/dist/chunk-67SJLMC7.js.map +0 -1
  190. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  191. package/dist/chunk-E44SU6XS.js +0 -50
  192. package/dist/chunk-E44SU6XS.js.map +0 -1
  193. package/dist/chunk-EKIICY6X.js.map +0 -1
  194. package/dist/chunk-KJALCDWE.js.map +0 -1
  195. package/dist/chunk-NDRYLVOW.js.map +0 -1
  196. package/dist/chunk-NS64DXMN.js.map +0 -1
  197. package/dist/chunk-PGETSEDK.js.map +0 -1
  198. package/dist/chunk-QLSV2N3G.js.map +0 -1
  199. package/dist/chunk-S2SHZRD7.js +0 -7681
  200. package/dist/chunk-S2SHZRD7.js.map +0 -1
  201. package/dist/chunk-TQBBYTAF.js +0 -280
  202. package/dist/chunk-TQBBYTAF.js.map +0 -1
  203. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  204. package/src/canvas/useOrbit.test.ts +0 -167
  205. package/src/canvas/useOrbit.ts +0 -156
  206. package/src/theme/interstellar.tokens.json +0 -61
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { auto } from './auto';
3
+ import { autoPathsOf, resolveAutoConfig } from './autoConfig';
4
+ import { f } from './builder';
5
+ import { resolveConfigSchema } from './resolve';
6
+
7
+ const schema = f.schema({
8
+ width: f.number(432),
9
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
10
+ cols: f.number(3).initial(auto),
11
+ grid: f.group({ size: f.number(8).auto(() => 99) }),
12
+ });
13
+ const resolved = resolveConfigSchema(schema);
14
+ const raw = { width: 432, gap: 12, cols: 3, grid: { size: 8 } };
15
+
16
+ describe('autoPathsOf', () => {
17
+ it('lists the dotted paths a schema starts auto', () => {
18
+ expect(autoPathsOf(resolved)).toEqual(['cols']);
19
+ });
20
+
21
+ it('reaches leaves nested under a group', () => {
22
+ const nested = resolveConfigSchema(
23
+ f.schema({ grid: f.group({ size: f.number(8).initial(auto) }) }),
24
+ );
25
+ expect(autoPathsOf(nested)).toEqual(['grid.size']);
26
+ });
27
+ });
28
+
29
+ describe('resolveAutoConfig', () => {
30
+ it('leaves a config with nothing auto exactly as it was', () => {
31
+ expect(resolveAutoConfig(resolved, raw, new Set())).toEqual(raw);
32
+ });
33
+
34
+ it('replaces an auto path that has a resolver with the computed value', () => {
35
+ const out = resolveAutoConfig(resolved, raw, new Set(['gap']));
36
+ expect(out.gap).toBe(18);
37
+ expect(out.width).toBe(432);
38
+ });
39
+
40
+ it('deletes an auto path that has no resolver', () => {
41
+ const out = resolveAutoConfig(resolved, raw, new Set(['cols'])) as Record<string, unknown>;
42
+ expect('cols' in out).toBe(false);
43
+ });
44
+
45
+ it('resolves a leaf nested under a group', () => {
46
+ const out = resolveAutoConfig(resolved, raw, new Set(['grid.size'])) as {
47
+ grid: { size: number };
48
+ };
49
+ expect(out.grid.size).toBe(99);
50
+ });
51
+
52
+ it('lets one resolver read another auto path, already resolved', () => {
53
+ const chained = resolveConfigSchema(
54
+ f.schema({
55
+ a: f.number(1).auto(() => 10),
56
+ b: f.number(2).auto((c) => (c.a as number) * 3),
57
+ }),
58
+ );
59
+ const out = resolveAutoConfig(chained, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
60
+ a: number;
61
+ b: number;
62
+ };
63
+ expect(out).toEqual({ a: 10, b: 30 });
64
+ });
65
+
66
+ it('resolves a dependency declared after its dependent', () => {
67
+ const backward = resolveConfigSchema(
68
+ f.schema({
69
+ b: f.number(2).auto((c) => (c.a as number) * 3),
70
+ a: f.number(1).auto(() => 10),
71
+ }),
72
+ );
73
+ const out = resolveAutoConfig(backward, { a: 1, b: 2 }, new Set(['a', 'b'])) as {
74
+ a: number;
75
+ b: number;
76
+ };
77
+ expect(out).toEqual({ a: 10, b: 30 });
78
+ });
79
+
80
+ it('throws naming the path when resolvers cycle', () => {
81
+ const cyclic = resolveConfigSchema(
82
+ f.schema({
83
+ a: f.number(1).auto((c) => (c.b as number) + 1),
84
+ b: f.number(2).auto((c) => (c.a as number) + 1),
85
+ }),
86
+ );
87
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/cycle/i);
88
+ expect(() => resolveAutoConfig(cyclic, { a: 1, b: 2 }, new Set(['a', 'b']))).toThrow(/"a"/);
89
+ });
90
+
91
+ it('throws on a resolver that reads its own path', () => {
92
+ const selfish = resolveConfigSchema(
93
+ f.schema({ a: f.number(1).auto((c) => (c.a as number) + 1) }),
94
+ );
95
+ expect(() => resolveAutoConfig(selfish, { a: 1 }, new Set(['a']))).toThrow(/cycle/i);
96
+ });
97
+
98
+ it('ignores an auto path the schema does not have', () => {
99
+ expect(resolveAutoConfig(resolved, raw, new Set(['gone']))).toEqual(raw);
100
+ });
101
+
102
+ it('does not mutate the config it was given', () => {
103
+ const before = structuredClone(raw);
104
+ resolveAutoConfig(resolved, raw, new Set(['gap', 'grid.size']));
105
+ expect(raw).toEqual(before);
106
+ });
107
+ });
@@ -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's label does not toggle. */
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> {
@@ -212,15 +254,14 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
212
254
  return this.with({ ...this.annotations, name }, this.options);
213
255
  }
214
256
 
215
- /** Longer help text. Carried on the resolved `PrefGroup`, where weasel-ui's
216
- * `PrefsForm` draws it under the heading; `ControlPanel` has no place for
217
- * it yet and shows the heading alone. */
257
+ /** Longer help text. Carried on the resolved `PrefGroup`; both weasel-ui's
258
+ * `PrefsForm` and `ControlPanel` draw it under the heading. */
218
259
  describe(description: string): GroupNode<S> {
219
260
  return this.with({ ...this.annotations, description }, this.options);
220
261
  }
221
262
 
222
263
  /** Render this whole group under a named section heading. */
223
- section(label: string, opts: { collapsed?: boolean } = {}): GroupNode<S> {
264
+ section(label: string, opts: Omit<SectionOption, 'label'> = {}): GroupNode<S> {
224
265
  return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
225
266
  }
226
267
 
@@ -282,8 +323,11 @@ export const f = {
282
323
  group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
283
324
 
284
325
  /** 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 } : {}),
326
+ custom: <T>(
327
+ kind: string,
328
+ def: T,
329
+ validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[],
330
+ ): CustomNode<T> => new CustomNode(kind, def, {}, validate ? { validate } : {}),
287
331
 
288
332
  /** Collect leaves and groups into an instrument's config. */
289
333
  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 gives
127
+ * the row a real value to pin back to 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's label does not toggle. For a value
134
+ * 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
+ }