@weasel-js/labkit 1.2.0 → 1.3.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 (227) 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/types-1Sdxy_Pv.d.ts +90 -0
  4. package/dist/_dts/types-BttTIent.d.ts +314 -0
  5. package/dist/_dts/types-DCGEcMO_.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 +3 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +9 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/chunk-3XXPU73K.js +138 -0
  13. package/dist/chunk-3XXPU73K.js.map +1 -0
  14. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-6X5RBC6G.js +366 -0
  17. package/dist/chunk-6X5RBC6G.js.map +1 -0
  18. package/dist/{chunk-574LJAV4.js → chunk-BDJXIBRZ.js} +33 -35
  19. package/dist/chunk-BDJXIBRZ.js.map +1 -0
  20. package/dist/{chunk-SFL7NFKN.js → chunk-BMW4TDP5.js} +15 -7
  21. package/dist/chunk-BMW4TDP5.js.map +1 -0
  22. package/dist/chunk-JDROYQM3.js +266 -0
  23. package/dist/chunk-JDROYQM3.js.map +1 -0
  24. package/dist/{chunk-THBG7FQZ.js → chunk-LGKRIUSW.js} +15 -12
  25. package/dist/chunk-LGKRIUSW.js.map +1 -0
  26. package/dist/{chunk-BOHF3PQO.js → chunk-QCICIHEO.js} +8129 -5196
  27. package/dist/chunk-QCICIHEO.js.map +1 -0
  28. package/dist/chunk-QRYPXSGP.js +212 -0
  29. package/dist/chunk-QRYPXSGP.js.map +1 -0
  30. package/dist/chunk-RGT4EJIB.js +6961 -0
  31. package/dist/chunk-RGT4EJIB.js.map +1 -0
  32. package/dist/chunk-SBXNQW4G.js +3 -0
  33. package/dist/chunk-SBXNQW4G.js.map +1 -0
  34. package/dist/chunk-T66HU3HX.js +17 -0
  35. package/dist/chunk-T66HU3HX.js.map +1 -0
  36. package/dist/chunk-WTI26YTM.js +266 -0
  37. package/dist/chunk-WTI26YTM.js.map +1 -0
  38. package/dist/controls/index.d.ts +19 -5
  39. package/dist/controls/index.js +3 -1
  40. package/dist/dragdrop/index.d.ts +46 -3
  41. package/dist/dragdrop/index.js +3 -1
  42. package/dist/index.d.ts +735 -565
  43. package/dist/index.js +661 -1019
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +5 -3
  46. package/dist/layers/index.js +4 -1
  47. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +3811 -3671
  50. package/dist/passthrough/weasel-ui.js +3 -2
  51. package/dist/primitives/index.d.ts +102 -8
  52. package/dist/primitives/index.js +5 -1
  53. package/dist/state/index.d.ts +3 -2
  54. package/dist/state/index.js +2 -2
  55. package/dist/styles.css +490 -803
  56. package/dist/surface/index.js +2 -1
  57. package/dist/ui/layers/index.d.ts +8 -2
  58. package/dist/ui/layers/index.js +2 -3
  59. package/dist/undo/index.d.ts +4 -2
  60. package/package.json +16 -7
  61. package/src/canvas/AGENTS.md +4 -0
  62. package/src/canvas/CanvasStack.tsx +6 -2
  63. package/src/canvas/useLayerScheduler.ts +22 -15
  64. package/src/canvas/usePanZoom.test.ts +80 -0
  65. package/src/canvas/usePanZoom.ts +16 -2
  66. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  67. package/src/chrome/builtins.test.ts +153 -0
  68. package/src/chrome/builtins.tsx +205 -0
  69. package/src/chrome/index.ts +25 -0
  70. package/src/chrome/merge.test.ts +51 -0
  71. package/src/chrome/merge.ts +52 -0
  72. package/src/chrome/regions/PaletteRegion.less +14 -0
  73. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  74. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  75. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  76. package/src/chrome/regions/StatusRegion.tsx +31 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  80. package/src/chrome/regions/ViewportRegion.less +42 -0
  81. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  82. package/src/chrome/regions/regions.test.tsx +81 -0
  83. package/src/chrome/types.ts +127 -0
  84. package/src/config/builder.test.ts +93 -0
  85. package/src/config/builder.ts +220 -0
  86. package/src/config/fromConfigField.test.ts +93 -0
  87. package/src/config/fromConfigField.ts +74 -0
  88. package/src/config/index.ts +22 -0
  89. package/src/config/resolve.test.ts +119 -0
  90. package/src/config/resolve.ts +67 -0
  91. package/src/config/rules.ts +68 -0
  92. package/src/config/types.ts +108 -0
  93. package/src/config/useConfigSchema.ts +30 -0
  94. package/src/config/visible.ts +20 -0
  95. package/src/controls/ControlPanel.less +4 -58
  96. package/src/controls/ControlPanel.stories.tsx +125 -0
  97. package/src/controls/ControlPanel.test.tsx +245 -10
  98. package/src/controls/ControlPanel.tsx +236 -203
  99. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  100. package/src/dragdrop/DragGhost.less +1 -1
  101. package/src/dragdrop/Palette.less +1 -1
  102. package/src/dragdrop/index.ts +11 -0
  103. package/src/index.ts +74 -5
  104. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  105. package/src/instrument/defineInstrument.test.ts +38 -1
  106. package/src/instrument/defineInstrument.ts +31 -4
  107. package/src/instrument/index.ts +1 -0
  108. package/src/instrument/types.ts +27 -4
  109. package/src/instrument/validateConfigSchema.test.ts +13 -3
  110. package/src/instrument/validateConfigSchema.ts +7 -10
  111. package/src/lab/Lab.chrome.test.tsx +65 -0
  112. package/src/lab/Lab.tsx +58 -15
  113. package/src/lab/LabContext.ts +6 -0
  114. package/src/lab/LabFullChrome.stories.tsx +308 -0
  115. package/src/lab/LabHeader.test.tsx +42 -0
  116. package/src/lab/LabHeader.tsx +57 -0
  117. package/src/lab/LabPalette.test.tsx +35 -0
  118. package/src/lab/LabPalette.tsx +34 -0
  119. package/src/lab/LabShell.less +50 -3
  120. package/src/lab/Workspace.less +1 -30
  121. package/src/lab/Workspace.test.tsx +20 -10
  122. package/src/lab/Workspace.tsx +5 -8
  123. package/src/lab/index.ts +3 -1
  124. package/src/layers/AGENTS.md +1 -1
  125. package/src/layers/LayerList.less +31 -7
  126. package/src/layers/LayerList.tsx +17 -4
  127. package/src/passthrough/weasel-ui.ts +28 -0
  128. package/src/primitives/FloatingPanel.less +28 -0
  129. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  130. package/src/primitives/FloatingPanel.test.tsx +240 -0
  131. package/src/primitives/FloatingPanel.tsx +190 -0
  132. package/src/primitives/FpsMeter.test.tsx +44 -0
  133. package/src/primitives/FpsMeter.tsx +20 -9
  134. package/src/primitives/JobProgress.less +69 -0
  135. package/src/primitives/JobProgress.stories.tsx +48 -0
  136. package/src/primitives/JobProgress.tsx +65 -0
  137. package/src/primitives/Legend.less +47 -0
  138. package/src/primitives/Legend.stories.tsx +32 -0
  139. package/src/primitives/Legend.test.tsx +57 -0
  140. package/src/primitives/Legend.tsx +40 -0
  141. package/src/primitives/Sidebar.less +32 -2
  142. package/src/primitives/StatusBar.less +7 -0
  143. package/src/primitives/StatusBar.tsx +10 -3
  144. package/src/primitives/Toolbar.less +50 -3
  145. package/src/primitives/Toolbar.test.tsx +67 -1
  146. package/src/primitives/Toolbar.tsx +72 -6
  147. package/src/primitives/ZoomControl.less +31 -0
  148. package/src/primitives/ZoomControl.tsx +76 -0
  149. package/src/primitives/floating.entry.test.tsx +14 -0
  150. package/src/primitives/index.ts +10 -2
  151. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  152. package/src/primitives/useRovingTabIndex.ts +79 -0
  153. package/src/state/store.ts +11 -3
  154. package/src/state/toolSlot.test.ts +36 -0
  155. package/src/state/types.ts +4 -0
  156. package/src/styles.less +6 -4
  157. package/src/surface/useTiledSurface.ts +14 -10
  158. package/src/test-setup.ts +26 -0
  159. package/src/theme/Interstellar.stories.tsx +1 -1
  160. package/src/theme/base.less +19 -2
  161. package/src/theme/interstellar.tokens.json +6 -2
  162. package/src/tools/types.ts +24 -0
  163. package/src/trial/Trial.config.test.tsx +108 -0
  164. package/src/trial/Trial.job.test.tsx +13 -15
  165. package/src/trial/Trial.less +115 -10
  166. package/src/trial/Trial.stories.tsx +1 -0
  167. package/src/trial/Trial.test.tsx +67 -96
  168. package/src/trial/Trial.tsx +80 -39
  169. package/src/trial/TrialChrome.tsx +99 -80
  170. package/src/trial/TrialDragContext.ts +10 -0
  171. package/src/trial/TrialTitleBar.tsx +41 -0
  172. package/src/trial/index.ts +1 -15
  173. package/src/trial/trialChrome.borders.test.ts +38 -0
  174. package/src/ui/layers/index.ts +5 -1
  175. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  176. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  177. package/dist/chunk-574LJAV4.js.map +0 -1
  178. package/dist/chunk-5R2ATYPJ.js +0 -201
  179. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  180. package/dist/chunk-73KA7WBO.js +0 -5992
  181. package/dist/chunk-73KA7WBO.js.map +0 -1
  182. package/dist/chunk-BOHF3PQO.js.map +0 -1
  183. package/dist/chunk-G5TJVQQT.js +0 -161
  184. package/dist/chunk-G5TJVQQT.js.map +0 -1
  185. package/dist/chunk-PMAU3SEE.js +0 -126
  186. package/dist/chunk-PMAU3SEE.js.map +0 -1
  187. package/dist/chunk-SFL7NFKN.js.map +0 -1
  188. package/dist/chunk-THBG7FQZ.js.map +0 -1
  189. package/dist/chunk-VUU5UXHE.js.map +0 -1
  190. package/src/instrument/capabilityDetector.test.ts +0 -64
  191. package/src/instrument/capabilityDetector.ts +0 -20
  192. package/src/trial/DefaultSidebar.tsx +0 -29
  193. package/src/trial/DefaultStatusBar.tsx +0 -19
  194. package/src/trial/DefaultToolbar.tsx +0 -92
  195. package/src/trial/slotTypes.ts +0 -54
  196. package/src/ui/format.test.ts +0 -33
  197. package/src/ui/format.ts +0 -30
  198. package/src/ui/layers/LayerStack.less +0 -143
  199. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  200. package/src/ui/layers/LayerStack.test.tsx +0 -170
  201. package/src/ui/layers/LayerStack.tsx +0 -220
  202. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  203. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  204. package/src/ui/properties/CurveField.less +0 -48
  205. package/src/ui/properties/CurveField.stories.tsx +0 -20
  206. package/src/ui/properties/CurveField.test.tsx +0 -127
  207. package/src/ui/properties/CurveField.tsx +0 -184
  208. package/src/ui/properties/EffectCard.tsx +0 -324
  209. package/src/ui/properties/Gallery.stories.tsx +0 -178
  210. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  211. package/src/ui/properties/PropertyGroup.less +0 -50
  212. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  213. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  214. package/src/ui/properties/PropertyGroup.tsx +0 -45
  215. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  216. package/src/ui/properties/PropertyPanel.less +0 -592
  217. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  218. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  219. package/src/ui/properties/PropertyPanel.tsx +0 -469
  220. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  221. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  222. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  223. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -396
  224. package/src/ui/properties/TextRow.stories.tsx +0 -62
  225. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  226. package/src/ui/properties/index.ts +0 -39
  227. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -1,5 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { PropertyRow } from '@weasel-js/ui';
2
3
  import { useState } from 'react';
4
+ import { f } from '../config/builder';
5
+ import { resolveConfigSchema } from '../config/resolve';
6
+ import type { ConfigRule, ControlRenderer } from '../config/types';
3
7
  import { ControlPanel } from './ControlPanel';
4
8
  import type { ConfigField } from './types';
5
9
 
@@ -75,3 +79,124 @@ export const Minimal: Story = {
75
79
  />
76
80
  ),
77
81
  };
82
+
83
+ const sectioned = f.schema({
84
+ showGrid: f.boolean(true),
85
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
86
+ seed: f.number(1).section('Advanced'),
87
+ jitter: f.number(0).range(0, 1).step(0.05).section('Advanced'),
88
+ label: f.string('untitled').section('Advanced').placeholder('name this run'),
89
+ offset: f.custom('vector2', { x: 0, y: 0 }).section('Advanced').label('Offset'),
90
+ });
91
+
92
+ function SchemaHarness({
93
+ rules = [],
94
+ renderers,
95
+ }: {
96
+ rules?: readonly ConfigRule[];
97
+ renderers?: Record<string, ControlRenderer>;
98
+ }) {
99
+ const schema = resolveConfigSchema(sectioned, rules);
100
+ const [config, setConfig] = useState<Record<string, unknown>>(sectioned.defaults());
101
+ return (
102
+ <ControlPanel
103
+ schema={schema}
104
+ config={config}
105
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
106
+ renderers={renderers}
107
+ />
108
+ );
109
+ }
110
+
111
+ /** Sections, and a kind no control is registered for — the placeholder names
112
+ * the gap rather than dropping the row. */
113
+ export const Sections: Story = {
114
+ render: () => <SchemaHarness />,
115
+ };
116
+
117
+ /** The same schema with a control supplied for `vector2`. */
118
+ export const CustomControl: Story = {
119
+ render: () => (
120
+ <SchemaHarness
121
+ renderers={{
122
+ vector2: ({ pref, value, setValue }) => {
123
+ const v = value as { x: number; y: number };
124
+ return (
125
+ <PropertyRow label={(pref as { name: string }).name}>
126
+ <input
127
+ type="number"
128
+ value={v.x}
129
+ onChange={(e) => setValue({ ...v, x: Number(e.target.value) })}
130
+ />
131
+ <input
132
+ type="number"
133
+ value={v.y}
134
+ onChange={(e) => setValue({ ...v, y: Number(e.target.value) })}
135
+ />
136
+ </PropertyRow>
137
+ );
138
+ },
139
+ }}
140
+ />
141
+ ),
142
+ };
143
+
144
+ const described = f.schema({
145
+ frequency: f
146
+ .number(2)
147
+ .range(0.1, 10)
148
+ .step(0.1)
149
+ .describe('Cycles per second the oscillator runs at.'),
150
+ amplitude: f.number(0.5).range(0, 1).step(0.05),
151
+ showGrid: f.boolean(true).describe('Draw the alignment grid behind the plot.'),
152
+ wave: f
153
+ .enum('sine', [
154
+ { value: 'sine', label: 'Sine' },
155
+ { value: 'square', label: 'Square' },
156
+ ])
157
+ .describe('Waveform the generator emits.'),
158
+ title: f.string('My experiment'),
159
+ tint: f.color('#3a86ff').describe('Plot line color.'),
160
+ });
161
+
162
+ /** A leaf with a `describe(...)` gets an \u24d8 beside its label whose tooltip
163
+ * carries the text; leaves without one stay bare. */
164
+ export const Described: Story = {
165
+ render: () => {
166
+ const schema = resolveConfigSchema(described, []);
167
+ const [config, setConfig] = useState<Record<string, unknown>>(described.defaults());
168
+ return (
169
+ <ControlPanel
170
+ schema={schema}
171
+ config={config}
172
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
173
+ />
174
+ );
175
+ },
176
+ };
177
+
178
+ const conditional = f.schema({
179
+ showGrid: f.boolean(true),
180
+ cellSize: f
181
+ .number(20)
182
+ .range(5, 80)
183
+ .step(5)
184
+ .label('Grid spacing')
185
+ .showIf((c) => c.showGrid === true),
186
+ });
187
+
188
+ /** `showIf` hides the row while the value stays in config. Toggle the
189
+ * checkbox to watch the slider come and go. */
190
+ export const Conditional: Story = {
191
+ render: () => {
192
+ const schema = resolveConfigSchema(conditional, []);
193
+ const [config, setConfig] = useState<Record<string, unknown>>(conditional.defaults());
194
+ return (
195
+ <ControlPanel
196
+ schema={schema}
197
+ config={config}
198
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
199
+ />
200
+ );
201
+ },
202
+ };
@@ -1,5 +1,10 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
2
+ import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
3
+ import type { PrefLeaf } from '@weasel-js/ui';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { f } from '../config/builder';
6
+ import { resolveConfigSchema } from '../config/resolve';
7
+ import type { ResolvedConfig } from '../config/types';
3
8
  import { ControlPanel } from './ControlPanel';
4
9
  import type { ConfigField } from './types';
5
10
 
@@ -9,12 +14,13 @@ describe('<ControlPanel> slider', () => {
9
14
  { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, step: 0.5, default: 2 },
10
15
  ];
11
16
  render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={vi.fn()} />);
12
- const input = screen.getByLabelText('Frequency') as HTMLInputElement;
13
- expect(input.type).toBe('range');
17
+ // The row holds two controls -- the range and an editable readout -- inside
18
+ // one wrapping label, so query the slider by role rather than by label text.
19
+ const input = screen.getByRole('slider') as HTMLInputElement;
14
20
  expect(input.min).toBe('0');
15
21
  expect(input.max).toBe('10');
16
22
  expect(input.step).toBe('0.5');
17
- expect(screen.getByText('2')).toBeInTheDocument();
23
+ expect(screen.getByDisplayValue('2')).toBeInTheDocument();
18
24
  });
19
25
 
20
26
  it('calls setConfig with numeric value on change', () => {
@@ -23,7 +29,7 @@ describe('<ControlPanel> slider', () => {
23
29
  { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, default: 2 },
24
30
  ];
25
31
  render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={setConfig} />);
26
- fireEvent.change(screen.getByLabelText('Frequency'), { target: { value: '5' } });
32
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '5' } });
27
33
  expect(setConfig).toHaveBeenCalledWith('freq', 5);
28
34
  });
29
35
  });
@@ -86,8 +92,9 @@ describe('<ControlPanel> number', () => {
86
92
  { key: 'n', label: 'N', type: 'number', default: 0, min: 0, max: 100 },
87
93
  ];
88
94
  render(<ControlPanel fields={fields} config={{ n: 50 }} setConfig={setConfig} />);
89
- const input = screen.getByLabelText('N');
90
- fireEvent.blur(input, { target: { value: '999' } });
95
+ // The number row commits as you type, so the clamp applies on change
96
+ // rather than waiting for blur.
97
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '999' } });
91
98
  expect(setConfig).toHaveBeenCalledWith('n', 100);
92
99
  });
93
100
  });
@@ -136,14 +143,242 @@ describe('<ControlPanel> color', () => {
136
143
  });
137
144
 
138
145
  describe('<ControlPanel> defensive', () => {
139
- it('renders nothing for an unknown field type without crashing', () => {
146
+ it('names an unknown field type rather than dropping its row', () => {
140
147
  const fields = [
141
148
  { key: 'mystery', label: 'Mystery', type: 'mystery', default: 1 },
149
+ { key: 'ok', label: 'Ok', type: 'checkbox', default: true },
142
150
  ] as unknown as ConfigField[];
143
151
  const { container } = render(
144
- <ControlPanel fields={fields} config={{ mystery: 1 }} setConfig={vi.fn()} />,
152
+ <ControlPanel fields={fields} config={{ mystery: 1, ok: true }} setConfig={vi.fn()} />,
145
153
  );
146
154
  expect(container.querySelector('.lk-control-panel')).not.toBeNull();
147
- expect(container.querySelector('.lk-control-row')).toBeNull();
155
+ // A kind a lab has not wired up must not blank the panel, and a silently
156
+ // dropped row reads as "this control does not exist".
157
+ expect(screen.getByText(/mystery/)).toBeInTheDocument();
158
+ expect(screen.getByLabelText('Ok')).toBeInTheDocument();
159
+ });
160
+ });
161
+
162
+ describe('<ControlPanel> schema', () => {
163
+ it('renders a schema through the built-in rows', () => {
164
+ const schema = resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []);
165
+ render(<ControlPanel schema={schema} config={{ showGrid: true }} setConfig={vi.fn()} />);
166
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
167
+ });
168
+
169
+ it('writes back through setConfig keyed by path', () => {
170
+ const setConfig = vi.fn();
171
+ const schema = resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []);
172
+ render(<ControlPanel schema={schema} config={{ showGrid: true }} setConfig={setConfig} />);
173
+ fireEvent.click(screen.getByLabelText('Show grid'));
174
+ expect(setConfig).toHaveBeenCalledWith('showGrid', false);
175
+ });
176
+
177
+ it('picks a slider for a bounded number and an input for an open one', () => {
178
+ const schema = resolveConfigSchema(
179
+ f.schema({ a: f.number(5).range(0, 10), b: f.number(5) }),
180
+ [],
181
+ );
182
+ render(<ControlPanel schema={schema} config={{ a: 5, b: 5 }} setConfig={vi.fn()} />);
183
+ expect(screen.getByLabelText('B')).toHaveAttribute('type', 'number');
184
+ });
185
+
186
+ it('falls back to the leaf default when config holds no value', () => {
187
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#123456') }), []);
188
+ render(<ControlPanel schema={schema} config={{}} setConfig={vi.fn()} />);
189
+ expect(screen.getByLabelText('Tint')).toHaveValue('#123456');
190
+ });
191
+
192
+ it('hangs a described leaf\u2019s description off a help affordance, and leaves an undescribed leaf bare', () => {
193
+ const schema = resolveConfigSchema(
194
+ f.schema({
195
+ showGrid: f.boolean(true).describe('Draw the alignment grid.'),
196
+ snap: f.boolean(true),
197
+ }),
198
+ [],
199
+ );
200
+ render(
201
+ <ControlPanel schema={schema} config={{ showGrid: true, snap: true }} setConfig={vi.fn()} />,
202
+ );
203
+ expect(screen.getByRole('button', { name: 'About Show grid' })).toBeInTheDocument();
204
+ expect(screen.getAllByRole('button', { name: /^About / })).toHaveLength(1);
205
+ });
206
+ });
207
+
208
+ describe('<ControlPanel> renderers', () => {
209
+ it('renderers[path] beats renderers[kind]', () => {
210
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
211
+ render(
212
+ <ControlPanel
213
+ schema={schema}
214
+ config={{ tint: '#ffffff' }}
215
+ setConfig={vi.fn()}
216
+ renderers={{ color: () => <span>by-kind</span>, tint: () => <span>by-path</span> }}
217
+ />,
218
+ );
219
+ expect(screen.getByText('by-path')).toBeInTheDocument();
220
+ expect(screen.queryByText('by-kind')).not.toBeInTheDocument();
221
+ });
222
+
223
+ it("a lab renderer for the path beats the node's own .render", () => {
224
+ const schema = resolveConfigSchema(
225
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
226
+ [],
227
+ );
228
+ render(
229
+ <ControlPanel
230
+ schema={schema}
231
+ config={{ tint: '#ffffff' }}
232
+ setConfig={vi.fn()}
233
+ renderers={{ tint: () => <span>by-path</span> }}
234
+ />,
235
+ );
236
+ expect(screen.getByText('by-path')).toBeInTheDocument();
237
+ });
238
+
239
+ it("a node's .render beats a lab renderer for the kind", () => {
240
+ const schema = resolveConfigSchema(
241
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
242
+ [],
243
+ );
244
+ render(
245
+ <ControlPanel
246
+ schema={schema}
247
+ config={{ tint: '#ffffff' }}
248
+ setConfig={vi.fn()}
249
+ renderers={{ color: () => <span>by-kind</span> }}
250
+ />,
251
+ );
252
+ expect(screen.getByText('by-node')).toBeInTheDocument();
253
+ });
254
+
255
+ it('supplies a control for a kind labkit does not ship', () => {
256
+ const schema = resolveConfigSchema(f.schema({ offset: f.custom('vector2', { x: 1 }) }), []);
257
+ render(
258
+ <ControlPanel
259
+ schema={schema}
260
+ config={{ offset: { x: 1 } }}
261
+ setConfig={vi.fn()}
262
+ renderers={{ vector2: (ctx) => <span>vec:{String((ctx.value as { x: number }).x)}</span> }}
263
+ />,
264
+ );
265
+ expect(screen.getByText('vec:1')).toBeInTheDocument();
266
+ });
267
+
268
+ it('a renderer returning null collapses the row', () => {
269
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
270
+ render(
271
+ <ControlPanel
272
+ schema={schema}
273
+ config={{ tint: '#ffffff' }}
274
+ setConfig={vi.fn()}
275
+ renderers={{ color: () => null }}
276
+ />,
277
+ );
278
+ expect(screen.queryByText('Tint')).toBeNull();
279
+ });
280
+ });
281
+
282
+ describe('<ControlPanel> visibility and sections', () => {
283
+ it('hides a row whose showIf is false', () => {
284
+ const schema = resolveConfigSchema(
285
+ f.schema({
286
+ showGrid: f.boolean(true),
287
+ cellSize: f.number(20).showIf((c) => c.showGrid === true),
288
+ }),
289
+ [],
290
+ );
291
+ const { rerender } = render(
292
+ <ControlPanel
293
+ schema={schema}
294
+ config={{ showGrid: true, cellSize: 20 }}
295
+ setConfig={vi.fn()}
296
+ />,
297
+ );
298
+ expect(screen.getByLabelText('Cell size')).toBeInTheDocument();
299
+ rerender(
300
+ <ControlPanel
301
+ schema={schema}
302
+ config={{ showGrid: false, cellSize: 20 }}
303
+ setConfig={vi.fn()}
304
+ />,
305
+ );
306
+ expect(screen.queryByLabelText('Cell size')).not.toBeInTheDocument();
307
+ });
308
+
309
+ it('keeps a hidden leaf out unless asked for it', () => {
310
+ const schema = resolveConfigSchema(f.schema({ seed: f.number(0).hidden() }), []);
311
+ const { rerender } = render(
312
+ <ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} />,
313
+ );
314
+ expect(screen.queryByLabelText('Seed')).not.toBeInTheDocument();
315
+ rerender(<ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} showHidden />);
316
+ expect(screen.getByLabelText('Seed')).toBeInTheDocument();
317
+ });
318
+
319
+ it('groups sectioned leaves under a heading, ungrouped ones first', () => {
320
+ const schema = resolveConfigSchema(
321
+ f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
322
+ [],
323
+ );
324
+ render(
325
+ <ControlPanel schema={schema} config={{ showGrid: true, seed: 0 }} setConfig={vi.fn()} />,
326
+ );
327
+ expect(screen.getByText('Advanced')).toBeInTheDocument();
328
+ const group = screen.getByRole('heading', { name: 'Advanced' }).parentElement;
329
+ expect(group).not.toBeNull();
330
+ expect(group?.textContent).toContain('Seed');
331
+ expect(group?.textContent).not.toContain('Show grid');
332
+ });
333
+ });
334
+
335
+ describe('<ControlPanel> built-in kind coverage', () => {
336
+ const KIND = 'spline' as ToolPrefKind;
337
+ afterEach(() => {
338
+ delete (TOOL_PREF_KINDS as Record<string, true>)[KIND];
339
+ });
340
+
341
+ const schemaWith = (...leaves: PrefLeaf[]): ResolvedConfig => ({
342
+ group: {
343
+ name: 'root',
344
+ children: Object.fromEntries(leaves.map((leaf, i) => [`k${i}`, leaf])),
345
+ },
346
+ sections: [],
347
+ showIf: new Map(),
348
+ renderers: {},
349
+ });
350
+
351
+ it('declines paint and object with a named row rather than a blank one', () => {
352
+ render(
353
+ <ControlPanel
354
+ schema={schemaWith(
355
+ { kind: 'paint', name: 'Fill', description: '', default: null },
356
+ {
357
+ kind: 'object',
358
+ name: 'Stroke',
359
+ description: '',
360
+ default: {},
361
+ children: { width: { kind: 'number', name: 'Width', description: '', default: 1 } },
362
+ },
363
+ )}
364
+ config={{}}
365
+ setConfig={vi.fn()}
366
+ />,
367
+ );
368
+ expect(screen.getByText(/no control for .paint./)).toBeInTheDocument();
369
+ expect(screen.getByText(/no control for .object./)).toBeInTheDocument();
370
+ });
371
+
372
+ it('throws for a built-in kind with no case arm', () => {
373
+ (TOOL_PREF_KINDS as Record<string, true>)[KIND] = true;
374
+ expect(() =>
375
+ render(
376
+ <ControlPanel
377
+ schema={schemaWith({ kind: KIND, name: 'Curve', description: '', default: null })}
378
+ config={{}}
379
+ setConfig={vi.fn()}
380
+ />,
381
+ ),
382
+ ).toThrow(/spline/);
148
383
  });
149
384
  });