@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,396 @@
1
+ // Visual parity check: rebuild a slice of the speech-balloons lab side panels
2
+ // using only labkit primitives. If the readouts, units, alpha, two-per-row
3
+ // effect bodies, subpanels, and accent-tinted effect cards look the same as
4
+ // the SB reference, we've captured the styling correctly.
5
+
6
+ import type { Meta, StoryObj } from '@storybook/react-vite';
7
+ import { useState } from 'react';
8
+ import {
9
+ CheckboxRow,
10
+ ColorRow,
11
+ EffectCard,
12
+ EffectCardList,
13
+ PropertyList,
14
+ PropertyPanel,
15
+ SelectRow,
16
+ SliderRow,
17
+ Subpanel,
18
+ } from './';
19
+
20
+ const meta: Meta = {
21
+ title: 'labkit/UI/Properties/PropertyPanel/SpeechBalloonPanels',
22
+ };
23
+ export default meta;
24
+ type Story = StoryObj;
25
+
26
+ function BodyPanel() {
27
+ const [base, setBase] = useState<'rectangle' | 'oval' | 'polygon' | 'cloud'>('rectangle');
28
+ const [width, setWidth] = useState(220);
29
+ const [height, setHeight] = useState(140);
30
+ const [lean, setLean] = useState(0);
31
+ const [textColor, setTextColor] = useState('#e6e7ec');
32
+ const [textAlpha, setTextAlpha] = useState(1);
33
+
34
+ return (
35
+ <PropertyPanel title={`Body — ${base}`}>
36
+ <PropertyList>
37
+ <SelectRow
38
+ label="Base shape"
39
+ value={base}
40
+ onChange={(v) => setBase(v as typeof base)}
41
+ options={[
42
+ { value: 'rectangle', label: 'rectangle' },
43
+ { value: 'oval', label: 'oval' },
44
+ { value: 'polygon', label: 'polygon' },
45
+ { value: 'cloud', label: 'cloud' },
46
+ ]}
47
+ />
48
+ <SliderRow
49
+ label="Width"
50
+ value={width}
51
+ min={60}
52
+ max={500}
53
+ step={2}
54
+ unit="px"
55
+ onChange={setWidth}
56
+ />
57
+ <SliderRow
58
+ label="Height"
59
+ value={height}
60
+ min={20}
61
+ max={500}
62
+ step={2}
63
+ unit="px"
64
+ onChange={setHeight}
65
+ />
66
+ <SliderRow
67
+ label="Italic lean"
68
+ value={lean}
69
+ min={-25}
70
+ max={25}
71
+ step={0.5}
72
+ unit={<sup>°</sup>}
73
+ onChange={setLean}
74
+ />
75
+ <ColorRow
76
+ label="Text color"
77
+ value={textColor}
78
+ onChange={setTextColor}
79
+ alpha={textAlpha}
80
+ onAlphaChange={setTextAlpha}
81
+ />
82
+ </PropertyList>
83
+ </PropertyPanel>
84
+ );
85
+ }
86
+
87
+ // A single tail's editor body — demonstrates pack='pairs' + a Subpanel
88
+ // divider for the shape-specific section (e.g. Bubbles parameters).
89
+ function TailBody() {
90
+ const [shape, setShape] = useState<'classic' | 'bubbles' | 'lightning' | 'wavy'>('bubbles');
91
+ const [angle, setAngle] = useState(115);
92
+ const [outAngle, setOutAngle] = useState(0);
93
+ const [arc, setArc] = useState(0);
94
+ const [size, setSize] = useState(60);
95
+ const [bubbleDiameter, setBubbleDiameter] = useState(30);
96
+ const [count, setCount] = useState(3);
97
+ const [gap, setGap] = useState(0.15);
98
+ const [radial, setRadial] = useState(0);
99
+
100
+ return (
101
+ <PropertyList pack="pairs">
102
+ <SelectRow
103
+ label="Shape"
104
+ value={shape}
105
+ onChange={(v) => setShape(v as typeof shape)}
106
+ options={[
107
+ { value: 'classic', label: 'classic' },
108
+ { value: 'bubbles', label: 'bubbles' },
109
+ { value: 'lightning', label: 'lightning' },
110
+ { value: 'wavy', label: 'wavy' },
111
+ ]}
112
+ />
113
+ <SliderRow
114
+ label="Angle"
115
+ value={angle}
116
+ min={0}
117
+ max={359}
118
+ unit={<sup>°</sup>}
119
+ onChange={setAngle}
120
+ />
121
+ <SliderRow
122
+ label="Tip angle"
123
+ value={outAngle}
124
+ min={-90}
125
+ max={90}
126
+ unit={<sup>°</sup>}
127
+ onChange={setOutAngle}
128
+ />
129
+ <SliderRow
130
+ label="Bend"
131
+ value={arc}
132
+ min={-1}
133
+ max={1}
134
+ step={0.02}
135
+ format={(v) => v.toFixed(2)}
136
+ onChange={setArc}
137
+ />
138
+ <SliderRow
139
+ label="Length"
140
+ value={size}
141
+ min={8}
142
+ max={220}
143
+ step={0.5}
144
+ unit="px"
145
+ onChange={setSize}
146
+ />
147
+ {shape === 'bubbles' && (
148
+ <Subpanel title="Bubbles">
149
+ <SliderRow
150
+ label="Size"
151
+ value={bubbleDiameter}
152
+ min={8}
153
+ max={120}
154
+ unit="px"
155
+ onChange={setBubbleDiameter}
156
+ />
157
+ <SliderRow label="Count" value={count} min={1} max={8} onChange={setCount} />
158
+ <SliderRow
159
+ label="Gap"
160
+ value={gap}
161
+ min={-1}
162
+ max={1}
163
+ step={0.02}
164
+ format={(v) => v.toFixed(2)}
165
+ onChange={setGap}
166
+ />
167
+ <SliderRow
168
+ label="Base distance"
169
+ value={radial}
170
+ min={-60}
171
+ max={60}
172
+ step={0.5}
173
+ unit="px"
174
+ onChange={setRadial}
175
+ />
176
+ </Subpanel>
177
+ )}
178
+ </PropertyList>
179
+ );
180
+ }
181
+
182
+ function StrokePanel() {
183
+ const [width, setWidth] = useState(2);
184
+ const [color, setColor] = useState('#161921');
185
+ const [alpha, setAlpha] = useState(1);
186
+ return (
187
+ <PropertyPanel title="Stroke">
188
+ <PropertyList pack="pairs">
189
+ <SliderRow
190
+ label="Width"
191
+ value={width}
192
+ min={0.5}
193
+ max={12}
194
+ step={0.5}
195
+ unit="px"
196
+ format={(v) => v.toFixed(1)}
197
+ onChange={setWidth}
198
+ />
199
+ <ColorRow
200
+ label="Color"
201
+ value={color}
202
+ onChange={setColor}
203
+ alpha={alpha}
204
+ onAlphaChange={setAlpha}
205
+ />
206
+ </PropertyList>
207
+ </PropertyPanel>
208
+ );
209
+ }
210
+
211
+ function ShadowPanel() {
212
+ const [dx, setDx] = useState(4);
213
+ const [dy, setDy] = useState(8);
214
+ const [blur, setBlur] = useState(10);
215
+ const [opacity, setOpacity] = useState(0.4);
216
+ const [enabled, setEnabled] = useState(true);
217
+ return (
218
+ <PropertyPanel title="Shadow">
219
+ <PropertyList pack="pairs">
220
+ <div className="lk-property-list__span">
221
+ <CheckboxRow label="Enabled" value={enabled} onChange={setEnabled} />
222
+ </div>
223
+ <SliderRow
224
+ label="Offset X"
225
+ value={dx}
226
+ min={-20}
227
+ max={20}
228
+ step={0.5}
229
+ unit="px"
230
+ format={(v) => v.toFixed(1)}
231
+ onChange={setDx}
232
+ />
233
+ <SliderRow
234
+ label="Offset Y"
235
+ value={dy}
236
+ min={-20}
237
+ max={20}
238
+ step={0.5}
239
+ unit="px"
240
+ format={(v) => v.toFixed(1)}
241
+ onChange={setDy}
242
+ />
243
+ <SliderRow
244
+ label="Blur"
245
+ value={blur}
246
+ min={0}
247
+ max={30}
248
+ step={0.5}
249
+ unit="px"
250
+ format={(v) => v.toFixed(1)}
251
+ onChange={setBlur}
252
+ />
253
+ <SliderRow
254
+ label="Opacity"
255
+ value={opacity}
256
+ min={0}
257
+ max={1}
258
+ step={0.05}
259
+ format={(v) => v.toFixed(2)}
260
+ onChange={setOpacity}
261
+ />
262
+ </PropertyList>
263
+ </PropertyPanel>
264
+ );
265
+ }
266
+
267
+ export const LeftSidebar: Story = {
268
+ render: () => (
269
+ <div style={{ width: 360, display: 'flex', flexDirection: 'column', gap: 8 }}>
270
+ <BodyPanel />
271
+ <StrokePanel />
272
+ <ShadowPanel />
273
+ </div>
274
+ ),
275
+ };
276
+
277
+ // ── Right sidebar: tails ───────────────────────────────────────────
278
+ // Each tail is an EffectCard with a per-instance accent (--lk-panel-accent)
279
+ // that re-binds --lk-accent inside the card. Drag the badge handle to reorder.
280
+
281
+ interface TailItem {
282
+ id: number;
283
+ shape: string;
284
+ accent: string;
285
+ }
286
+
287
+ const TAIL_PALETTE = ['#7ec8e3', '#f0a35c', '#a48bd4', '#7fb069', '#d46aaa'];
288
+
289
+ export const RightSidebarTails: Story = {
290
+ render: () => {
291
+ function TailsList() {
292
+ const [tails, setTails] = useState<TailItem[]>([
293
+ { id: 1, shape: 'classic', accent: TAIL_PALETTE[0]! },
294
+ { id: 2, shape: 'bubbles', accent: TAIL_PALETTE[1]! },
295
+ { id: 3, shape: 'wavy', accent: TAIL_PALETTE[2]! },
296
+ ]);
297
+ const reorder = (sourceId: number, targetId: number, position: 'before' | 'after') => {
298
+ setTails((prev) => {
299
+ const next = prev.filter((t) => t.id !== sourceId);
300
+ const idx = next.findIndex((t) => t.id === targetId);
301
+ const insertAt = position === 'before' ? idx : idx + 1;
302
+ const src = prev.find((t) => t.id === sourceId)!;
303
+ next.splice(insertAt, 0, src);
304
+ return next;
305
+ });
306
+ };
307
+ const remove = (id: number) => setTails((prev) => prev.filter((t) => t.id !== id));
308
+ return (
309
+ <EffectCardList
310
+ items={tails}
311
+ onReorder={reorder}
312
+ defaultExpandedIds={tails.map((t) => t.id)}
313
+ renderItem={(tail, { cardProps }) => (
314
+ <EffectCard
315
+ {...cardProps}
316
+ accent={tail.accent}
317
+ index={tails.findIndex((t) => t.id === tail.id)}
318
+ title={tail.shape}
319
+ primary={<>{Math.round(60 + tail.id * 30)}°</>}
320
+ onRemove={() => remove(tail.id)}
321
+ >
322
+ <TailBody />
323
+ </EffectCard>
324
+ )}
325
+ />
326
+ );
327
+ }
328
+ return (
329
+ <div style={{ width: 360, display: 'flex', flexDirection: 'column', gap: 8 }}>
330
+ <TailsList />
331
+ </div>
332
+ );
333
+ },
334
+ };
335
+
336
+ // ── Effect cards without accent — for fill / stroke / shadow stacks ─
337
+
338
+ export const LayerStack: Story = {
339
+ render: () => {
340
+ function Stack() {
341
+ const [items, setItems] = useState([
342
+ { id: 1, kind: 'fill' },
343
+ { id: 2, kind: 'stroke' },
344
+ { id: 3, kind: 'shadow' },
345
+ ]);
346
+ const reorder = (sourceId: number, targetId: number, position: 'before' | 'after') => {
347
+ setItems((prev) => {
348
+ const next = prev.filter((t) => t.id !== sourceId);
349
+ const idx = next.findIndex((t) => t.id === targetId);
350
+ const insertAt = position === 'before' ? idx : idx + 1;
351
+ const src = prev.find((t) => t.id === sourceId)!;
352
+ next.splice(insertAt, 0, src);
353
+ return next;
354
+ });
355
+ };
356
+ return (
357
+ <EffectCardList
358
+ items={items}
359
+ onReorder={reorder}
360
+ defaultExpandedIds={items.map((i) => i.id)}
361
+ renderItem={(item, { cardProps }) => (
362
+ <EffectCard
363
+ {...cardProps}
364
+ title={item.kind}
365
+ onRemove={() => setItems((prev) => prev.filter((t) => t.id !== item.id))}
366
+ >
367
+ <PropertyList pack="pairs">
368
+ <SliderRow
369
+ label="Sample A"
370
+ value={12}
371
+ min={0}
372
+ max={64}
373
+ unit="px"
374
+ onChange={() => {}}
375
+ />
376
+ <SliderRow
377
+ label="Sample B"
378
+ value={45}
379
+ min={-180}
380
+ max={180}
381
+ unit={<sup>°</sup>}
382
+ onChange={() => {}}
383
+ />
384
+ </PropertyList>
385
+ </EffectCard>
386
+ )}
387
+ />
388
+ );
389
+ }
390
+ return (
391
+ <div style={{ width: 360, display: 'flex', flexDirection: 'column', gap: 8 }}>
392
+ <Stack />
393
+ </div>
394
+ );
395
+ },
396
+ };
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { TextRow } from './PropertyPanel';
4
+ import { SideBySide } from './storyLayouts';
5
+
6
+ const meta: Meta<typeof TextRow> = {
7
+ title: 'labkit/UI/Properties/Rows/TextRow',
8
+ component: TextRow,
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof TextRow>;
12
+
13
+ function Controlled({
14
+ initial,
15
+ label,
16
+ placeholder,
17
+ maxLength,
18
+ layout,
19
+ }: {
20
+ initial: string;
21
+ label: string;
22
+ placeholder?: string;
23
+ maxLength?: number;
24
+ layout?: 'block' | 'inline';
25
+ }) {
26
+ const [value, setValue] = useState(initial);
27
+ return (
28
+ <TextRow
29
+ label={label}
30
+ value={value}
31
+ onChange={setValue}
32
+ placeholder={placeholder}
33
+ maxLength={maxLength}
34
+ layout={layout}
35
+ />
36
+ );
37
+ }
38
+
39
+ export const BothLayouts: Story = {
40
+ render: () => (
41
+ <SideBySide
42
+ block={<Controlled initial="Untitled" label="Name" />}
43
+ inline={<Controlled initial="Untitled" label="Name" layout="inline" />}
44
+ />
45
+ ),
46
+ };
47
+
48
+ export const Empty: Story = {
49
+ render: () => (
50
+ <div style={{ width: 280 }}>
51
+ <Controlled initial="" label="Name" placeholder="Type a name…" />
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ export const Capped: Story = {
57
+ render: () => (
58
+ <div style={{ width: 280 }}>
59
+ <Controlled initial="ABCD" label="Tag" maxLength={4} />
60
+ </div>
61
+ ),
62
+ };
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { ToggleRow } from './PropertyPanel';
4
+ import { SideBySide } from './storyLayouts';
5
+
6
+ const meta: Meta<typeof ToggleRow> = {
7
+ title: 'labkit/UI/Properties/Rows/ToggleRow',
8
+ component: ToggleRow,
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof ToggleRow>;
12
+
13
+ const ALIGN_OPTIONS = [
14
+ { value: 'left', label: 'L' },
15
+ { value: 'center', label: 'C' },
16
+ { value: 'right', label: 'R' },
17
+ ] as const;
18
+
19
+ function Align({ layout }: { layout?: 'block' | 'inline' }) {
20
+ const [value, setValue] = useState<'left' | 'center' | 'right'>('center');
21
+ return (
22
+ <ToggleRow
23
+ label="Align"
24
+ value={value}
25
+ onChange={setValue}
26
+ options={ALIGN_OPTIONS}
27
+ layout={layout}
28
+ />
29
+ );
30
+ }
31
+
32
+ export const BothLayouts: Story = {
33
+ render: () => <SideBySide block={<Align />} inline={<Align layout="inline" />} />,
34
+ };
35
+
36
+ function TwoOptions() {
37
+ const [value, setValue] = useState<'on' | 'off'>('on');
38
+ return (
39
+ <ToggleRow
40
+ label="Snapping"
41
+ value={value}
42
+ onChange={setValue}
43
+ options={[
44
+ { value: 'on', label: 'On' },
45
+ { value: 'off', label: 'Off' },
46
+ ]}
47
+ />
48
+ );
49
+ }
50
+
51
+ export const TwoOption: Story = {
52
+ render: () => (
53
+ <div style={{ width: 280 }}>
54
+ <TwoOptions />
55
+ </div>
56
+ ),
57
+ };
@@ -0,0 +1,39 @@
1
+ export type { CurveFieldProps, CurveMark } from './CurveField';
2
+ export { CurveField } from './CurveField';
3
+ export type {
4
+ EffectCardListItem,
5
+ EffectCardListProps,
6
+ EffectCardProps,
7
+ SubpanelProps,
8
+ } from './EffectCard';
9
+ export { EffectCard, EffectCardList, Subpanel } from './EffectCard';
10
+ export type { PropertyGroupProps } from './PropertyGroup';
11
+ export { PropertyGroup } from './PropertyGroup';
12
+ export type {
13
+ CheckboxRowProps,
14
+ ColorRowProps,
15
+ NumberRowProps,
16
+ PropertyListPack,
17
+ PropertyListProps,
18
+ PropertyPanelProps,
19
+ PropertyRowLayout,
20
+ PropertyRowProps,
21
+ PropertyRowVariant,
22
+ SelectOption,
23
+ SelectRowProps,
24
+ SliderRowProps,
25
+ TextRowProps,
26
+ ToggleRowProps,
27
+ } from './PropertyPanel';
28
+ export {
29
+ CheckboxRow,
30
+ ColorRow,
31
+ NumberRow,
32
+ PropertyList,
33
+ PropertyPanel,
34
+ PropertyRow,
35
+ SelectRow,
36
+ SliderRow,
37
+ TextRow,
38
+ ToggleRow,
39
+ } from './PropertyPanel';
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Side-by-side comparison used by row stories to show block + inline layouts
5
+ * at the same time. Not a stories file — name has no .stories. suffix so
6
+ * Storybook's glob skips it.
7
+ */
8
+ export function SideBySide({ block, inline }: { block: ReactNode; inline: ReactNode }) {
9
+ const labelStyle = {
10
+ font: '300 0.72rem/1 Oswald, system-ui',
11
+ color: 'var(--lk-text-muted)',
12
+ textTransform: 'uppercase' as const,
13
+ letterSpacing: '0.08em',
14
+ margin: '0 0 8px',
15
+ };
16
+ return (
17
+ <div
18
+ style={{
19
+ display: 'grid',
20
+ gridTemplateColumns: '1fr 1fr',
21
+ gap: 24,
22
+ width: 720,
23
+ alignItems: 'start',
24
+ }}
25
+ >
26
+ <div>
27
+ <div style={labelStyle}>Block</div>
28
+ {block}
29
+ </div>
30
+ <div>
31
+ <div style={labelStyle}>Inline</div>
32
+ {inline}
33
+ </div>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createEventBus } from './eventBus';
3
+
4
+ describe('EventBus', () => {
5
+ it('fires registered listener for matching event', () => {
6
+ const bus = createEventBus();
7
+ const fn = vi.fn();
8
+ bus.on('foo', fn);
9
+ bus.emit('foo');
10
+ expect(fn).toHaveBeenCalledTimes(1);
11
+ });
12
+
13
+ it('does not fire on different event name', () => {
14
+ const bus = createEventBus();
15
+ const fn = vi.fn();
16
+ bus.on('foo', fn);
17
+ bus.emit('bar');
18
+ expect(fn).not.toHaveBeenCalled();
19
+ });
20
+
21
+ it('unsubscribe stops further calls', () => {
22
+ const bus = createEventBus();
23
+ const fn = vi.fn();
24
+ const off = bus.on('foo', fn);
25
+ off();
26
+ bus.emit('foo');
27
+ expect(fn).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('multiple listeners for same event all fire', () => {
31
+ const bus = createEventBus();
32
+ const a = vi.fn();
33
+ const b = vi.fn();
34
+ bus.on('foo', a);
35
+ bus.on('foo', b);
36
+ bus.emit('foo');
37
+ expect(a).toHaveBeenCalled();
38
+ expect(b).toHaveBeenCalled();
39
+ });
40
+
41
+ it('clear removes all listeners', () => {
42
+ const bus = createEventBus();
43
+ const fn = vi.fn();
44
+ bus.on('foo', fn);
45
+ bus.clear();
46
+ bus.emit('foo');
47
+ expect(fn).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -0,0 +1,32 @@
1
+ export type EventListener = () => void;
2
+
3
+ export interface EventBus {
4
+ on(event: string, listener: EventListener): () => void;
5
+ emit(event: string): void;
6
+ clear(): void;
7
+ }
8
+
9
+ export function createEventBus(): EventBus {
10
+ const listeners = new Map<string, Set<EventListener>>();
11
+ return {
12
+ on(event, listener) {
13
+ let set = listeners.get(event);
14
+ if (!set) {
15
+ set = new Set();
16
+ listeners.set(event, set);
17
+ }
18
+ set.add(listener);
19
+ return () => {
20
+ set?.delete(listener);
21
+ };
22
+ },
23
+ emit(event) {
24
+ const set = listeners.get(event);
25
+ if (!set) return;
26
+ for (const fn of [...set]) fn();
27
+ },
28
+ clear() {
29
+ listeners.clear();
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,5 @@
1
+ export type { UndoCapability } from '../instrument/types';
2
+ export type { EventBus, EventListener } from './eventBus';
3
+ export { createEventBus } from './eventBus';
4
+ export type { UndoStack } from './undoStack';
5
+ export { clearUndo, emptyStack, pushSnapshot, redo, undo } from './undoStack';