@weasel-js/labkit 1.1.0 → 1.3.0-pre.0

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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -0,0 +1,224 @@
1
+ import type { PrefLeaf } from '@weasel-js/ui';
2
+ import type {
3
+ Annotations,
4
+ ConfigNode,
5
+ ConfigOption,
6
+ ConfigSchema,
7
+ ControlRenderer,
8
+ InferConfig,
9
+ NodeOptions,
10
+ } from './types';
11
+
12
+ /** Shared chaining surface. Every method clones, so a node can be reused as a
13
+ * base for several leaves without one bleeding into the next. */
14
+ abstract class BaseNode<T> implements ConfigNode<T> {
15
+ abstract readonly kind: string | null;
16
+
17
+ // `default` is a reserved word in a parameter position, so the field is
18
+ // declared rather than taken as a parameter property.
19
+ readonly default: T;
20
+
21
+ constructor(
22
+ defaultValue: T,
23
+ readonly annotations: Readonly<Annotations> = {},
24
+ readonly options: Readonly<NodeOptions> = {},
25
+ ) {
26
+ this.default = defaultValue;
27
+ }
28
+
29
+ /** Clone into the same subclass. Subclasses with extra constructor
30
+ * arguments override this. */
31
+ protected with(annotations: Annotations, options: NodeOptions): this {
32
+ const Ctor = this.constructor as new (
33
+ d: T,
34
+ a: Annotations,
35
+ o: NodeOptions,
36
+ ) => this;
37
+ return new Ctor(this.default, annotations, options);
38
+ }
39
+
40
+ protected ann(patch: Annotations): this {
41
+ return this.with({ ...this.annotations, ...patch }, this.options);
42
+ }
43
+
44
+ protected opt(patch: NodeOptions): this {
45
+ return this.with(this.annotations, { ...this.options, ...patch });
46
+ }
47
+
48
+ /** Human-readable label. Defaults to the key, title-cased. */
49
+ label(name: string): this {
50
+ return this.ann({ name });
51
+ }
52
+
53
+ /** Longer help text, surfaced as a tooltip on the row label. */
54
+ describe(description: string): this {
55
+ return this.ann({ description });
56
+ }
57
+
58
+ /** Omit from the panel unless it is asked to show hidden leaves. */
59
+ hidden(): this {
60
+ return this.ann({ hidden: true });
61
+ }
62
+
63
+ /** Pair with a sibling sharing this id, side-by-side on one row. */
64
+ pair(pair: string): this {
65
+ return this.ann({ pair });
66
+ }
67
+
68
+ /** Render under a named section heading. */
69
+ section(section: string): this {
70
+ return this.opt({ section });
71
+ }
72
+
73
+ /** Show this row only while the predicate holds. Presentational — the value
74
+ * stays in config and the instrument still reads it. */
75
+ showIf(predicate: (config: Record<string, unknown>) => boolean): this {
76
+ return this.opt({ showIf: predicate });
77
+ }
78
+
79
+ /** Draw this one row yourself, keeping the kind, default and validation. */
80
+ render(renderer: ControlRenderer): this {
81
+ return this.opt({ render: renderer });
82
+ }
83
+ }
84
+
85
+ class NumberNode extends BaseNode<number> {
86
+ readonly kind = 'number';
87
+
88
+ /** Bound the value. A number with both bounds renders as a slider. */
89
+ range(min: number, max: number): this {
90
+ return this.ann({ min, max });
91
+ }
92
+
93
+ step(step: number): this {
94
+ return this.ann({ step });
95
+ }
96
+
97
+ /** Force a slider even without both bounds. */
98
+ slider(): this {
99
+ return this.ann({ control: 'slider' });
100
+ }
101
+
102
+ /** Force a typed input even when both bounds are set. */
103
+ input(): this {
104
+ return this.ann({ control: 'input' });
105
+ }
106
+ }
107
+
108
+ class BooleanNode extends BaseNode<boolean> {
109
+ readonly kind = 'boolean';
110
+
111
+ /** Ask for a switch. `ControlPanel` still draws a checkbox; weasel-ui's
112
+ * `PrefsForm` honors the distinction. */
113
+ toggle(): this {
114
+ return this.ann({ control: 'switch' });
115
+ }
116
+ }
117
+
118
+ class StringNode extends BaseNode<string> {
119
+ readonly kind = 'string';
120
+
121
+ placeholder(placeholder: string): this {
122
+ return this.ann({ placeholder });
123
+ }
124
+
125
+ maxLength(maxLength: number): this {
126
+ return this.ann({ maxLength });
127
+ }
128
+
129
+ /** Milliseconds to debounce live writes. 0 commits every keystroke. */
130
+ debounce(debounceMs: number): this {
131
+ return this.ann({ debounceMs });
132
+ }
133
+ }
134
+
135
+ class ColorNode extends BaseNode<string> {
136
+ readonly kind = 'color';
137
+ }
138
+
139
+ class EnumNode<T extends string> extends BaseNode<T> {
140
+ readonly kind = 'enum';
141
+
142
+ /** Render as a segmented control rather than a select. */
143
+ radio(): this {
144
+ return this.ann({ control: 'radio' });
145
+ }
146
+
147
+ /** Relabel the choices. Named `labels` rather than `options` because
148
+ * `options` is the node's own extras bag. */
149
+ labels(options: readonly ConfigOption[]): this {
150
+ return this.ann({ options });
151
+ }
152
+ }
153
+
154
+ /** A leaf whose kind the rule chain decides. */
155
+ class ValueNode<T> extends BaseNode<T> {
156
+ readonly kind = null;
157
+ }
158
+
159
+ /** A leaf of a kind labkit does not ship a row for. */
160
+ class CustomNode<T> extends BaseNode<T> {
161
+ constructor(
162
+ readonly kind: string,
163
+ defaultValue: T,
164
+ annotations: Readonly<Annotations> = {},
165
+ options: Readonly<NodeOptions> = {},
166
+ ) {
167
+ super(defaultValue, annotations, options);
168
+ }
169
+
170
+ protected override with(annotations: Annotations, options: NodeOptions): this {
171
+ return new CustomNode(this.kind, this.default, annotations, options) as this;
172
+ }
173
+ }
174
+
175
+ const expand = <T extends string>(
176
+ options: readonly T[] | readonly ConfigOption[],
177
+ ): readonly ConfigOption[] =>
178
+ options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
179
+
180
+ /**
181
+ * Builds an instrument's config: the values, their types, and how each is
182
+ * edited, in one declaration.
183
+ *
184
+ * ```ts
185
+ * const config = f.schema({
186
+ * showGrid: f.boolean(true),
187
+ * cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
188
+ * })
189
+ * ```
190
+ */
191
+ export const f = {
192
+ number: (def: number): NumberNode => new NumberNode(def),
193
+ boolean: (def: boolean): BooleanNode => new BooleanNode(def),
194
+ string: (def: string): StringNode => new StringNode(def),
195
+ color: (def: string): ColorNode => new ColorNode(def),
196
+
197
+ /** A fixed set of choices. The default's literal type flows into the config
198
+ * type, so `f.enum('fast', ['fast', 'accurate'])` gives
199
+ * `'fast' | 'accurate'` with no `as const`. */
200
+ enum: <const T extends string>(
201
+ def: T,
202
+ options: readonly T[] | readonly ConfigOption[],
203
+ ): EnumNode<T> => new EnumNode<T>(def, { options: expand(options) }),
204
+
205
+ /** A leaf with no declared kind: the rule chain decides, which is how a lab
206
+ * states a convention like "every `*Color` key is a color picker". */
207
+ value: <T>(def: T): ValueNode<T> => new ValueNode(def),
208
+
209
+ /** A leaf of a kind a lab supplies the control for, through `controls`. */
210
+ custom: <T>(kind: string, def: T, validate?: (leaf: PrefLeaf) => string[]): CustomNode<T> =>
211
+ new CustomNode(kind, def, {}, validate ? { validate } : {}),
212
+
213
+ /** Collect leaves into an instrument's config. */
214
+ schema<S extends Record<string, ConfigNode>>(nodes: S): ConfigSchema<InferConfig<S>> {
215
+ return {
216
+ nodes,
217
+ defaults: () => {
218
+ const out: Record<string, unknown> = {};
219
+ for (const [key, node] of Object.entries(nodes)) out[key] = node.default;
220
+ return out as InferConfig<S>;
221
+ },
222
+ };
223
+ },
224
+ };
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ConfigField } from '../controls/types';
3
+ import { fromConfigFields } from './fromConfigField';
4
+ import { isLeafVisible } from './visible';
5
+
6
+ const leaf = (fields: ConfigField[], k: string) =>
7
+ fromConfigFields(fields).group.children[k] as unknown as Record<string, unknown>;
8
+
9
+ describe('fromConfigFields', () => {
10
+ it('maps every ConfigField type to its PrefLeaf kind', () => {
11
+ expect(
12
+ leaf([{ type: 'slider', key: 'a', label: 'A', default: 1, min: 0, max: 2 }], 'a'),
13
+ ).toMatchObject({
14
+ kind: 'number',
15
+ control: 'slider',
16
+ min: 0,
17
+ max: 2,
18
+ default: 1,
19
+ name: 'A',
20
+ });
21
+ expect(leaf([{ type: 'number', key: 'b', label: 'B', default: 1 }], 'b')).toMatchObject({
22
+ kind: 'number',
23
+ control: 'input',
24
+ });
25
+ expect(leaf([{ type: 'checkbox', key: 'c', label: 'C', default: true }], 'c')).toMatchObject({
26
+ kind: 'boolean',
27
+ control: 'checkbox',
28
+ });
29
+ expect(
30
+ leaf(
31
+ [
32
+ {
33
+ type: 'select',
34
+ key: 'd',
35
+ label: 'D',
36
+ default: 'x',
37
+ options: [{ value: 'x', label: 'X' }],
38
+ },
39
+ ],
40
+ 'd',
41
+ ),
42
+ ).toMatchObject({ kind: 'enum', options: [{ value: 'x', label: 'X' }] });
43
+ expect(leaf([{ type: 'text', key: 'e', label: 'E', default: '' }], 'e')).toMatchObject({
44
+ kind: 'string',
45
+ });
46
+ expect(leaf([{ type: 'color', key: 'g', label: 'G', default: '#fff' }], 'g')).toMatchObject({
47
+ kind: 'color',
48
+ });
49
+ });
50
+
51
+ it('gives every leaf the description PrefBase requires', () => {
52
+ expect(leaf([{ type: 'checkbox', key: 'c', label: 'C', default: true }], 'c').description).toBe(
53
+ '',
54
+ );
55
+ });
56
+
57
+ it('carries the text debounce through as a labkit extra', () => {
58
+ expect(
59
+ leaf([{ type: 'text', key: 'e', label: 'E', default: '', debounceMs: 0 }], 'e').debounceMs,
60
+ ).toBe(0);
61
+ });
62
+
63
+ it('drops absent optional annotations rather than settling them undefined', () => {
64
+ expect('min' in leaf([{ type: 'number', key: 'b', label: 'B', default: 1 }], 'b')).toBe(false);
65
+ });
66
+
67
+ it('carries an unrecognized type through as its kind', () => {
68
+ const odd = { type: 'vector2', key: 'v', label: 'V', default: 0 } as unknown as ConfigField;
69
+ expect(leaf([odd], 'v').kind).toBe('vector2');
70
+ });
71
+
72
+ it('preserves declaration order', () => {
73
+ const r = fromConfigFields([
74
+ { type: 'checkbox', key: 'z', label: 'Z', default: true },
75
+ { type: 'checkbox', key: 'a', label: 'A', default: true },
76
+ ]);
77
+ expect(Object.keys(r.group.children)).toEqual(['z', 'a']);
78
+ });
79
+ });
80
+
81
+ describe('isLeafVisible', () => {
82
+ it('is true for a leaf with no predicate', () => {
83
+ const r = fromConfigFields([{ type: 'checkbox', key: 'a', label: 'A', default: true }]);
84
+ expect(isLeafVisible(r, 'a', {})).toBe(true);
85
+ });
86
+
87
+ it('honors a hidden leaf unless asked to show hidden', () => {
88
+ const r = fromConfigFields([{ type: 'checkbox', key: 'a', label: 'A', default: true }]);
89
+ (r.group.children.a as unknown as Record<string, unknown>).hidden = true;
90
+ expect(isLeafVisible(r, 'a', {})).toBe(false);
91
+ expect(isLeafVisible(r, 'a', {}, true)).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,74 @@
1
+ import type { PrefLeaf } from '@weasel-js/ui';
2
+ import type { ConfigField } from '../controls/types';
3
+ import type { LeafPatch, ResolvedConfig } from './types';
4
+
5
+ /** One `ConfigField` as the `Pref*` vocabulary spells it. The two describe the
6
+ * same thing; `ConfigField` just keys on the control where `PrefLeaf`
7
+ * separates the value's kind from how it is presented. */
8
+ function toLeaf(field: ConfigField): LeafPatch {
9
+ const base = { name: field.label, description: '' };
10
+ switch (field.type) {
11
+ case 'slider':
12
+ return {
13
+ ...base,
14
+ kind: 'number',
15
+ control: 'slider',
16
+ min: field.min,
17
+ max: field.max,
18
+ step: field.step,
19
+ };
20
+ case 'number':
21
+ return {
22
+ ...base,
23
+ kind: 'number',
24
+ control: 'input',
25
+ min: field.min,
26
+ max: field.max,
27
+ step: field.step,
28
+ };
29
+ case 'checkbox':
30
+ return { ...base, kind: 'boolean', control: 'checkbox' };
31
+ case 'select':
32
+ return { ...base, kind: 'enum', options: field.options };
33
+ case 'text':
34
+ return {
35
+ ...base,
36
+ kind: 'string',
37
+ placeholder: field.placeholder,
38
+ maxLength: field.maxLength,
39
+ debounceMs: field.debounceMs,
40
+ };
41
+ case 'color':
42
+ return { ...base, kind: 'color' };
43
+ default:
44
+ // A schema can arrive from outside TypeScript. Carry the kind through
45
+ // so a lab-supplied renderer can still claim it.
46
+ return { ...base, kind: (field as ConfigField).type };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Adapt a legacy `configSchema(): ConfigField[]` into the resolved shape, so
52
+ * both instrument paths reach one renderer.
53
+ *
54
+ * Rules do not run here: a hand-written `ConfigField` already states its label
55
+ * and control, which is everything the rule chain would have inferred.
56
+ */
57
+ export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig {
58
+ const children: Record<string, PrefLeaf> = {};
59
+ for (const field of fields) {
60
+ const patch = toLeaf(field);
61
+ for (const key of Object.keys(patch)) {
62
+ if ((patch as Record<string, unknown>)[key] === undefined) {
63
+ delete (patch as Record<string, unknown>)[key];
64
+ }
65
+ }
66
+ children[field.key] = { ...patch, default: field.default } as PrefLeaf;
67
+ }
68
+ return {
69
+ group: { name: '', children },
70
+ sections: [],
71
+ showIf: new Map(),
72
+ renderers: {},
73
+ };
74
+ }
@@ -0,0 +1,22 @@
1
+ export { f } from './builder';
2
+ export { fromConfigFields } from './fromConfigField';
3
+ export { resolveConfigSchema } from './resolve';
4
+ export { applyRules, builtinRules, titleCase } from './rules';
5
+ export type {
6
+ Annotations,
7
+ ConfigNode,
8
+ ConfigOf,
9
+ ConfigOption,
10
+ ConfigRule,
11
+ ConfigRuleContext,
12
+ ConfigSchema,
13
+ ControlRenderer,
14
+ InferConfig,
15
+ LeafPatch,
16
+ NodeOptions,
17
+ NodeValue,
18
+ ResolvedConfig,
19
+ SectionSpec,
20
+ } from './types';
21
+ export { useConfigSchema } from './useConfigSchema';
22
+ export { isLeafVisible } from './visible';
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { f } from './builder';
3
+ import { resolveConfigSchema } from './resolve';
4
+ import { titleCase } from './rules';
5
+ import type { ConfigRule } from './types';
6
+
7
+ const leafAt = (r: ReturnType<typeof resolveConfigSchema>, k: string) =>
8
+ r.group.children[k] as unknown as Record<string, unknown>;
9
+
10
+ describe('titleCase', () => {
11
+ it('splits camelCase without shattering acronyms', () => {
12
+ expect(titleCase('cellSize')).toBe('Cell size');
13
+ expect(titleCase('showGrid')).toBe('Show grid');
14
+ expect(titleCase('useHDR')).toBe('Use hdr');
15
+ expect(titleCase('seed')).toBe('Seed');
16
+ });
17
+ });
18
+
19
+ describe('resolveConfigSchema', () => {
20
+ it('titleCases a missing label', () => {
21
+ const r = resolveConfigSchema(f.schema({ cellSize: f.number(20) }), []);
22
+ expect(leafAt(r, 'cellSize').name).toBe('Cell size');
23
+ });
24
+
25
+ it('an explicit label beats the built-in rule', () => {
26
+ const r = resolveConfigSchema(f.schema({ cellSize: f.number(20).label('Grid spacing') }), []);
27
+ expect(leafAt(r, 'cellSize').name).toBe('Grid spacing');
28
+ });
29
+
30
+ it('carries the default through', () => {
31
+ const r = resolveConfigSchema(f.schema({ cellSize: f.number(20) }), []);
32
+ expect(leafAt(r, 'cellSize').default).toBe(20);
33
+ });
34
+
35
+ it('picks a slider when a number has both bounds', () => {
36
+ const r = resolveConfigSchema(f.schema({ a: f.number(1).range(0, 10), b: f.number(1) }), []);
37
+ expect(leafAt(r, 'a').control).toBe('slider');
38
+ expect(leafAt(r, 'b').control).toBeUndefined();
39
+ });
40
+
41
+ it('an explicit .input() beats the slider rule', () => {
42
+ const r = resolveConfigSchema(f.schema({ a: f.number(1).range(0, 10).input() }), []);
43
+ expect(leafAt(r, 'a').control).toBe('input');
44
+ });
45
+
46
+ it('infers kind from typeof for an f.value leaf', () => {
47
+ const r = resolveConfigSchema(
48
+ f.schema({ on: f.value(true), n: f.value(2), s: f.value('x') }),
49
+ [],
50
+ );
51
+ expect(leafAt(r, 'on').kind).toBe('boolean');
52
+ expect(leafAt(r, 'n').kind).toBe('number');
53
+ expect(leafAt(r, 's').kind).toBe('string');
54
+ });
55
+
56
+ it('a consumer rule claims an f.value kind before the built-in', () => {
57
+ const colorByName: ConfigRule = (ctx) =>
58
+ ctx.key.endsWith('Color') ? { kind: 'color' } : null;
59
+ const r = resolveConfigSchema(f.schema({ tintColor: f.value('#fff') }), [colorByName]);
60
+ expect(leafAt(r, 'tintColor').kind).toBe('color');
61
+ });
62
+
63
+ it('a consumer rule cannot overwrite a kind the factory settled', () => {
64
+ const forceColor: ConfigRule = () => ({ kind: 'color' });
65
+ const r = resolveConfigSchema(f.schema({ a: f.string('#fff') }), [forceColor]);
66
+ expect(leafAt(r, 'a').kind).toBe('string');
67
+ });
68
+
69
+ it('a consumer rule cannot overwrite an explicit annotation', () => {
70
+ const forceLabel: ConfigRule = () => ({ name: 'Forced' });
71
+ const r = resolveConfigSchema(f.schema({ a: f.number(1).label('Mine') }), [forceLabel]);
72
+ expect(leafAt(r, 'a').name).toBe('Mine');
73
+ });
74
+
75
+ it('an earlier consumer rule beats a later one', () => {
76
+ const first: ConfigRule = () => ({ name: 'First' });
77
+ const second: ConfigRule = () => ({ name: 'Second' });
78
+ const r = resolveConfigSchema(f.schema({ a: f.number(1) }), [first, second]);
79
+ expect(leafAt(r, 'a').name).toBe('First');
80
+ });
81
+
82
+ it('collects sections in first-appearance order', () => {
83
+ const r = resolveConfigSchema(
84
+ f.schema({
85
+ b: f.number(1).section('Two'),
86
+ a: f.number(1).section('One'),
87
+ c: f.number(1).section('Two'),
88
+ }),
89
+ [],
90
+ );
91
+ expect(r.sections).toEqual([
92
+ { label: 'Two', paths: ['b', 'c'] },
93
+ { label: 'One', paths: ['a'] },
94
+ ]);
95
+ });
96
+
97
+ it('collects showIf and node renderers by path', () => {
98
+ const r = resolveConfigSchema(
99
+ f.schema({
100
+ showGrid: f.boolean(true),
101
+ seed: f.value(0).showIf((c) => c.showGrid === true),
102
+ custom: f.number(1).render(() => null),
103
+ }),
104
+ [],
105
+ );
106
+ expect(r.showIf.get('seed')?.({ showGrid: false })).toBe(false);
107
+ expect(r.renderers.custom).toBeTypeOf('function');
108
+ expect(r.renderers.seed).toBeUndefined();
109
+ });
110
+
111
+ it('every leaf gets a description, since PrefBase requires one', () => {
112
+ const r = resolveConfigSchema(f.schema({ a: f.number(1) }), []);
113
+ expect(leafAt(r, 'a').description).toBe('');
114
+ });
115
+
116
+ it('preserves declaration order', () => {
117
+ const r = resolveConfigSchema(f.schema({ z: f.number(1), a: f.number(1) }), []);
118
+ expect(Object.keys(r.group.children)).toEqual(['z', 'a']);
119
+ });
120
+ });
@@ -0,0 +1,67 @@
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
+ import { applyRules, builtinRules } from './rules';
3
+ import type {
4
+ ConfigRule,
5
+ ConfigSchema,
6
+ ControlRenderer,
7
+ LeafPatch,
8
+ ResolvedConfig,
9
+ SectionSpec,
10
+ } from './types';
11
+
12
+ /** Drop keys whose value is undefined, so an unset annotation stays a gap the
13
+ * rule chain can fill rather than a settled `undefined`. */
14
+ function defined(annotations: Record<string, unknown>): LeafPatch {
15
+ const out: Record<string, unknown> = {};
16
+ for (const [k, v] of Object.entries(annotations)) if (v !== undefined) out[k] = v;
17
+ return out as LeafPatch;
18
+ }
19
+
20
+ /**
21
+ * Resolve a schema into the vocabulary weasel-ui renders, running each leaf
22
+ * through the consumer's rules and then labkit's own.
23
+ *
24
+ * The group is flat — every leaf is a direct child — so a leaf's path is its
25
+ * config key and both `ControlPanel` and `PrefsForm` address it identically.
26
+ */
27
+ export function resolveConfigSchema<TC>(
28
+ schema: ConfigSchema<TC>,
29
+ rules: readonly ConfigRule[] = [],
30
+ ): ResolvedConfig {
31
+ const children: Record<string, PrefLeaf> = {};
32
+ const sectionOrder: string[] = [];
33
+ const sectionPaths = new Map<string, string[]>();
34
+ const showIf = new Map<string, (config: Record<string, unknown>) => boolean>();
35
+ const renderers: Record<string, ControlRenderer> = {};
36
+
37
+ const chain = [...rules, ...builtinRules];
38
+
39
+ for (const [key, node] of Object.entries(schema.nodes)) {
40
+ const seed: LeafPatch = defined({
41
+ ...node.annotations,
42
+ ...(node.kind === null ? {} : { kind: node.kind }),
43
+ });
44
+
45
+ const patch = applyRules(seed, { key, path: key, default: node.default }, chain);
46
+ children[key] = { ...patch, default: node.default } as PrefLeaf;
47
+
48
+ const { section, showIf: predicate, render } = node.options;
49
+ if (section !== undefined) {
50
+ if (!sectionPaths.has(section)) {
51
+ sectionOrder.push(section);
52
+ sectionPaths.set(section, []);
53
+ }
54
+ sectionPaths.get(section)?.push(key);
55
+ }
56
+ if (predicate) showIf.set(key, predicate);
57
+ if (render) renderers[key] = render;
58
+ }
59
+
60
+ const group: PrefGroup = { name: '', children };
61
+ const sections: SectionSpec[] = sectionOrder.map((label) => ({
62
+ label,
63
+ paths: sectionPaths.get(label) ?? [],
64
+ }));
65
+
66
+ return { group, sections, showIf, renderers };
67
+ }
@@ -0,0 +1,68 @@
1
+ import type { ConfigRule, ConfigRuleContext, LeafPatch } from './types';
2
+
3
+ /** `cellSize` -> `Cell size`. Splits camelCase without shattering acronyms. */
4
+ export function titleCase(key: string): string {
5
+ const words = key
6
+ .replace(/[_-]+/g, ' ')
7
+ .split(/(?<=[a-z0-9])(?=[A-Z])/)
8
+ .join(' ')
9
+ .trim()
10
+ .toLowerCase();
11
+ return words.length === 0 ? words : words[0].toUpperCase() + words.slice(1);
12
+ }
13
+
14
+ /** An `f.value` leaf takes its kind from the shape of its default. */
15
+ const kindFromValue: ConfigRule = (ctx) => {
16
+ const t = typeof ctx.default;
17
+ if (t === 'boolean' || t === 'number' || t === 'string') return { kind: t };
18
+ return null;
19
+ };
20
+
21
+ const labelFromKey: ConfigRule = (ctx) => ({ name: titleCase(ctx.key) });
22
+
23
+ /** A number bounded on both sides is a slider; one bound or none is an input. */
24
+ const sliderWhenBounded: ConfigRule = (ctx) => {
25
+ if (ctx.leaf.kind !== 'number') return null;
26
+ const bounded = ctx.leaf.min !== undefined && ctx.leaf.max !== undefined;
27
+ return bounded ? { control: 'slider' } : null;
28
+ };
29
+
30
+ /** `PrefBase.description` is required, so every leaf must end up with one. */
31
+ const descriptionDefault: ConfigRule = () => ({ description: '' });
32
+
33
+ /**
34
+ * labkit's own inference, expressed in the same vocabulary a consumer
35
+ * extends. Runs after the consumer's rules, so a lab always wins.
36
+ *
37
+ * If one of these ever needs something a `ConfigRule` cannot express, the
38
+ * seam is too weak — that is the point of writing them this way.
39
+ */
40
+ export const builtinRules: readonly ConfigRule[] = [
41
+ kindFromValue,
42
+ labelFromKey,
43
+ sliderWhenBounded,
44
+ descriptionDefault,
45
+ ];
46
+
47
+ /**
48
+ * Fold a rule chain into one leaf patch. Gap-filling: a property already
49
+ * settled is never overwritten, so the accumulator seeded with the author's
50
+ * annotations beats every rule, and earlier rules beat later ones.
51
+ */
52
+ export function applyRules(
53
+ seed: LeafPatch,
54
+ ctx: Omit<ConfigRuleContext, 'leaf'>,
55
+ rules: readonly ConfigRule[],
56
+ ): LeafPatch {
57
+ const acc: LeafPatch = { ...seed };
58
+ for (const rule of rules) {
59
+ const patch = rule({ ...ctx, leaf: acc });
60
+ if (patch === null) continue;
61
+ for (const [k, v] of Object.entries(patch)) {
62
+ if (v !== undefined && acc[k as keyof LeafPatch] === undefined) {
63
+ (acc as Record<string, unknown>)[k] = v;
64
+ }
65
+ }
66
+ }
67
+ return acc;
68
+ }