@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,181 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { type ControlPoint, CurveEditor, dlog } from '../../passthrough/weasel-ui';
3
+
4
+ export type CurveMark =
5
+ | { kind: 'band'; x: [number, number]; color?: string }
6
+ | { kind: 'line'; x: number; color?: string };
7
+
8
+ export interface CurveFieldProps {
9
+ /** Flat [x0, y0, x1, y1, …] — matches how curve-as-array configs
10
+ * serialize in JSON snapshots. */
11
+ values: number[];
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ /** Plot width in CSS px. Caller sizes (typically uses a ResizeObserver). */
16
+ width: number;
17
+ /** Plot height in CSS px. Default 110. */
18
+ height?: number;
19
+ /** Flat default curve. When provided, a Reset button restores it. */
20
+ defaults?: readonly number[];
21
+ onChange: (next: number[]) => void;
22
+ /** Optional vertical marks overlaid on the plot (e.g. landmarks). */
23
+ marks?: readonly CurveMark[];
24
+ }
25
+
26
+ /**
27
+ * A function-domain (y = f(x), x ∈ [0,1]) curve editor with per-stop
28
+ * numeric readouts and flip buttons. Wraps weasel-ui's
29
+ * `CurveEditor`; consumers wanting the raw editor (2D paths,
30
+ * custom anchors) should import it from `@weasel-js/labkit/weasel-ui`.
31
+ */
32
+ export function CurveField({
33
+ values,
34
+ min,
35
+ max,
36
+ step,
37
+ width,
38
+ height = 110,
39
+ defaults,
40
+ onChange,
41
+ marks,
42
+ }: CurveFieldProps) {
43
+ const points: ControlPoint[] = useMemo(() => {
44
+ const out: ControlPoint[] = [];
45
+ for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i]!, y: values[i + 1]! });
46
+ return out;
47
+ }, [values]);
48
+
49
+ const handleChange = useCallback(
50
+ (next: ControlPoint[]) => {
51
+ const flat: number[] = new Array(next.length * 2);
52
+ for (let i = 0; i < next.length; i++) {
53
+ const ySnap = Math.round(next[i]!.y / step) * step;
54
+ const y = Math.max(min, Math.min(max, ySnap));
55
+ flat[i * 2] = next[i]!.x;
56
+ flat[i * 2 + 1] = y;
57
+ }
58
+ dlog('curve-field', 'handleChange', {
59
+ nLen: next.length,
60
+ flatLen: flat.length,
61
+ sample: flat.slice(0, 4),
62
+ });
63
+ onChange(flat);
64
+ },
65
+ [onChange, min, max, step],
66
+ );
67
+
68
+ const handleFlip = useCallback(() => {
69
+ const flipped: Array<{ x: number; y: number }> = [];
70
+ for (let i = 0; i + 1 < values.length; i += 2) {
71
+ flipped.push({ x: 1 - values[i]!, y: values[i + 1]! });
72
+ }
73
+ flipped.sort((a, b) => a.x - b.x);
74
+ const out: number[] = [];
75
+ for (const p of flipped) out.push(p.x, p.y);
76
+ onChange(out);
77
+ }, [values, onChange]);
78
+
79
+ const handleFlipVertical = useCallback(() => {
80
+ const mid = (min + max) / 2;
81
+ const flipped: number[] = new Array(values.length);
82
+ for (let i = 0; i + 1 < values.length; i += 2) {
83
+ flipped[i] = values[i]!;
84
+ flipped[i + 1] = 2 * mid - values[i + 1]!;
85
+ }
86
+ onChange(flipped);
87
+ }, [values, min, max, onChange]);
88
+
89
+ return (
90
+ <div className="lk-curve-field">
91
+ <div className="lk-curve-field__plot">
92
+ <CurveEditor
93
+ value={points}
94
+ onChange={handleChange}
95
+ domain="1d"
96
+ constrain="function"
97
+ xRange={[0, 1]}
98
+ yRange={[min, max]}
99
+ width={width}
100
+ height={height}
101
+ endpoints="pinned-x"
102
+ addPointMode="click-curve"
103
+ minPoints={2}
104
+ grid={{}}
105
+ history={false}
106
+ />
107
+ {marks && marks.length > 0 && (
108
+ <svg
109
+ className="lk-curve-field__marks"
110
+ width={width}
111
+ height={height}
112
+ viewBox={`0 0 ${width} ${height}`}
113
+ aria-hidden="true"
114
+ >
115
+ {marks.map((m) => {
116
+ const color = m.color ?? '#ffcc00';
117
+ if (m.kind === 'band') {
118
+ const x0 = Math.max(0, Math.min(1, m.x[0])) * width;
119
+ const x1 = Math.max(0, Math.min(1, m.x[1])) * width;
120
+ const left = Math.min(x0, x1);
121
+ const w = Math.abs(x1 - x0);
122
+ return (
123
+ <g key={`band-${m.x[0]}-${m.x[1]}-${color}`}>
124
+ <rect x={left} y={0} width={w} height={height} fill={color} fillOpacity={0.4} />
125
+ <line
126
+ x1={left}
127
+ y1={0}
128
+ x2={left}
129
+ y2={height}
130
+ stroke={color}
131
+ strokeOpacity={0.85}
132
+ strokeWidth={1}
133
+ />
134
+ <line
135
+ x1={left + w}
136
+ y1={0}
137
+ x2={left + w}
138
+ y2={height}
139
+ stroke={color}
140
+ strokeOpacity={0.85}
141
+ strokeWidth={1}
142
+ />
143
+ </g>
144
+ );
145
+ }
146
+ const x = Math.max(0, Math.min(1, m.x)) * width;
147
+ return (
148
+ <line
149
+ key={`mark-${m.x}-${color}`}
150
+ x1={x}
151
+ y1={0}
152
+ x2={x}
153
+ y2={height}
154
+ stroke={color}
155
+ strokeWidth={1}
156
+ />
157
+ );
158
+ })}
159
+ </svg>
160
+ )}
161
+ </div>
162
+ <div className="lk-curve-field__actions">
163
+ <button type="button" className="lk-curve-field__action" onClick={handleFlip}>
164
+ Flip horizontally
165
+ </button>
166
+ <button type="button" className="lk-curve-field__action" onClick={handleFlipVertical}>
167
+ Flip vertically
168
+ </button>
169
+ {defaults && (
170
+ <button
171
+ type="button"
172
+ className="lk-curve-field__action"
173
+ onClick={() => onChange([...defaults])}
174
+ >
175
+ Reset
176
+ </button>
177
+ )}
178
+ </div>
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,312 @@
1
+ import { type CSSProperties, type ReactNode, useState } from 'react';
2
+
3
+ // ── Subpanel ─────────────────────────────────────────────────────────
4
+ // Headered group of controls inside a wider panel/list. The header is a
5
+ // label flanked by a horizontal rule — no background, padding, or border;
6
+ // purely a typographic divider. Ported from speech-balloons styles.css:277-313.
7
+
8
+ export interface SubpanelProps {
9
+ title: ReactNode;
10
+ children: ReactNode;
11
+ className?: string;
12
+ }
13
+
14
+ export function Subpanel({ title, children, className }: SubpanelProps) {
15
+ const cls = className ? `lk-subpanel ${className}` : 'lk-subpanel';
16
+ return (
17
+ <div className={cls}>
18
+ <h4 className="lk-subpanel__title">
19
+ <span>{title}</span>
20
+ <hr />
21
+ </h4>
22
+ {children}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ // ── EffectCard ───────────────────────────────────────────────────────
28
+ // Draggable, collapsible card for stacked effect editors. Optional --lk-panel-accent
29
+ // per-instance recolors the title bar, border-left, and (via re-binding --lk-accent
30
+ // inside the card) every descendant control that reads from the accent token.
31
+
32
+ export interface EffectCardProps {
33
+ /** Card title rendered in the title bar (e.g. effect kind label or a select). */
34
+ title: ReactNode;
35
+ /** Optional badge/chip displayed before the remove button (e.g. primary value summary). */
36
+ primary?: ReactNode;
37
+ /** Optional accent color in any CSS color form. Sets --lk-panel-accent on the card. */
38
+ accent?: string;
39
+ /** Optional ordinal badge that doubles as the drag handle (used by tail cards in SB). */
40
+ index?: number;
41
+ expanded?: boolean;
42
+ onToggleExpanded?: () => void;
43
+ onRemove?: () => void;
44
+ /**
45
+ * Drag-handle wiring. When provided, an inline handle (⋮⋮ icon or `index`)
46
+ * activates HTML5 drag on press. Consumer owns the drag/drop coordination —
47
+ * see EffectCardList for the batteries-included wrapper.
48
+ */
49
+ draggable?: boolean;
50
+ onPressHandle?: () => void;
51
+ onReleaseHandle?: () => void;
52
+ onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void;
53
+ onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void;
54
+ onDragOver?: (e: React.DragEvent<HTMLDivElement>) => void;
55
+ onDragLeave?: (e: React.DragEvent<HTMLDivElement>) => void;
56
+ onDrop?: (e: React.DragEvent<HTMLDivElement>) => void;
57
+ dragging?: boolean;
58
+ children?: ReactNode;
59
+ className?: string;
60
+ }
61
+
62
+ export function EffectCard({
63
+ title,
64
+ primary,
65
+ accent,
66
+ index,
67
+ expanded = true,
68
+ onToggleExpanded,
69
+ onRemove,
70
+ draggable,
71
+ onPressHandle,
72
+ onReleaseHandle,
73
+ onDragStart,
74
+ onDragEnd,
75
+ onDragOver,
76
+ onDragLeave,
77
+ onDrop,
78
+ dragging,
79
+ children,
80
+ className,
81
+ }: EffectCardProps) {
82
+ const stateClass = `${expanded ? 'is-expanded' : 'is-collapsed'}${dragging ? ' is-dragging' : ''}${accent ? ' has-accent' : ''}`;
83
+ const cls = `lk-effect-card ${stateClass}${className ? ` ${className}` : ''}`;
84
+ // Per-instance dynamic color → CSS custom property requires inline style.
85
+ // The card's CSS re-binds --lk-accent to --lk-panel-accent so descendants tint.
86
+ const style = accent ? ({ '--lk-panel-accent': accent } as CSSProperties) : undefined;
87
+
88
+ // When a toggle handler is supplied the header acts as a disclosure button:
89
+ // role + tab stop + Enter/Space. Bundled together so the interactive props are
90
+ // applied as a set (no half-interactive element when onToggleExpanded is absent).
91
+ const headProps = onToggleExpanded
92
+ ? {
93
+ role: 'button' as const,
94
+ tabIndex: 0,
95
+ onClick: onToggleExpanded,
96
+ onKeyDown: (e: React.KeyboardEvent) => {
97
+ if (e.key === 'Enter' || e.key === ' ') {
98
+ e.preventDefault();
99
+ onToggleExpanded();
100
+ }
101
+ },
102
+ }
103
+ : {};
104
+
105
+ return (
106
+ // biome-ignore lint/a11y/noStaticElementInteractions: native HTML5 drag-and-drop container — drag events are not click/keyboard interactions and have no semantic element; the in-card handle and header carry the a11y affordances.
107
+ <div
108
+ className={cls}
109
+ style={style}
110
+ draggable={draggable}
111
+ onDragStart={onDragStart}
112
+ onDragEnd={onDragEnd}
113
+ onDragOver={onDragOver}
114
+ onDragLeave={onDragLeave}
115
+ onDrop={onDrop}
116
+ >
117
+ <div className="lk-effect-card__head" {...headProps}>
118
+ {index != null ? (
119
+ <button
120
+ type="button"
121
+ className="lk-effect-card__index-badge lk-effect-card__handle"
122
+ title="Drag to reorder · click to toggle"
123
+ aria-label={`Item ${index + 1} — drag to reorder, click to toggle`}
124
+ onMouseDown={onPressHandle}
125
+ onMouseUp={onReleaseHandle}
126
+ onClick={(e) => {
127
+ e.stopPropagation();
128
+ onToggleExpanded?.();
129
+ }}
130
+ >
131
+ {index + 1}
132
+ </button>
133
+ ) : (
134
+ <span
135
+ className="lk-effect-card__handle"
136
+ aria-hidden="true"
137
+ title="Drag to reorder"
138
+ onMouseDown={onPressHandle}
139
+ onMouseUp={onReleaseHandle}
140
+ >
141
+ <DragHandleIcon />
142
+ </span>
143
+ )}
144
+ <span className="lk-effect-card__title">{title}</span>
145
+ {primary != null && <span className="lk-effect-card__primary">{primary}</span>}
146
+ {onRemove && (
147
+ <button
148
+ type="button"
149
+ className="lk-effect-card__remove"
150
+ onClick={(e) => {
151
+ e.stopPropagation();
152
+ onRemove();
153
+ }}
154
+ title="Remove"
155
+ aria-label="Remove"
156
+ >
157
+
158
+ </button>
159
+ )}
160
+ </div>
161
+ {expanded && <div className="lk-effect-card__body">{children}</div>}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ function DragHandleIcon() {
167
+ return (
168
+ <svg width="10" height="14" viewBox="0 0 10 14" aria-hidden="true">
169
+ <circle cx="3" cy="3" r="1.3" fill="currentColor" />
170
+ <circle cx="7" cy="3" r="1.3" fill="currentColor" />
171
+ <circle cx="3" cy="7" r="1.3" fill="currentColor" />
172
+ <circle cx="7" cy="7" r="1.3" fill="currentColor" />
173
+ <circle cx="3" cy="11" r="1.3" fill="currentColor" />
174
+ <circle cx="7" cy="11" r="1.3" fill="currentColor" />
175
+ </svg>
176
+ );
177
+ }
178
+
179
+ // ── EffectCardList ───────────────────────────────────────────────────
180
+ // Batteries-included list wrapper that coordinates HTML5 drag-and-drop across
181
+ // a list of EffectCards. Consumer supplies items + a renderItem callback;
182
+ // onReorder fires with (sourceId, targetId, position) on drop. Mirrors the
183
+ // LayerStack pattern from speech-balloons Lab.tsx:630-707.
184
+
185
+ export interface EffectCardListItem {
186
+ id: string | number;
187
+ }
188
+
189
+ export interface EffectCardListProps<T extends EffectCardListItem> {
190
+ items: ReadonlyArray<T>;
191
+ renderItem: (
192
+ item: T,
193
+ helpers: {
194
+ isExpanded: boolean;
195
+ isDragging: boolean;
196
+ toggleExpanded: () => void;
197
+ cardProps: Pick<
198
+ EffectCardProps,
199
+ | 'expanded'
200
+ | 'dragging'
201
+ | 'draggable'
202
+ | 'onToggleExpanded'
203
+ | 'onPressHandle'
204
+ | 'onReleaseHandle'
205
+ | 'onDragStart'
206
+ | 'onDragEnd'
207
+ | 'onDragOver'
208
+ | 'onDragLeave'
209
+ | 'onDrop'
210
+ >;
211
+ },
212
+ ) => ReactNode;
213
+ onReorder: (sourceId: T['id'], targetId: T['id'], position: 'before' | 'after') => void;
214
+ /** Optional rendered when the list is empty. */
215
+ empty?: ReactNode;
216
+ /** IDs to render expanded on first mount. Defaults to all collapsed. */
217
+ defaultExpandedIds?: ReadonlyArray<T['id']>;
218
+ }
219
+
220
+ export function EffectCardList<T extends EffectCardListItem>({
221
+ items,
222
+ renderItem,
223
+ onReorder,
224
+ empty,
225
+ defaultExpandedIds,
226
+ }: EffectCardListProps<T>) {
227
+ const [expanded, setExpanded] = useState<Set<T['id']>>(() => new Set(defaultExpandedIds ?? []));
228
+ const [draggingId, setDraggingId] = useState<T['id'] | null>(null);
229
+ const [pressedId, setPressedId] = useState<T['id'] | null>(null);
230
+ const [dropHint, setDropHint] = useState<{ id: T['id']; position: 'before' | 'after' } | null>(
231
+ null,
232
+ );
233
+
234
+ if (items.length === 0) {
235
+ return <>{empty ?? <div className="lk-effect-card-list__empty">No items.</div>}</>;
236
+ }
237
+
238
+ return (
239
+ <div className="lk-effect-card-list">
240
+ {items.map((item) => {
241
+ const isExpanded = expanded.has(item.id);
242
+ const isDragging = draggingId === item.id;
243
+ const isPressed = pressedId === item.id;
244
+ const showHintBefore =
245
+ dropHint && dropHint.id === item.id && dropHint.position === 'before';
246
+ const showHintAfter = dropHint && dropHint.id === item.id && dropHint.position === 'after';
247
+
248
+ const toggleExpanded = () =>
249
+ setExpanded((prev) => {
250
+ const next = new Set(prev);
251
+ if (next.has(item.id)) next.delete(item.id);
252
+ else next.add(item.id);
253
+ return next;
254
+ });
255
+
256
+ return (
257
+ <div key={String(item.id)} className="lk-effect-card-list__wrap">
258
+ {showHintBefore && <div className="lk-effect-card-list__drop-hint" />}
259
+ {renderItem(item, {
260
+ isExpanded,
261
+ isDragging,
262
+ toggleExpanded,
263
+ cardProps: {
264
+ expanded: isExpanded,
265
+ dragging: isDragging,
266
+ draggable: isPressed,
267
+ onToggleExpanded: toggleExpanded,
268
+ onPressHandle: () => setPressedId(item.id),
269
+ onReleaseHandle: () => setPressedId(null),
270
+ onDragStart: (e) => {
271
+ e.dataTransfer.effectAllowed = 'move';
272
+ e.dataTransfer.setData('text/plain', String(item.id));
273
+ setDraggingId(item.id);
274
+ },
275
+ onDragEnd: () => {
276
+ setDraggingId(null);
277
+ setPressedId(null);
278
+ setDropHint(null);
279
+ },
280
+ onDragOver: (e) => {
281
+ if (draggingId === null || draggingId === item.id) return;
282
+ e.preventDefault();
283
+ e.dataTransfer.dropEffect = 'move';
284
+ const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
285
+ const position: 'before' | 'after' =
286
+ e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
287
+ setDropHint({ id: item.id, position });
288
+ },
289
+ onDragLeave: () => {
290
+ setDropHint((h) => (h && h.id === item.id ? null : h));
291
+ },
292
+ onDrop: (e) => {
293
+ e.preventDefault();
294
+ if (draggingId === null || draggingId === item.id) {
295
+ setDropHint(null);
296
+ return;
297
+ }
298
+ const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
299
+ const position: 'before' | 'after' =
300
+ e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
301
+ onReorder(draggingId, item.id, position);
302
+ setDropHint(null);
303
+ },
304
+ },
305
+ })}
306
+ {showHintAfter && <div className="lk-effect-card-list__drop-hint" />}
307
+ </div>
308
+ );
309
+ })}
310
+ </div>
311
+ );
312
+ }
@@ -0,0 +1,178 @@
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
+ PropertyPanel,
9
+ PropertyRow,
10
+ SelectRow,
11
+ SliderRow,
12
+ TextRow,
13
+ ToggleRow,
14
+ } from './PropertyPanel';
15
+
16
+ // Titled "Gallery" so it sorts above PropertyPanel/PropertyList/Rows in the
17
+ // sidebar alphabetically (G < P < R). Acts as the visual root of the section.
18
+ const meta: Meta = {
19
+ title: 'labkit/UI/Properties/Gallery',
20
+ };
21
+ export default meta;
22
+ type Story = StoryObj;
23
+
24
+ function BlockRows() {
25
+ const [opacity, setOpacity] = useState(0.65);
26
+ const [count, setCount] = useState(8);
27
+ const [name, setName] = useState('Untitled');
28
+ const [mode, setMode] = useState<'fill' | 'stroke' | 'both'>('both');
29
+ const [align, setAlign] = useState<'left' | 'center' | 'right'>('center');
30
+ return (
31
+ <PropertyPanel title="Block layout (default)">
32
+ <PropertyList>
33
+ <SliderRow
34
+ label="Opacity"
35
+ value={opacity}
36
+ min={0}
37
+ max={1}
38
+ step={0.01}
39
+ onChange={setOpacity}
40
+ format={(v) => v.toFixed(2)}
41
+ />
42
+ <NumberRow label="Count" value={count} onChange={setCount} min={0} max={100} />
43
+ <TextRow label="Name" value={name} onChange={setName} />
44
+ <SelectRow
45
+ label="Mode"
46
+ value={mode}
47
+ onChange={setMode}
48
+ options={[
49
+ { value: 'fill', label: 'Fill only' },
50
+ { value: 'stroke', label: 'Stroke only' },
51
+ { value: 'both', label: 'Fill + stroke' },
52
+ ]}
53
+ />
54
+ <ToggleRow
55
+ label="Align"
56
+ value={align}
57
+ onChange={setAlign}
58
+ options={[
59
+ { value: 'left', label: 'L' },
60
+ { value: 'center', label: 'C' },
61
+ { value: 'right', label: 'R' },
62
+ ]}
63
+ />
64
+ </PropertyList>
65
+ </PropertyPanel>
66
+ );
67
+ }
68
+
69
+ function InlineRows() {
70
+ const [opacity, setOpacity] = useState(0.5);
71
+ const [count, setCount] = useState(8);
72
+ const [name, setName] = useState('Untitled');
73
+ const [mode, setMode] = useState<'fill' | 'stroke' | 'both'>('both');
74
+ const [align, setAlign] = useState<'left' | 'center' | 'right'>('center');
75
+ return (
76
+ <PropertyPanel title="Inline layout">
77
+ <PropertyList>
78
+ <SliderRow
79
+ label="Opacity"
80
+ layout="inline"
81
+ value={opacity}
82
+ min={0}
83
+ max={1}
84
+ step={0.01}
85
+ onChange={setOpacity}
86
+ format={(v) => v.toFixed(2)}
87
+ />
88
+ <NumberRow
89
+ label="Count"
90
+ layout="inline"
91
+ value={count}
92
+ onChange={setCount}
93
+ min={0}
94
+ max={100}
95
+ />
96
+ <TextRow label="Name" layout="inline" value={name} onChange={setName} />
97
+ <SelectRow
98
+ label="Mode"
99
+ layout="inline"
100
+ value={mode}
101
+ onChange={setMode}
102
+ options={[
103
+ { value: 'fill', label: 'Fill only' },
104
+ { value: 'stroke', label: 'Stroke only' },
105
+ { value: 'both', label: 'Fill + stroke' },
106
+ ]}
107
+ />
108
+ <ToggleRow
109
+ label="Align"
110
+ layout="inline"
111
+ value={align}
112
+ onChange={setAlign}
113
+ options={[
114
+ { value: 'left', label: 'L' },
115
+ { value: 'center', label: 'C' },
116
+ { value: 'right', label: 'R' },
117
+ ]}
118
+ />
119
+ </PropertyList>
120
+ </PropertyPanel>
121
+ );
122
+ }
123
+
124
+ function IntrinsicRows() {
125
+ const [fill, setFill] = useState('#b08adb');
126
+ const [stroke, setStroke] = useState('#1a1428');
127
+ const [highlight, setHighlight] = useState('#7fb069');
128
+ const [shadow, setShadow] = useState('#000000');
129
+ const [visible, setVisible] = useState(true);
130
+ const [snap, setSnap] = useState(false);
131
+ return (
132
+ <PropertyPanel title="Intrinsic-layout variants">
133
+ <PropertyList>
134
+ <ColorRow label="Fill" value={fill} onChange={setFill} />
135
+ <ColorRow label="Stroke" value={stroke} onChange={setStroke} />
136
+ <ColorRow label="Highlight" value={highlight} onChange={setHighlight} />
137
+ <ColorRow label="Shadow" value={shadow} onChange={setShadow} />
138
+ <CheckboxRow label="Visible" value={visible} onChange={setVisible} />
139
+ <CheckboxRow label="Snap to grid" value={snap} onChange={setSnap} />
140
+ </PropertyList>
141
+ </PropertyPanel>
142
+ );
143
+ }
144
+
145
+ function CustomRows() {
146
+ return (
147
+ <PropertyPanel title="Custom (PropertyRow)">
148
+ <PropertyList>
149
+ <PropertyRow label="Tempo" readout="120 bpm">
150
+ <button type="button">Tap to set</button>
151
+ </PropertyRow>
152
+ <PropertyRow label="Free-form" layout="inline">
153
+ <code style={{ background: 'rgba(0,0,0,0.35)', padding: '2px 6px', borderRadius: 3 }}>
154
+ arbitrary children
155
+ </code>
156
+ </PropertyRow>
157
+ </PropertyList>
158
+ </PropertyPanel>
159
+ );
160
+ }
161
+
162
+ export const All: Story = {
163
+ render: () => (
164
+ <div
165
+ style={{
166
+ display: 'grid',
167
+ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
168
+ gap: 16,
169
+ maxWidth: 1100,
170
+ }}
171
+ >
172
+ <BlockRows />
173
+ <InlineRows />
174
+ <IntrinsicRows />
175
+ <CustomRows />
176
+ </div>
177
+ ),
178
+ };