@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
@@ -1,5 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
+ import { f } from '../config/builder';
4
+ import { resolveConfigSchema } from '../config/resolve';
5
+ import type { ConfigRule, ControlRenderer } from '../config/types';
6
+ import { PropertyRow } from '../ui/properties/PropertyPanel';
3
7
  import { ControlPanel } from './ControlPanel';
4
8
  import type { ConfigField } from './types';
5
9
 
@@ -75,3 +79,85 @@ 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 conditional = f.schema({
145
+ showGrid: f.boolean(true),
146
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing').showIf((c) => c.showGrid === true),
147
+ });
148
+
149
+ /** `showIf` hides the row while the value stays in config. Toggle the
150
+ * checkbox to watch the slider come and go. */
151
+ export const Conditional: Story = {
152
+ render: () => {
153
+ const schema = resolveConfigSchema(conditional, []);
154
+ const [config, setConfig] = useState<Record<string, unknown>>(conditional.defaults());
155
+ return (
156
+ <ControlPanel
157
+ schema={schema}
158
+ config={config}
159
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
160
+ />
161
+ );
162
+ },
163
+ };
@@ -1,5 +1,10 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
4
+ import type { PrefLeaf } from '@weasel-js/ui';
5
+ import type { ResolvedConfig } from '../config/types';
6
+ import { f } from '../config/builder';
7
+ import { resolveConfigSchema } from '../config/resolve';
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,220 @@ 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
+
193
+ describe('<ControlPanel> renderers', () => {
194
+ it('renderers[path] beats renderers[kind]', () => {
195
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
196
+ render(
197
+ <ControlPanel
198
+ schema={schema}
199
+ config={{ tint: '#ffffff' }}
200
+ setConfig={vi.fn()}
201
+ renderers={{ color: () => <span>by-kind</span>, tint: () => <span>by-path</span> }}
202
+ />,
203
+ );
204
+ expect(screen.getByText('by-path')).toBeInTheDocument();
205
+ expect(screen.queryByText('by-kind')).not.toBeInTheDocument();
206
+ });
207
+
208
+ it('a lab renderer for the path beats the node\'s own .render', () => {
209
+ const schema = resolveConfigSchema(
210
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
211
+ [],
212
+ );
213
+ render(
214
+ <ControlPanel
215
+ schema={schema}
216
+ config={{ tint: '#ffffff' }}
217
+ setConfig={vi.fn()}
218
+ renderers={{ tint: () => <span>by-path</span> }}
219
+ />,
220
+ );
221
+ expect(screen.getByText('by-path')).toBeInTheDocument();
222
+ });
223
+
224
+ it("a node's .render beats a lab renderer for the kind", () => {
225
+ const schema = resolveConfigSchema(
226
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
227
+ [],
228
+ );
229
+ render(
230
+ <ControlPanel
231
+ schema={schema}
232
+ config={{ tint: '#ffffff' }}
233
+ setConfig={vi.fn()}
234
+ renderers={{ color: () => <span>by-kind</span> }}
235
+ />,
236
+ );
237
+ expect(screen.getByText('by-node')).toBeInTheDocument();
238
+ });
239
+
240
+ it('supplies a control for a kind labkit does not ship', () => {
241
+ const schema = resolveConfigSchema(f.schema({ offset: f.custom('vector2', { x: 1 }) }), []);
242
+ render(
243
+ <ControlPanel
244
+ schema={schema}
245
+ config={{ offset: { x: 1 } }}
246
+ setConfig={vi.fn()}
247
+ renderers={{ vector2: (ctx) => <span>vec:{String((ctx.value as { x: number }).x)}</span> }}
248
+ />,
249
+ );
250
+ expect(screen.getByText('vec:1')).toBeInTheDocument();
251
+ });
252
+
253
+ it('a renderer returning null collapses the row', () => {
254
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
255
+ const { container } = render(
256
+ <ControlPanel
257
+ schema={schema}
258
+ config={{ tint: '#ffffff' }}
259
+ setConfig={vi.fn()}
260
+ renderers={{ color: () => null }}
261
+ />,
262
+ );
263
+ expect(container.querySelector('.lk-property-row')).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe('<ControlPanel> visibility and sections', () => {
268
+ it('hides a row whose showIf is false', () => {
269
+ const schema = resolveConfigSchema(
270
+ f.schema({
271
+ showGrid: f.boolean(true),
272
+ cellSize: f.number(20).showIf((c) => c.showGrid === true),
273
+ }),
274
+ [],
275
+ );
276
+ const { rerender } = render(
277
+ <ControlPanel schema={schema} config={{ showGrid: true, cellSize: 20 }} setConfig={vi.fn()} />,
278
+ );
279
+ expect(screen.getByLabelText('Cell size')).toBeInTheDocument();
280
+ rerender(
281
+ <ControlPanel
282
+ schema={schema}
283
+ config={{ showGrid: false, cellSize: 20 }}
284
+ setConfig={vi.fn()}
285
+ />,
286
+ );
287
+ expect(screen.queryByLabelText('Cell size')).not.toBeInTheDocument();
288
+ });
289
+
290
+ it('keeps a hidden leaf out unless asked for it', () => {
291
+ const schema = resolveConfigSchema(f.schema({ seed: f.number(0).hidden() }), []);
292
+ const { rerender } = render(
293
+ <ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} />,
294
+ );
295
+ expect(screen.queryByLabelText('Seed')).not.toBeInTheDocument();
296
+ rerender(<ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} showHidden />);
297
+ expect(screen.getByLabelText('Seed')).toBeInTheDocument();
298
+ });
299
+
300
+ it('groups sectioned leaves under a heading, ungrouped ones first', () => {
301
+ const schema = resolveConfigSchema(
302
+ f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
303
+ [],
304
+ );
305
+ const { container } = render(
306
+ <ControlPanel schema={schema} config={{ showGrid: true, seed: 0 }} setConfig={vi.fn()} />,
307
+ );
308
+ expect(screen.getByText('Advanced')).toBeInTheDocument();
309
+ const group = container.querySelector('.lk-property-group');
310
+ expect(group).not.toBeNull();
311
+ expect(group?.textContent).toContain('Seed');
312
+ expect(group?.textContent).not.toContain('Show grid');
313
+ });
314
+ });
315
+
316
+ describe('<ControlPanel> built-in kind coverage', () => {
317
+ const KIND = 'spline' as ToolPrefKind;
318
+ afterEach(() => {
319
+ delete (TOOL_PREF_KINDS as Record<string, true>)[KIND];
320
+ });
321
+
322
+ const schemaWith = (...leaves: PrefLeaf[]): ResolvedConfig => ({
323
+ group: {
324
+ name: 'root',
325
+ children: Object.fromEntries(leaves.map((leaf, i) => [`k${i}`, leaf])),
326
+ },
327
+ sections: [],
328
+ showIf: new Map(),
329
+ renderers: {},
330
+ });
331
+
332
+ it('declines paint and object with a named row rather than a blank one', () => {
333
+ render(
334
+ <ControlPanel
335
+ schema={schemaWith(
336
+ { kind: 'paint', name: 'Fill', description: '', default: null },
337
+ {
338
+ kind: 'object', name: 'Stroke', description: '', default: {},
339
+ children: { width: { kind: 'number', name: 'Width', description: '', default: 1 } },
340
+ },
341
+ )}
342
+ config={{}}
343
+ setConfig={vi.fn()}
344
+ />,
345
+ );
346
+ expect(screen.getByText(/no control for .paint./)).toBeInTheDocument();
347
+ expect(screen.getByText(/no control for .object./)).toBeInTheDocument();
348
+ });
349
+
350
+ it('throws for a built-in kind with no case arm', () => {
351
+ (TOOL_PREF_KINDS as Record<string, true>)[KIND] = true;
352
+ expect(() =>
353
+ render(
354
+ <ControlPanel
355
+ schema={schemaWith({ kind: KIND, name: 'Curve', description: '', default: null })}
356
+ config={{}}
357
+ setConfig={vi.fn()}
358
+ />,
359
+ ),
360
+ ).toThrow(/spline/);
148
361
  });
149
362
  });