@weasel-js/labkit 1.2.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 (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ import type { PrefGroup, PrefLeaf, PrefRenderer } from '@weasel-js/ui';
2
+
3
+ /**
4
+ * Renders the control cell for one config leaf. Identical to weasel-ui's
5
+ * `PrefRenderer` on purpose — aliased rather than redeclared so the two
6
+ * cannot drift.
7
+ */
8
+ export type ControlRenderer = PrefRenderer;
9
+
10
+ /** One labeled choice in an `enum` leaf. */
11
+ export interface ConfigOption {
12
+ value: string;
13
+ label: string;
14
+ }
15
+
16
+ /** Everything a leaf can carry beyond its kind and default. A flat union of
17
+ * every `Pref*` leaf's extras, plus labkit's own `debounceMs`. */
18
+ export interface Annotations {
19
+ name?: string;
20
+ description?: string;
21
+ hidden?: boolean;
22
+ block?: boolean;
23
+ pair?: string;
24
+ min?: number;
25
+ max?: number;
26
+ step?: number;
27
+ control?: string;
28
+ options?: readonly ConfigOption[];
29
+ placeholder?: string;
30
+ maxLength?: number;
31
+ /** Milliseconds to debounce a string leaf's live writes. Default 150. */
32
+ debounceMs?: number;
33
+ }
34
+
35
+ /** What a rule contributes. `kind` is honored only while it is still unset,
36
+ * which is what makes `f.value` claimable and a kinded factory final. */
37
+ export type LeafPatch = Annotations & { kind?: string };
38
+
39
+ /** What a rule is given for the leaf it is deciding about. */
40
+ export interface ConfigRuleContext {
41
+ /** The config key this leaf writes. */
42
+ key: string;
43
+ /** Dotted path within the schema. Equal to `key` while schemas are flat. */
44
+ path: string;
45
+ /** The leaf's default value. A rule may read it but never change it. */
46
+ default: unknown;
47
+ /** What earlier rules and the author's own annotations have settled. */
48
+ leaf: Readonly<LeafPatch>;
49
+ }
50
+
51
+ /**
52
+ * Decides part of how a leaf is presented. Returns a patch, or null to
53
+ * abstain. Merging is gap-filling: a property already settled is never
54
+ * overwritten, so the author's annotations beat every rule and a consumer's
55
+ * rules beat labkit's built-ins.
56
+ */
57
+ export type ConfigRule = (ctx: ConfigRuleContext) => LeafPatch | null;
58
+
59
+ /** A presentational bucket of leaves, rendered under one heading. */
60
+ export interface SectionSpec {
61
+ label: string;
62
+ paths: readonly string[];
63
+ }
64
+
65
+ /** A schema resolved against a set of rules: the vocabulary weasel-ui renders,
66
+ * plus the three things labkit keeps on the side because `PrefLeaf` has no
67
+ * field for them. */
68
+ export interface ResolvedConfig {
69
+ /** Flat: every leaf is a direct child, so a leaf's path is its config key. */
70
+ group: PrefGroup;
71
+ sections: readonly SectionSpec[];
72
+ showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
73
+ /** Node-level `.render` overrides, keyed by path. */
74
+ renderers: Readonly<Record<string, ControlRenderer>>;
75
+ }
76
+
77
+ /** Per-node extras that do not belong on a `PrefLeaf`. */
78
+ export interface NodeOptions {
79
+ section?: string;
80
+ showIf?: (config: Record<string, unknown>) => boolean;
81
+ render?: ControlRenderer;
82
+ validate?: (leaf: PrefLeaf) => string[];
83
+ }
84
+
85
+ /** The builder's leaf: a kind (or null, to be decided by rules), a default,
86
+ * an annotation bag, and the extras above. */
87
+ export interface ConfigNode<T = unknown> {
88
+ readonly kind: string | null;
89
+ readonly default: T;
90
+ readonly annotations: Readonly<Annotations>;
91
+ readonly options: Readonly<NodeOptions>;
92
+ }
93
+
94
+ /** The value type a node produces. */
95
+ export type NodeValue<N> = N extends ConfigNode<infer T> ? T : never;
96
+
97
+ /** The config type a builder shape produces. */
98
+ export type InferConfig<S> = { [K in keyof S]: NodeValue<S[K]> };
99
+
100
+ /** An instrument's config, declared once. */
101
+ export interface ConfigSchema<TC> {
102
+ readonly nodes: Readonly<Record<string, ConfigNode>>;
103
+ /** The starting config — what `defaultConfig()` would have returned. */
104
+ defaults(): TC;
105
+ }
106
+
107
+ /** The config type behind a schema: `ConfigOf<typeof sceneConfig>`. */
108
+ export type ConfigOf<S> = S extends ConfigSchema<infer TC> ? TC : never;
@@ -0,0 +1,30 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import type { Instrument } from '../instrument/types';
3
+ import { LabContext } from '../lab/LabContext';
4
+ import { fromConfigFields } from './fromConfigField';
5
+ import { resolveConfigSchema } from './resolve';
6
+ import type { ResolvedConfig } from './types';
7
+
8
+ const NO_RULES: readonly [] = [];
9
+
10
+ /**
11
+ * An instrument's controls, resolved against the surrounding lab's rules.
12
+ *
13
+ * Resolution cannot happen in `defineInstrument` — rules are lab-scoped and
14
+ * that runs at module load — so it happens here, at render. Both instrument
15
+ * paths land in the same shape, so callers never branch on which was used.
16
+ */
17
+ export function useConfigSchema(
18
+ // biome-ignore lint/suspicious/noExplicitAny: instruments are stored contravariantly; see InstrumentList
19
+ instrument: Instrument<any, any, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
20
+ ): ResolvedConfig {
21
+ const lab = useContext(LabContext);
22
+ const rules = lab?.configRules ?? NO_RULES;
23
+ return useMemo(
24
+ () =>
25
+ instrument.config
26
+ ? resolveConfigSchema(instrument.config, rules)
27
+ : fromConfigFields(instrument.configSchema?.() ?? []),
28
+ [instrument, rules],
29
+ );
30
+ }
@@ -0,0 +1,20 @@
1
+ import type { ResolvedConfig } from './types';
2
+
3
+ /**
4
+ * Whether a leaf's row should be drawn right now.
5
+ *
6
+ * `hidden` is the leaf's own static flag; `showIf` is the schema's predicate
7
+ * over the live config. Both are presentational — a hidden leaf keeps its
8
+ * value and the instrument still reads it.
9
+ */
10
+ export function isLeafVisible(
11
+ resolved: ResolvedConfig,
12
+ path: string,
13
+ config: Record<string, unknown>,
14
+ showHidden = false,
15
+ ): boolean {
16
+ const leaf = resolved.group.children[path];
17
+ if (leaf && 'hidden' in leaf && leaf.hidden === true && !showHidden) return false;
18
+ const predicate = resolved.showIf.get(path);
19
+ return predicate ? predicate(config) : true;
20
+ }
@@ -1,61 +1,7 @@
1
+ // The panel is a `<PropertyList>` now, so the rows carry their own styling
2
+ // from PropertyPanel.less. This class remains as the consumer-facing hook.
3
+ // It carries no padding: whatever mounts the panel insets it, and a second
4
+ // inset here doubled the gutter inside an already narrow sidebar.
1
5
  .lk-control-panel {
2
- display: flex;
3
- flex-direction: column;
4
- gap: var(--wzl-space-sm);
5
- padding: var(--wzl-space-sm);
6
- }
7
-
8
- .lk-control-row {
9
- display: flex;
10
- flex-direction: column;
11
- gap: var(--wzl-space-xs);
12
- }
13
-
14
- .lk-control-row--inline {
15
- flex-direction: row;
16
- align-items: center;
17
- gap: var(--wzl-space-sm);
18
- }
19
-
20
- .lk-control-label {
21
- font-size: var(--wzl-font-size-sm);
22
- color: var(--wzl-fg);
23
- }
24
-
25
- .lk-control-input {
26
- width: 100%;
27
- background: var(--wzl-surface-raised);
28
- color: var(--wzl-fg);
29
- border: 1px solid var(--wzl-border);
30
- border-radius: 4px;
31
- padding: 2px 6px;
32
- }
33
-
34
- .lk-control-input--range {
35
6
  padding: 0;
36
- border: none;
37
- }
38
-
39
- .lk-control-input--checkbox {
40
- width: auto;
41
- }
42
-
43
- .lk-control-input--color {
44
- width: 40px;
45
- height: 24px;
46
- padding: 0;
47
- }
48
-
49
- .lk-control-slider-value {
50
- font-size: var(--wzl-font-size-sm);
51
- font-variant-numeric: tabular-nums;
52
- color: var(--wzl-fg-muted);
53
- min-width: 3ch;
54
- text-align: right;
55
- }
56
-
57
- .lk-control-slider-row {
58
- display: flex;
59
- align-items: center;
60
- gap: var(--wzl-space-sm);
61
7
  }