@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,441 @@
1
+ import { type ReactNode, useState } from 'react';
2
+ import { dlog } from '../../passthrough/weasel-ui';
3
+ import { formatNumber, parseSignedNumber } from '../format';
4
+
5
+ export interface PropertyPanelProps {
6
+ title?: ReactNode;
7
+ children: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function PropertyPanel({ title, children, className }: PropertyPanelProps) {
12
+ const cls = className ? `lk-property-panel ${className}` : 'lk-property-panel';
13
+ return (
14
+ <div className={cls}>
15
+ {title != null && <h2 className="lk-property-panel__title">{title}</h2>}
16
+ {children}
17
+ </div>
18
+ );
19
+ }
20
+
21
+ export type PropertyListPack = 'auto-color' | 'pairs';
22
+
23
+ export interface PropertyListProps {
24
+ children: ReactNode;
25
+ className?: string;
26
+ /**
27
+ * How rows pack into the 2-column grid.
28
+ * - `'auto-color'` (default): only color rows pair side-by-side; everything
29
+ * else spans the full width. Right for sparse top-level panels.
30
+ * - `'pairs'`: every row auto-places into the 2-column grid two-per-row.
31
+ * Headers, subpanels, and full-width children (`<hr>`, curve blocks)
32
+ * still span via `lk-property-list__span`. Right for dense effect bodies.
33
+ */
34
+ pack?: PropertyListPack;
35
+ }
36
+
37
+ /**
38
+ * Grid container for PropertyRows. Use standalone for chrome-less layouts, or
39
+ * nest inside <PropertyPanel/> for the standard glass card.
40
+ */
41
+ export function PropertyList({ children, className, pack = 'auto-color' }: PropertyListProps) {
42
+ const packClass = pack === 'pairs' ? ' lk-property-list--pairs' : '';
43
+ const cls = `lk-property-list${packClass}${className ? ` ${className}` : ''}`;
44
+ return <div className={cls}>{children}</div>;
45
+ }
46
+
47
+ export type PropertyRowVariant = 'default' | 'color' | 'checkbox';
48
+ export type PropertyRowLayout = 'block' | 'inline';
49
+
50
+ export interface PropertyRowProps {
51
+ label: ReactNode;
52
+ /** Right-aligned readout shown next to the label (e.g. current value). */
53
+ readout?: ReactNode;
54
+ variant?: PropertyRowVariant;
55
+ /**
56
+ * Label position relative to the control. "block" (default) stacks
57
+ * label above control; "inline" places the label to the left.
58
+ * Color and checkbox variants are always inline by their own nature.
59
+ */
60
+ layout?: PropertyRowLayout;
61
+ children: ReactNode;
62
+ htmlFor?: string;
63
+ className?: string;
64
+ }
65
+
66
+ export function PropertyRow({
67
+ label,
68
+ readout,
69
+ variant = 'default',
70
+ layout = 'block',
71
+ children,
72
+ htmlFor,
73
+ className,
74
+ }: PropertyRowProps) {
75
+ const variantClass = variant === 'default' ? '' : ` lk-property-row--${variant}`;
76
+ // Layout only meaningfully applies to the default variant; color and
77
+ // checkbox have their own intrinsic orientation.
78
+ const layoutClass =
79
+ variant === 'default' && layout === 'inline' ? ' lk-property-row--inline' : '';
80
+ const cls = `lk-property-row${variantClass}${layoutClass}${className ? ` ${className}` : ''}`;
81
+ return (
82
+ <label className={cls} htmlFor={htmlFor}>
83
+ <span className="lk-property-row__label">
84
+ {label}
85
+ {readout != null && <em className="lk-property-row__readout">{readout}</em>}
86
+ </span>
87
+ {children}
88
+ </label>
89
+ );
90
+ }
91
+
92
+ // ── Row implementations ──────────────────────────────────────────────
93
+
94
+ export interface SliderRowProps {
95
+ label: ReactNode;
96
+ value: number;
97
+ min: number;
98
+ max: number;
99
+ step?: number;
100
+ onChange: (next: number) => void;
101
+ /** Override how the value is rendered next to the label. Defaults to `value.toString()`. */
102
+ format?: (value: number) => ReactNode;
103
+ /**
104
+ * Optional suffix rendered next to the readout. A string becomes a
105
+ * baseline-aligned dim "word" unit (e.g. "px"); pass JSX like `<sup>°</sup>`
106
+ * to get the browser's native super positioning for symbol units.
107
+ */
108
+ unit?: ReactNode;
109
+ layout?: PropertyRowLayout;
110
+ }
111
+
112
+ export function SliderRow({
113
+ label,
114
+ value,
115
+ min,
116
+ max,
117
+ step = 1,
118
+ onChange,
119
+ format,
120
+ unit,
121
+ layout,
122
+ }: SliderRowProps) {
123
+ // Default readout precision tracks `step`: integer steps → 0 decimals,
124
+ // 0.1 → 1 decimal, 0.05/0.02/0.01 → 2 decimals, 0.005 → 3, etc. Callers
125
+ // can still pass an explicit `format` to override (e.g. for a custom
126
+ // unit string or a non-decimal display like fractions).
127
+ const decimals = step >= 1 ? 0 : Math.min(6, Math.max(0, Math.ceil(-Math.log10(step))));
128
+ const effectiveFormat = format ?? ((n: number) => formatNumber(n, decimals));
129
+ return (
130
+ <PropertyRow
131
+ label={label}
132
+ readout={
133
+ <EditableReadout
134
+ value={value}
135
+ min={min}
136
+ max={max}
137
+ format={effectiveFormat}
138
+ unit={unit}
139
+ onCommit={onChange}
140
+ />
141
+ }
142
+ layout={layout}
143
+ >
144
+ <input
145
+ type="range"
146
+ tabIndex={-1}
147
+ min={min}
148
+ max={max}
149
+ step={step}
150
+ value={value}
151
+ onChange={(e) => {
152
+ const v = Number(e.target.value);
153
+ dlog('property-panel', 'slider', { label, value: v });
154
+ onChange(v);
155
+ }}
156
+ />
157
+ </PropertyRow>
158
+ );
159
+ }
160
+
161
+ interface EditableReadoutProps {
162
+ value: number;
163
+ min: number;
164
+ max: number;
165
+ format?: (value: number) => ReactNode;
166
+ unit?: ReactNode;
167
+ onCommit: (next: number) => void;
168
+ }
169
+
170
+ /**
171
+ * Readout that swaps to a number input on click, commits on Enter/blur,
172
+ * cancels on Escape. Clicks are stopped so the wrapping <label> doesn't
173
+ * forward focus to the slider thumb.
174
+ */
175
+ function EditableReadout({ value, min, max, format, unit, onCommit }: EditableReadoutProps) {
176
+ // Draft is non-null only while the input is focused. Live value mirrors
177
+ // into the input otherwise. Pattern mirrors speech-balloons Lab.tsx:893-942.
178
+ const [draft, setDraft] = useState<string | null>(null);
179
+ const fmt = (n: number) => (format ? format(n) : formatNumber(n));
180
+ const displayValue =
181
+ draft !== null
182
+ ? draft
183
+ : (() => {
184
+ const formatted = fmt(value);
185
+ return typeof formatted === 'string' ? formatted : String(formatted);
186
+ })();
187
+
188
+ const suffix =
189
+ unit == null ? null : typeof unit === 'string' ? (
190
+ <span className="lk-property-row__readout-unit">{unit}</span>
191
+ ) : (
192
+ unit
193
+ );
194
+
195
+ const commit = () => {
196
+ if (draft !== null) {
197
+ const n = parseSignedNumber(draft);
198
+ if (Number.isFinite(n)) onCommit(Math.min(max, Math.max(min, n)));
199
+ }
200
+ setDraft(null);
201
+ };
202
+
203
+ return (
204
+ <span className="lk-property-row__readout-group">
205
+ <input
206
+ type="text"
207
+ inputMode="decimal"
208
+ className="lk-property-row__readout-input"
209
+ value={displayValue}
210
+ onFocus={(e) => {
211
+ const formatted = fmt(value);
212
+ setDraft(typeof formatted === 'string' ? formatted : String(formatted));
213
+ e.currentTarget.select();
214
+ }}
215
+ onChange={(e) => setDraft(e.target.value.replace(/-/g, '−'))}
216
+ onBlur={commit}
217
+ onClick={(e) => {
218
+ // Stop the click from reaching the wrapping <label> (which would
219
+ // forward focus to the slider thumb).
220
+ e.preventDefault();
221
+ e.stopPropagation();
222
+ e.currentTarget.focus();
223
+ }}
224
+ onMouseDown={(e) => e.stopPropagation()}
225
+ onKeyDown={(e) => {
226
+ if (e.key === 'Enter') {
227
+ commit();
228
+ e.currentTarget.blur();
229
+ } else if (e.key === 'Escape') {
230
+ setDraft(null);
231
+ e.currentTarget.blur();
232
+ }
233
+ }}
234
+ />
235
+ {suffix}
236
+ </span>
237
+ );
238
+ }
239
+
240
+ export interface ColorRowProps {
241
+ label: ReactNode;
242
+ value: string;
243
+ onChange: (next: string) => void;
244
+ /** 0..1 alpha. When provided, a translucent slider renders beneath the swatch. */
245
+ alpha?: number;
246
+ onAlphaChange?: (next: number) => void;
247
+ /**
248
+ * Render the alpha track as inert (dimmed, no thumb, not-allowed cursor).
249
+ * Use when the color's consumer drops alpha so the affordance reads dead.
250
+ */
251
+ alphaDisabled?: boolean;
252
+ }
253
+
254
+ export function ColorRow({
255
+ label,
256
+ value,
257
+ onChange,
258
+ alpha,
259
+ onAlphaChange,
260
+ alphaDisabled,
261
+ }: ColorRowProps) {
262
+ const showAlpha = alpha != null;
263
+ const className = alphaDisabled ? 'lk-property-row--alpha-disabled' : undefined;
264
+ return (
265
+ <PropertyRow label={label} variant="color" className={className}>
266
+ <input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
267
+ {showAlpha && (
268
+ <input
269
+ type="range"
270
+ className="lk-property-row__alpha"
271
+ min={0}
272
+ max={1}
273
+ step={0.01}
274
+ value={alpha}
275
+ disabled={alphaDisabled}
276
+ onChange={(e) => onAlphaChange?.(Number(e.target.value))}
277
+ />
278
+ )}
279
+ </PropertyRow>
280
+ );
281
+ }
282
+
283
+ export interface CheckboxRowProps {
284
+ label: ReactNode;
285
+ value: boolean;
286
+ onChange: (next: boolean) => void;
287
+ }
288
+
289
+ export function CheckboxRow({ label, value, onChange }: CheckboxRowProps) {
290
+ return (
291
+ <PropertyRow label={label} variant="checkbox">
292
+ <input type="checkbox" checked={value} onChange={(e) => onChange(e.target.checked)} />
293
+ </PropertyRow>
294
+ );
295
+ }
296
+
297
+ export interface TextRowProps {
298
+ label: ReactNode;
299
+ value: string;
300
+ onChange: (next: string) => void;
301
+ placeholder?: string;
302
+ maxLength?: number;
303
+ layout?: PropertyRowLayout;
304
+ }
305
+
306
+ export function TextRow({ label, value, onChange, placeholder, maxLength, layout }: TextRowProps) {
307
+ return (
308
+ <PropertyRow label={label} layout={layout}>
309
+ <input
310
+ type="text"
311
+ value={value}
312
+ placeholder={placeholder}
313
+ maxLength={maxLength}
314
+ onChange={(e) => onChange(e.target.value)}
315
+ />
316
+ </PropertyRow>
317
+ );
318
+ }
319
+
320
+ export interface NumberRowProps {
321
+ label: ReactNode;
322
+ value: number;
323
+ onChange: (next: number) => void;
324
+ min?: number;
325
+ max?: number;
326
+ step?: number;
327
+ placeholder?: string;
328
+ layout?: PropertyRowLayout;
329
+ }
330
+
331
+ export function NumberRow({
332
+ label,
333
+ value,
334
+ onChange,
335
+ min,
336
+ max,
337
+ step,
338
+ placeholder,
339
+ layout,
340
+ }: NumberRowProps) {
341
+ return (
342
+ <PropertyRow label={label} layout={layout}>
343
+ <input
344
+ type="number"
345
+ value={value}
346
+ min={min}
347
+ max={max}
348
+ step={step}
349
+ placeholder={placeholder}
350
+ onChange={(e) => {
351
+ const raw = e.target.value;
352
+ if (raw === '') return;
353
+ const n = Number(raw);
354
+ if (Number.isFinite(n)) onChange(n);
355
+ }}
356
+ />
357
+ </PropertyRow>
358
+ );
359
+ }
360
+
361
+ export interface SelectOption<T extends string> {
362
+ value: T;
363
+ label: ReactNode;
364
+ }
365
+
366
+ export interface SelectRowProps<T extends string> {
367
+ label: ReactNode;
368
+ value: T;
369
+ options: ReadonlyArray<SelectOption<T>>;
370
+ onChange: (next: T) => void;
371
+ layout?: PropertyRowLayout;
372
+ }
373
+
374
+ export function SelectRow<T extends string>({
375
+ label,
376
+ value,
377
+ options,
378
+ onChange,
379
+ layout,
380
+ }: SelectRowProps<T>) {
381
+ return (
382
+ <PropertyRow label={label} layout={layout}>
383
+ <select
384
+ value={value}
385
+ onChange={(e) => {
386
+ const v = e.target.value as T;
387
+ dlog('property-panel', 'select', { label, value: v });
388
+ onChange(v);
389
+ }}
390
+ >
391
+ {options.map((opt) => (
392
+ <option key={opt.value} value={opt.value}>
393
+ {/* HTML <option> only renders text; ReactNode → string coerce */}
394
+ {opt.label as string}
395
+ </option>
396
+ ))}
397
+ </select>
398
+ </PropertyRow>
399
+ );
400
+ }
401
+
402
+ export interface ToggleRowProps<T extends string> {
403
+ label: ReactNode;
404
+ value: T;
405
+ options: ReadonlyArray<SelectOption<T>>;
406
+ onChange: (next: T) => void;
407
+ layout?: PropertyRowLayout;
408
+ }
409
+
410
+ export function ToggleRow<T extends string>({
411
+ label,
412
+ value,
413
+ options,
414
+ onChange,
415
+ layout,
416
+ }: ToggleRowProps<T>) {
417
+ return (
418
+ <PropertyRow label={label} layout={layout}>
419
+ <div className="lk-property-row__toggle">
420
+ {options.map((opt) => {
421
+ const selected = opt.value === value;
422
+ return (
423
+ <button
424
+ key={opt.value}
425
+ type="button"
426
+ aria-pressed={selected}
427
+ className={
428
+ selected
429
+ ? 'lk-property-row__toggle-button lk-property-row__toggle-button--selected'
430
+ : 'lk-property-row__toggle-button'
431
+ }
432
+ onClick={() => onChange(opt.value)}
433
+ >
434
+ {opt.label}
435
+ </button>
436
+ );
437
+ })}
438
+ </div>
439
+ </PropertyRow>
440
+ );
441
+ }
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { PropertyRow } from './PropertyPanel';
3
+ import { SideBySide } from './storyLayouts';
4
+
5
+ const meta: Meta<typeof PropertyRow> = {
6
+ title: 'labkit/UI/Properties/Rows/PropertyRow',
7
+ component: PropertyRow,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof PropertyRow>;
11
+
12
+ export const BothLayouts: Story = {
13
+ render: () => (
14
+ <SideBySide
15
+ block={
16
+ <PropertyRow label="Custom control">
17
+ <input type="text" placeholder="anything goes" defaultValue="foo" />
18
+ </PropertyRow>
19
+ }
20
+ inline={
21
+ <PropertyRow label="Custom control" layout="inline">
22
+ <input type="text" placeholder="anything goes" defaultValue="foo" />
23
+ </PropertyRow>
24
+ }
25
+ />
26
+ ),
27
+ };
28
+
29
+ export const WithReadout: Story = {
30
+ render: () => (
31
+ <div style={{ width: 280 }}>
32
+ <PropertyRow label="Tempo" readout="120 bpm">
33
+ <button type="button">Tap</button>
34
+ </PropertyRow>
35
+ </div>
36
+ ),
37
+ };
38
+
39
+ export const Variants: Story = {
40
+ render: () => (
41
+ <div style={{ width: 280, display: 'flex', flexDirection: 'column', gap: 10 }}>
42
+ <PropertyRow label="Default" readout="42">
43
+ <input type="text" defaultValue="hello" />
44
+ </PropertyRow>
45
+ <PropertyRow label="Color variant" variant="color">
46
+ <input type="color" defaultValue="#b08adb" />
47
+ </PropertyRow>
48
+ <PropertyRow label="Checkbox variant" variant="checkbox">
49
+ <input type="checkbox" defaultChecked />
50
+ </PropertyRow>
51
+ </div>
52
+ ),
53
+ };
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { SelectRow } from './PropertyPanel';
4
+ import { SideBySide } from './storyLayouts';
5
+
6
+ const meta: Meta<typeof SelectRow> = {
7
+ title: 'labkit/UI/Properties/Rows/SelectRow',
8
+ component: SelectRow,
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof SelectRow>;
12
+
13
+ const MODE_OPTIONS = [
14
+ { value: 'fill', label: 'Fill only' },
15
+ { value: 'stroke', label: 'Stroke only' },
16
+ { value: 'both', label: 'Fill + stroke' },
17
+ ] as const;
18
+
19
+ function Modes({ layout }: { layout?: 'block' | 'inline' }) {
20
+ const [value, setValue] = useState<'fill' | 'stroke' | 'both'>('both');
21
+ return (
22
+ <SelectRow
23
+ label="Render mode"
24
+ value={value}
25
+ onChange={setValue}
26
+ options={MODE_OPTIONS}
27
+ layout={layout}
28
+ />
29
+ );
30
+ }
31
+
32
+ export const BothLayouts: Story = {
33
+ render: () => <SideBySide block={<Modes />} inline={<Modes layout="inline" />} />,
34
+ };
35
+
36
+ function ManyOptions() {
37
+ const [value, setValue] = useState('round');
38
+ return (
39
+ <SelectRow
40
+ label="Line cap"
41
+ value={value}
42
+ onChange={setValue}
43
+ options={[
44
+ { value: 'butt', label: 'Butt' },
45
+ { value: 'round', label: 'Round' },
46
+ { value: 'square', label: 'Square' },
47
+ ]}
48
+ />
49
+ );
50
+ }
51
+
52
+ export const LineCap: Story = {
53
+ render: () => (
54
+ <div style={{ width: 280 }}>
55
+ <ManyOptions />
56
+ </div>
57
+ ),
58
+ };
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { SliderRow } from './PropertyPanel';
4
+ import { SideBySide } from './storyLayouts';
5
+
6
+ const meta: Meta<typeof SliderRow> = {
7
+ title: 'labkit/UI/Properties/Rows/SliderRow',
8
+ component: SliderRow,
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof SliderRow>;
12
+
13
+ function Controlled({
14
+ initial,
15
+ layout,
16
+ ...rest
17
+ }: {
18
+ initial: number;
19
+ label: string;
20
+ min: number;
21
+ max: number;
22
+ step?: number;
23
+ format?: (v: number) => string;
24
+ unit?: ReactNode;
25
+ layout?: 'block' | 'inline';
26
+ }) {
27
+ const [value, setValue] = useState(initial);
28
+ return <SliderRow value={value} onChange={setValue} layout={layout} {...rest} />;
29
+ }
30
+
31
+ export const BothLayouts: Story = {
32
+ render: () => (
33
+ <SideBySide
34
+ block={
35
+ <Controlled
36
+ initial={0.65}
37
+ label="Opacity"
38
+ min={0}
39
+ max={1}
40
+ step={0.01}
41
+ format={(v) => v.toFixed(2)}
42
+ />
43
+ }
44
+ inline={
45
+ <Controlled
46
+ initial={0.65}
47
+ label="Opacity"
48
+ min={0}
49
+ max={1}
50
+ step={0.01}
51
+ format={(v) => v.toFixed(2)}
52
+ layout="inline"
53
+ />
54
+ }
55
+ />
56
+ ),
57
+ };
58
+
59
+ export const WithUnit: Story = {
60
+ render: () => (
61
+ <div style={{ width: 280 }}>
62
+ <Controlled initial={12} label="Radius" min={0} max={64} format={(v) => `${v}px`} />
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ export const WithUnitSuffix: Story = {
68
+ render: () => (
69
+ <div style={{ width: 280, display: 'grid', gap: 12 }}>
70
+ <Controlled initial={12} label="Radius" min={0} max={64} unit="px" />
71
+ <Controlled initial={45} label="Angle" min={-180} max={180} unit={<sup>°</sup>} />
72
+ </div>
73
+ ),
74
+ };