@weasel-js/labkit 0.1.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 (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,191 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import {
4
+ CheckboxRow,
5
+ ColorRow,
6
+ NumberRow,
7
+ PropertyList,
8
+ type PropertyListPack,
9
+ PropertyPanel,
10
+ PropertyRow,
11
+ SelectRow,
12
+ SliderRow,
13
+ TextRow,
14
+ ToggleRow,
15
+ } from './PropertyPanel';
16
+
17
+ // Args common to most stories — exposed as Storybook controls so the
18
+ // title, pack mode, and container width can be tweaked live.
19
+ interface DemoArgs {
20
+ title: string;
21
+ pack: PropertyListPack;
22
+ width: number;
23
+ }
24
+
25
+ const meta: Meta<DemoArgs> = {
26
+ title: 'labkit/UI/Properties/PropertyPanel',
27
+ component: PropertyPanel,
28
+ argTypes: {
29
+ title: { control: 'text' },
30
+ pack: {
31
+ control: 'inline-radio',
32
+ options: ['auto-color', 'pairs'] satisfies PropertyListPack[],
33
+ description:
34
+ "PropertyList packing. 'auto-color' (default) spans non-color rows full-width; 'pairs' packs every row two-per-row.",
35
+ },
36
+ width: { control: { type: 'number', min: 200, max: 600, step: 10 } },
37
+ },
38
+ args: {
39
+ title: 'Shape',
40
+ pack: 'auto-color',
41
+ width: 320,
42
+ },
43
+ };
44
+ export default meta;
45
+
46
+ type Story = StoryObj<DemoArgs>;
47
+
48
+ function Demo({ title, pack, width }: DemoArgs) {
49
+ const [opacity, setOpacity] = useState(0.65);
50
+ const [radius, setRadius] = useState(12);
51
+ const [fill, setFill] = useState('#b08adb');
52
+ const [stroke, setStroke] = useState('#1a1428');
53
+ const [label, setLabel] = useState('Untitled');
54
+ const [visible, setVisible] = useState(true);
55
+
56
+ return (
57
+ <div style={{ width }}>
58
+ <PropertyPanel title={title}>
59
+ <PropertyList pack={pack}>
60
+ <SliderRow
61
+ label="Opacity"
62
+ value={opacity}
63
+ min={0}
64
+ max={1}
65
+ step={0.01}
66
+ onChange={setOpacity}
67
+ format={(v) => v.toFixed(2)}
68
+ />
69
+ <SliderRow
70
+ label="Radius"
71
+ value={radius}
72
+ min={0}
73
+ max={64}
74
+ step={1}
75
+ onChange={setRadius}
76
+ unit="px"
77
+ />
78
+ <ColorRow label="Fill" value={fill} onChange={setFill} />
79
+ <ColorRow label="Stroke" value={stroke} onChange={setStroke} />
80
+ <TextRow label="Label" value={label} onChange={setLabel} />
81
+ <CheckboxRow label="Visible" value={visible} onChange={setVisible} />
82
+ </PropertyList>
83
+ </PropertyPanel>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export const Default: Story = {
89
+ render: (args) => <Demo {...args} />,
90
+ };
91
+
92
+ export const NoTitle: Story = {
93
+ args: { title: '' },
94
+ render: ({ title, width }) => (
95
+ <div style={{ width }}>
96
+ <PropertyPanel title={title || undefined}>
97
+ <PropertyList>
98
+ <SliderRow label="Just a slider" value={50} min={0} max={100} onChange={() => {}} />
99
+ </PropertyList>
100
+ </PropertyPanel>
101
+ </div>
102
+ ),
103
+ };
104
+
105
+ function AllRowsDemo({ title, pack, width }: DemoArgs) {
106
+ const [opacity, setOpacity] = useState(0.65);
107
+ const [count, setCount] = useState(8);
108
+ const [fill, setFill] = useState('#b08adb');
109
+ const [stroke, setStroke] = useState('#1a1428');
110
+ const [name, setName] = useState('Untitled');
111
+ const [visible, setVisible] = useState(true);
112
+ const [mode, setMode] = useState<'fill' | 'stroke' | 'both'>('both');
113
+ const [align, setAlign] = useState<'left' | 'center' | 'right'>('center');
114
+
115
+ return (
116
+ <div style={{ width }}>
117
+ <PropertyPanel title={title}>
118
+ <PropertyList pack={pack}>
119
+ <SliderRow
120
+ label="Opacity"
121
+ value={opacity}
122
+ min={0}
123
+ max={1}
124
+ step={0.01}
125
+ onChange={setOpacity}
126
+ format={(v) => v.toFixed(2)}
127
+ />
128
+ <NumberRow label="Count" value={count} onChange={setCount} min={0} max={100} step={1} />
129
+ <ColorRow label="Fill" value={fill} onChange={setFill} />
130
+ <ColorRow label="Stroke" value={stroke} onChange={setStroke} />
131
+ <TextRow label="Name" value={name} onChange={setName} />
132
+ <SelectRow
133
+ label="Mode"
134
+ value={mode}
135
+ onChange={setMode}
136
+ options={[
137
+ { value: 'fill', label: 'Fill only' },
138
+ { value: 'stroke', label: 'Stroke only' },
139
+ { value: 'both', label: 'Fill + stroke' },
140
+ ]}
141
+ />
142
+ <ToggleRow
143
+ label="Align"
144
+ value={align}
145
+ onChange={setAlign}
146
+ options={[
147
+ { value: 'left', label: 'L' },
148
+ { value: 'center', label: 'C' },
149
+ { value: 'right', label: 'R' },
150
+ ]}
151
+ />
152
+ <CheckboxRow label="Visible" value={visible} onChange={setVisible} />
153
+ </PropertyList>
154
+ </PropertyPanel>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ export const AllRows: Story = {
160
+ args: { title: 'Everything' },
161
+ render: (args) => <AllRowsDemo {...args} />,
162
+ };
163
+
164
+ export const CustomPropertyRow: Story = {
165
+ args: { title: 'Custom row' },
166
+ render: ({ title, pack, width }) => (
167
+ <div style={{ width }}>
168
+ <PropertyPanel title={title}>
169
+ <PropertyList pack={pack}>
170
+ <PropertyRow label="Anything" readout="custom">
171
+ <button type="button">click me</button>
172
+ </PropertyRow>
173
+ </PropertyList>
174
+ </PropertyPanel>
175
+ </div>
176
+ ),
177
+ };
178
+
179
+ export const ListWithoutChrome: Story = {
180
+ // No title — this story explicitly demonstrates rendering without the panel chrome.
181
+ argTypes: { title: { table: { disable: true } } },
182
+ render: ({ pack, width }) => (
183
+ <div style={{ width }}>
184
+ <PropertyList pack={pack}>
185
+ <SliderRow label="Bare" value={50} min={0} max={100} onChange={() => {}} />
186
+ <ColorRow label="Color A" value="#b08adb" onChange={() => {}} />
187
+ <ColorRow label="Color B" value="#1a1428" onChange={() => {}} />
188
+ </PropertyList>
189
+ </div>
190
+ ),
191
+ };
@@ -0,0 +1,193 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ CheckboxRow,
5
+ ColorRow,
6
+ NumberRow,
7
+ PropertyList,
8
+ PropertyPanel,
9
+ PropertyRow,
10
+ SelectRow,
11
+ SliderRow,
12
+ TextRow,
13
+ ToggleRow,
14
+ } from './PropertyPanel';
15
+
16
+ describe('PropertyPanel', () => {
17
+ it('renders title and children', () => {
18
+ render(
19
+ <PropertyPanel title="Shape">
20
+ <PropertyRow label="A">
21
+ <input type="text" defaultValue="x" />
22
+ </PropertyRow>
23
+ </PropertyPanel>,
24
+ );
25
+ expect(screen.getByRole('heading', { name: 'Shape' })).toBeInTheDocument();
26
+ expect(screen.getByText('A')).toBeInTheDocument();
27
+ });
28
+
29
+ it('omits the heading when no title is given', () => {
30
+ const { container } = render(
31
+ <PropertyPanel>
32
+ <PropertyRow label="A">
33
+ <input type="text" defaultValue="x" />
34
+ </PropertyRow>
35
+ </PropertyPanel>,
36
+ );
37
+ expect(container.querySelector('.lk-property-panel__title')).toBeNull();
38
+ });
39
+
40
+ it('renders children directly (no implicit grid)', () => {
41
+ const { container } = render(
42
+ <PropertyPanel>
43
+ <span>raw child</span>
44
+ </PropertyPanel>,
45
+ );
46
+ expect(container.querySelector('.lk-property-list')).toBeNull();
47
+ expect(screen.getByText('raw child')).toBeInTheDocument();
48
+ });
49
+ });
50
+
51
+ describe('PropertyList', () => {
52
+ it('renders a grid container', () => {
53
+ const { container } = render(
54
+ <PropertyList>
55
+ <PropertyRow label="A">
56
+ <input type="text" defaultValue="x" />
57
+ </PropertyRow>
58
+ </PropertyList>,
59
+ );
60
+ expect(container.querySelector('.lk-property-list')).not.toBeNull();
61
+ });
62
+ });
63
+
64
+ describe('PropertyRow', () => {
65
+ it('renders label and optional readout', () => {
66
+ render(
67
+ <PropertyRow label="Opacity" readout="0.65">
68
+ <input type="range" defaultValue={50} />
69
+ </PropertyRow>,
70
+ );
71
+ expect(screen.getByText('Opacity')).toBeInTheDocument();
72
+ expect(screen.getByText('0.65')).toBeInTheDocument();
73
+ });
74
+
75
+ it('applies variant class', () => {
76
+ const { container } = render(
77
+ <PropertyRow label="L" variant="color">
78
+ <input type="color" defaultValue="#fff" />
79
+ </PropertyRow>,
80
+ );
81
+ expect(container.querySelector('.lk-property-row--color')).not.toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('SliderRow', () => {
86
+ it('emits numeric value on change', () => {
87
+ const onChange = vi.fn();
88
+ render(<SliderRow label="Op" value={10} min={0} max={100} onChange={onChange} />);
89
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '42' } });
90
+ expect(onChange).toHaveBeenCalledWith(42);
91
+ });
92
+
93
+ it('formats the readout when format is supplied', () => {
94
+ render(
95
+ <SliderRow
96
+ label="Op"
97
+ value={0.5}
98
+ min={0}
99
+ max={1}
100
+ step={0.01}
101
+ onChange={() => {}}
102
+ format={(v) => v.toFixed(2)}
103
+ />,
104
+ );
105
+ expect(screen.getByDisplayValue('0.50')).toBeInTheDocument();
106
+ });
107
+ });
108
+
109
+ describe('ColorRow', () => {
110
+ it('emits the new hex on change', () => {
111
+ const onChange = vi.fn();
112
+ const { container } = render(<ColorRow label="Fill" value="#ffffff" onChange={onChange} />);
113
+ const input = container.querySelector('input[type="color"]') as HTMLInputElement;
114
+ fireEvent.change(input, { target: { value: '#aa3300' } });
115
+ expect(onChange).toHaveBeenCalledWith('#aa3300');
116
+ });
117
+ });
118
+
119
+ describe('CheckboxRow', () => {
120
+ it('toggles the boolean', () => {
121
+ const onChange = vi.fn();
122
+ render(<CheckboxRow label="Visible" value={false} onChange={onChange} />);
123
+ fireEvent.click(screen.getByRole('checkbox'));
124
+ expect(onChange).toHaveBeenCalledWith(true);
125
+ });
126
+ });
127
+
128
+ describe('TextRow', () => {
129
+ it('emits the new text on change', () => {
130
+ const onChange = vi.fn();
131
+ render(<TextRow label="Name" value="foo" onChange={onChange} />);
132
+ fireEvent.change(screen.getByDisplayValue('foo'), { target: { value: 'bar' } });
133
+ expect(onChange).toHaveBeenCalledWith('bar');
134
+ });
135
+ });
136
+
137
+ describe('NumberRow', () => {
138
+ it('emits a number on change', () => {
139
+ const onChange = vi.fn();
140
+ render(<NumberRow label="N" value={1} onChange={onChange} />);
141
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '42' } });
142
+ expect(onChange).toHaveBeenCalledWith(42);
143
+ });
144
+
145
+ it('ignores non-finite values', () => {
146
+ const onChange = vi.fn();
147
+ render(<NumberRow label="N" value={1} onChange={onChange} />);
148
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } });
149
+ expect(onChange).not.toHaveBeenCalled();
150
+ });
151
+ });
152
+
153
+ describe('SelectRow', () => {
154
+ it('emits the selected option', () => {
155
+ const onChange = vi.fn();
156
+ render(
157
+ <SelectRow
158
+ label="Mode"
159
+ value="a"
160
+ options={[
161
+ { value: 'a', label: 'A' },
162
+ { value: 'b', label: 'B' },
163
+ ]}
164
+ onChange={onChange}
165
+ />,
166
+ );
167
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'b' } });
168
+ expect(onChange).toHaveBeenCalledWith('b');
169
+ });
170
+ });
171
+
172
+ describe('ToggleRow', () => {
173
+ it('marks the active option aria-pressed and emits on click', () => {
174
+ const onChange = vi.fn();
175
+ render(
176
+ <ToggleRow
177
+ label="Align"
178
+ value="left"
179
+ options={[
180
+ { value: 'left', label: 'L' },
181
+ { value: 'right', label: 'R' },
182
+ ]}
183
+ onChange={onChange}
184
+ />,
185
+ );
186
+ const left = screen.getByText('L');
187
+ const right = screen.getByText('R');
188
+ expect(left).toHaveAttribute('aria-pressed', 'true');
189
+ expect(right).toHaveAttribute('aria-pressed', 'false');
190
+ fireEvent.click(right);
191
+ expect(onChange).toHaveBeenCalledWith('right');
192
+ });
193
+ });