@weasel-js/labkit 1.4.4 → 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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  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-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. 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
 
@@ -35,6 +36,10 @@ describe('builder', () => {
35
36
  expect(n.annotations.suffix).toBe('px');
36
37
  });
37
38
 
39
+ it('annotates a number with a display format', () => {
40
+ expect(f.number(0).format('compact').annotations.format).toBe('compact');
41
+ });
42
+
38
43
  it('keeps a custom node kind across a chain', () => {
39
44
  const n = f.custom('vector2', { x: 0 }).label('Offset');
40
45
  expect(n.kind).toBe('vector2');
@@ -150,3 +155,40 @@ describe('builder / nested types', () => {
150
155
  expect([size, path, value]).toEqual([20, 'grid.size', 40]);
151
156
  });
152
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 } 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. */
@@ -21,7 +23,7 @@ export function isConfigBranch(entry: ConfigEntry): entry is ConfigBranch {
21
23
 
22
24
  /** Shared chaining surface. Every method clones, so a node can be reused as a
23
25
  * base for several leaves without one bleeding into the next. */
24
- abstract class BaseNode<T> implements ConfigNode<T> {
26
+ export abstract class BaseNode<T> implements ConfigNode<T> {
25
27
  abstract readonly kind: string | null;
26
28
 
27
29
  // `default` is a reserved word in a parameter position, so the field is
@@ -72,8 +74,9 @@ 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,9 +90,33 @@ 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
- class NumberNode extends BaseNode<number> {
119
+ export class NumberNode extends BaseNode<number> {
93
120
  readonly kind = 'number';
94
121
 
95
122
  /** Bound the value. A number with both bounds renders as a slider. */
@@ -107,6 +134,22 @@ class NumberNode extends BaseNode<number> {
107
134
  return this.ann({ suffix });
108
135
  }
109
136
 
137
+ /** Show the value in a named format — `'compact'` reads `2.0M`. Presentation
138
+ * only: the stored value stays a plain number, and a typed `2.5m` reads back. */
139
+ format(format: PrefNumberFormat): this {
140
+ return this.ann({ format });
141
+ }
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
+
110
153
  /** Force a slider even without both bounds. */
111
154
  slider(): this {
112
155
  return this.ann({ control: 'slider' });
@@ -118,17 +161,16 @@ class NumberNode extends BaseNode<number> {
118
161
  }
119
162
  }
120
163
 
121
- class BooleanNode extends BaseNode<boolean> {
164
+ export class BooleanNode extends BaseNode<boolean> {
122
165
  readonly kind = 'boolean';
123
166
 
124
- /** Ask for a switch. `ControlPanel` still draws a checkbox; weasel-ui's
125
- * `PrefsForm` honors the distinction. */
167
+ /** Ask for a switch rather than a checkbox. */
126
168
  toggle(): this {
127
169
  return this.ann({ control: 'switch' });
128
170
  }
129
171
  }
130
172
 
131
- class StringNode extends BaseNode<string> {
173
+ export class StringNode extends BaseNode<string> {
132
174
  readonly kind = 'string';
133
175
 
134
176
  placeholder(placeholder: string): this {
@@ -145,11 +187,17 @@ class StringNode extends BaseNode<string> {
145
187
  }
146
188
  }
147
189
 
148
- class ColorNode extends BaseNode<string> {
190
+ export class ColorNode extends BaseNode<string> {
149
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
+ }
150
198
  }
151
199
 
152
- class EnumNode<T extends string> extends BaseNode<T> {
200
+ export class EnumNode<T extends string> extends BaseNode<T> {
153
201
  readonly kind = 'enum';
154
202
 
155
203
  /** Render as a segmented control rather than a select. */
@@ -165,12 +213,12 @@ class EnumNode<T extends string> extends BaseNode<T> {
165
213
  }
166
214
 
167
215
  /** A leaf whose kind the rule chain decides. */
168
- class ValueNode<T> extends BaseNode<T> {
216
+ export class ValueNode<T> extends BaseNode<T> {
169
217
  readonly kind = null;
170
218
  }
171
219
 
172
220
  /** A leaf of a kind labkit does not ship a row for. */
173
- class CustomNode<T> extends BaseNode<T> {
221
+ export class CustomNode<T> extends BaseNode<T> {
174
222
  constructor(
175
223
  readonly kind: string,
176
224
  defaultValue: T,
@@ -190,7 +238,7 @@ class CustomNode<T> extends BaseNode<T> {
190
238
  * one is read and written at a dotted path. Distinct from `.section()`, which
191
239
  * puts a heading over sibling leaves and leaves their paths alone.
192
240
  */
193
- class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
241
+ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
194
242
  constructor(
195
243
  readonly children: S,
196
244
  readonly annotations: Readonly<BranchAnnotations> = {},
@@ -214,7 +262,7 @@ class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
214
262
  }
215
263
 
216
264
  /** Render this whole group under a named section heading. */
217
- section(label: string, opts: { collapsed?: boolean } = {}): GroupNode<S> {
265
+ section(label: string, opts: Omit<SectionOption, 'label'> = {}): GroupNode<S> {
218
266
  return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
219
267
  }
220
268
 
@@ -276,8 +324,11 @@ export const f = {
276
324
  group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
277
325
 
278
326
  /** A leaf of a kind a lab supplies the control for, through `controls`. */
279
- custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
280
- 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 } : {}),
281
332
 
282
333
  /** Collect leaves and groups into an instrument's config. */
283
334
  schema<S extends ConfigShape>(nodes: S): ConfigSchema<InferConfig<S>> {
@@ -0,0 +1,11 @@
1
+ import { f } from './builder';
2
+
3
+ export const flat = f.schema({ n: f.number(1).range(0, 4), s: f.string('x').label('S') });
4
+
5
+ export const grouped = f.schema({
6
+ g: f.group({ on: f.boolean(true).toggle(), e: f.enum('a', ['a', 'b']).radio() }).label('G'),
7
+ });
8
+
9
+ export const node = f.number(1).slider();
10
+
11
+ export const group = f.group({ c: f.color('#fff'), v: f.value(1), k: f.custom('k', 'x') });
@@ -0,0 +1,51 @@
1
+ import { resolve } from 'node:path';
2
+ import ts from 'typescript';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const root = resolve(__dirname, '../../../..');
6
+ const fixture = resolve(__dirname, 'declarationEmit.fixture.ts');
7
+
8
+ /** Emits declarations for one file under the root tsconfig and returns every diagnostic, formatted. */
9
+ function declarationDiagnostics(file: string): string[] {
10
+ const parsed = ts.getParsedCommandLineOfConfigFile(
11
+ resolve(root, 'tsconfig.json'),
12
+ {},
13
+ {
14
+ ...ts.sys,
15
+ onUnRecoverableConfigFileDiagnostic: (d) => {
16
+ throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
17
+ },
18
+ },
19
+ );
20
+ if (!parsed) throw new Error('could not read the root tsconfig');
21
+ const options: ts.CompilerOptions = {
22
+ ...parsed.options,
23
+ noEmit: false,
24
+ declaration: true,
25
+ emitDeclarationOnly: true,
26
+ declarationMap: false,
27
+ sourceMap: false,
28
+ incremental: false,
29
+ tsBuildInfoFile: undefined,
30
+ types: [],
31
+ };
32
+ const program = ts.createProgram({ rootNames: [file], options });
33
+ const source = program.getSourceFile(file);
34
+ if (!source) throw new Error(`${file} is not in the program`);
35
+ const emitted = program.emit(source, () => {}, undefined, true);
36
+ const diagnostics = [
37
+ ...program.getSyntacticDiagnostics(source),
38
+ ...program.getSemanticDiagnostics(source),
39
+ ...emitted.diagnostics,
40
+ ];
41
+ return diagnostics.map((d) => {
42
+ const text = ts.flattenDiagnosticMessageText(d.messageText, '\n');
43
+ return d.file ? `${d.file.fileName}: TS${d.code} ${text}` : `TS${d.code} ${text}`;
44
+ });
45
+ }
46
+
47
+ describe('config schema declarations', () => {
48
+ it('emit for exported schemas, groups and nodes', () => {
49
+ expect(declarationDiagnostics(fixture)).toEqual([]);
50
+ }, 60_000);
51
+ });
@@ -0,0 +1,46 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const IMPORT =
6
+ /^\s*(?:import|export)\s+(?!type\b)[^'"]*from\s+['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gm;
7
+
8
+ const isFile = (path: string) => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
9
+
10
+ function walk(file: string, seen = new Set<string>()): Set<string> {
11
+ if (seen.has(file)) return seen;
12
+ seen.add(file);
13
+ const source = readFileSync(file, 'utf8');
14
+ for (const match of source.matchAll(IMPORT)) {
15
+ const spec = match[1] ?? match[2] ?? match[3];
16
+ if (!spec?.startsWith('.')) continue;
17
+ const base = resolve(dirname(file), spec.replace(/\.js$/, ''));
18
+ const candidates = [
19
+ resolve(dirname(file), spec),
20
+ `${base}.ts`,
21
+ `${base}.tsx`,
22
+ resolve(base, 'index.ts'),
23
+ resolve(base, 'index.tsx'),
24
+ ];
25
+ const hit = candidates.find(isFile);
26
+ // An import the walk cannot follow would let a stylesheet behind it pass unseen.
27
+ if (!hit) throw new Error(`${file}: cannot resolve ${spec}`);
28
+ walk(hit, seen);
29
+ }
30
+ return seen;
31
+ }
32
+
33
+ describe('@weasel-js/labkit/config', () => {
34
+ it('reaches no stylesheet', () => {
35
+ const files = [...walk(resolve(__dirname, 'index.ts'))];
36
+ expect(files.filter((f) => /\.(less|css)$/.test(f))).toEqual([]);
37
+ });
38
+
39
+ it('is published as an entry', async () => {
40
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
41
+ expect(pkg.exports['./config']).toEqual({
42
+ types: './dist/config/index.d.ts',
43
+ import: './dist/config/index.js',
44
+ });
45
+ });
46
+ });
@@ -1,4 +1,18 @@
1
- export { f, isConfigBranch } from './builder';
1
+ export { type Auto, auto, isAuto } from './auto';
2
+ export { autoPathsOf, resolveAutoConfig } from './autoConfig';
3
+ export {
4
+ BaseNode,
5
+ BooleanNode,
6
+ ColorNode,
7
+ CustomNode,
8
+ EnumNode,
9
+ f,
10
+ GroupNode,
11
+ isConfigBranch,
12
+ NumberNode,
13
+ StringNode,
14
+ ValueNode,
15
+ } from './builder';
2
16
  export { fromConfigFields } from './fromConfigField';
3
17
  export {
4
18
  fillConfigDefaults,
@@ -34,4 +48,5 @@ export type {
34
48
  ValueAtPath,
35
49
  } from './types';
36
50
  export { useConfigSchema } from './useConfigSchema';
51
+ export { useResolvedConfig } from './useResolvedConfig';
37
52
  export { isLeafVisible } from './visible';
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { f, GroupNode, NumberNode } from './index';
3
+
4
+ describe('config node classes', () => {
5
+ it('are exported as values', () => {
6
+ expect(typeof NumberNode).toBe('function');
7
+ expect(typeof GroupNode).toBe('function');
8
+ expect(f.number(1)).toBeInstanceOf(NumberNode);
9
+ expect(f.group({ n: f.number(1) })).toBeInstanceOf(GroupNode);
10
+ });
11
+
12
+ it('can be extended', () => {
13
+ class Mine extends NumberNode {}
14
+ const node = new Mine(2);
15
+ expect(node).toBeInstanceOf(NumberNode);
16
+ expect(node.default).toBe(2);
17
+ });
18
+ });
@@ -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
  }