@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,215 @@
1
+ import { type CSSProperties, type ReactNode, type RefCallback, useEffect, useState } from 'react';
2
+ import { dlog, useReorderDragList } from '../../passthrough/weasel-ui';
3
+
4
+ export interface LayerStackItem {
5
+ /** Stable id used for keys, onRemove, onReorder. Numeric to match
6
+ * common id-from-nextId conventions; string ids also work. */
7
+ id: number | string;
8
+ /** Short kind label rendered in the header when no primary select
9
+ * is hoisted (e.g. "shadow", "stroke"). */
10
+ kind: string;
11
+ /** When present, hoist this select into the card header so the user
12
+ * can switch mode/shape without expanding. */
13
+ primaryValue?: string;
14
+ primaryOptions?: string[];
15
+ /** Accent CSS color used as the left border / index-badge fill. */
16
+ accent?: string;
17
+ /** Optional badge text rendered before the primary control
18
+ * (e.g. tail index "1", "2", "3"). When omitted a drag handle
19
+ * glyph renders in its place. */
20
+ badge?: string;
21
+ /** Initial expanded state. Defaults to true for newly-added items. */
22
+ defaultExpanded?: boolean;
23
+ }
24
+
25
+ export interface LayerStackProps {
26
+ title: string;
27
+ items: LayerStackItem[];
28
+ /** Kinds the user can add via the header palette. */
29
+ paletteKinds: string[];
30
+ onAdd: (kind: string) => void;
31
+ onRemove: (id: number | string) => void;
32
+ onReorder: (orderedIds: Array<number | string>) => void;
33
+ onPrimaryChange: (id: number | string, nextValue: string) => void;
34
+ /** Render the body controls for each item. */
35
+ renderBody: (item: LayerStackItem) => ReactNode;
36
+ /** Hide the title + palette row (used when an outer wrap renders its
37
+ * own head — see speech-balloons Tails panel). */
38
+ hideHead?: boolean;
39
+ }
40
+
41
+ export function LayerStack({
42
+ title,
43
+ items,
44
+ paletteKinds,
45
+ onAdd,
46
+ onRemove,
47
+ onReorder,
48
+ onPrimaryChange,
49
+ renderBody,
50
+ hideHead,
51
+ }: LayerStackProps) {
52
+ const [expandedIds, setExpandedIds] = useState<Set<number | string>>(
53
+ () => new Set(items.filter((i) => i.defaultExpanded !== false).map((i) => i.id)),
54
+ );
55
+
56
+ useEffect(() => {
57
+ setExpandedIds((prev) => {
58
+ let next: Set<number | string> | null = null;
59
+ for (const item of items) {
60
+ if (item.defaultExpanded === false) continue;
61
+ if (prev.has(item.id)) continue;
62
+ if (next === null) next = new Set(prev);
63
+ next.add(item.id);
64
+ }
65
+ return next ?? prev;
66
+ });
67
+ }, [items]);
68
+
69
+ const dragItems = items.map((it) => ({ id: String(it.id), label: it.kind }));
70
+ const drag = useReorderDragList({
71
+ items: dragItems,
72
+ selectedIds: [],
73
+ onReorder: (ids, targetIndex) => {
74
+ dlog('layer-stack', 'onReorder', { ids, targetIndex });
75
+ const orig = items.map((i) => i.id);
76
+ const moving = new Set(ids);
77
+ const remaining = orig.filter((id) => !moving.has(String(id)));
78
+ const movedIds = items.map((i) => i.id).filter((id) => moving.has(String(id)));
79
+ // weasel reports targetIndex against the original list (which includes
80
+ // the dragged row). Each moved item that originally sat before
81
+ // targetIndex needs to shift the insertion point left by one.
82
+ const shift = orig.filter((id, idx) => moving.has(String(id)) && idx < targetIndex).length;
83
+ const adjusted = targetIndex - shift;
84
+ const out = [...remaining];
85
+ out.splice(adjusted, 0, ...movedIds);
86
+ onReorder(out);
87
+ },
88
+ });
89
+
90
+ const toggleExpanded = (id: number | string) => {
91
+ setExpandedIds((s) => {
92
+ const n = new Set(s);
93
+ if (n.has(id)) n.delete(id);
94
+ else n.add(id);
95
+ return n;
96
+ });
97
+ };
98
+
99
+ return (
100
+ <div className="lk-layer-stack">
101
+ {!hideHead && (
102
+ <div className="lk-layer-stack__head">
103
+ <h2 className="lk-layer-stack__title">{title}</h2>
104
+ <div className="lk-layer-stack__palette">
105
+ {paletteKinds.map((k) => (
106
+ <button
107
+ key={k}
108
+ type="button"
109
+ className="lk-layer-stack__add"
110
+ onClick={() => {
111
+ dlog('layer-stack', 'onAdd', { kind: k });
112
+ onAdd(k);
113
+ }}
114
+ aria-label={`Add ${k}`}
115
+ >
116
+ {k}
117
+ </button>
118
+ ))}
119
+ </div>
120
+ </div>
121
+ )}
122
+ <div
123
+ className="lk-layer-stack__list"
124
+ ref={drag.containerProps.ref as RefCallback<HTMLDivElement>}
125
+ onPointerMove={drag.containerProps.onPointerMove}
126
+ onPointerUp={drag.containerProps.onPointerUp}
127
+ onPointerCancel={drag.containerProps.onPointerCancel}
128
+ >
129
+ {items.map((item, i) => {
130
+ const expanded = expandedIds.has(item.id);
131
+ const draggedId = drag.state.draggedIds?.[0];
132
+ const isDragging = draggedId === String(item.id);
133
+ const showHintBefore = drag.state.targetIndex === i && draggedId !== String(item.id);
134
+ const showHintAfter = drag.state.targetIndex === items.length && i === items.length - 1;
135
+ const cardCls = [
136
+ 'lk-layer-card',
137
+ expanded ? 'is-expanded' : 'is-collapsed',
138
+ isDragging ? 'is-dragging' : '',
139
+ item.accent ? 'has-accent' : '',
140
+ ]
141
+ .filter(Boolean)
142
+ .join(' ');
143
+ const cardStyle = item.accent
144
+ ? ({ '--lk-layer-card-accent': item.accent } as CSSProperties)
145
+ : undefined;
146
+ const { onPointerDown } = drag.rowProps(String(item.id), i);
147
+ return (
148
+ <div key={item.id} className="lk-layer-card-wrap">
149
+ {showHintBefore && <div className="lk-layer-stack__drop-hint" />}
150
+ <div className={cardCls} data-testid={`lk-layer-card-${item.id}`} style={cardStyle}>
151
+ <div className="lk-layer-card__head">
152
+ <button
153
+ type="button"
154
+ className="lk-layer-card__handle"
155
+ aria-label={`Drag to reorder layer ${item.id}`}
156
+ onPointerDown={onPointerDown}
157
+ onClick={() => toggleExpanded(item.id)}
158
+ >
159
+ {item.badge ?? <DragHandleGlyph />}
160
+ </button>
161
+ {item.primaryValue !== undefined && item.primaryOptions ? (
162
+ <select
163
+ className="lk-layer-card__primary"
164
+ value={item.primaryValue}
165
+ aria-label={`Primary select for layer ${item.id}`}
166
+ onChange={(e) => onPrimaryChange(item.id, e.target.value)}
167
+ onClick={(e) => e.stopPropagation()}
168
+ >
169
+ {item.primaryOptions.map((o) => (
170
+ <option key={o} value={o}>
171
+ {o}
172
+ </option>
173
+ ))}
174
+ </select>
175
+ ) : (
176
+ <span className="lk-layer-card__kind">{item.kind}</span>
177
+ )}
178
+ <button
179
+ type="button"
180
+ className="lk-layer-card__remove"
181
+ aria-label="Remove layer"
182
+ onClick={(e) => {
183
+ e.stopPropagation();
184
+ onRemove(item.id);
185
+ }}
186
+ >
187
+
188
+ </button>
189
+ </div>
190
+ {expanded && <div className="lk-layer-card__body">{renderBody(item)}</div>}
191
+ </div>
192
+ {showHintAfter && <div className="lk-layer-stack__drop-hint" />}
193
+ </div>
194
+ );
195
+ })}
196
+ {items.length === 0 && (
197
+ <div className="lk-layer-stack__empty">No layers — add one above.</div>
198
+ )}
199
+ </div>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ function DragHandleGlyph() {
205
+ return (
206
+ <svg width="12" height="16" viewBox="0 0 8 16" fill="currentColor" aria-hidden="true">
207
+ <circle cx="2" cy="3" r="1.1" />
208
+ <circle cx="6" cy="3" r="1.1" />
209
+ <circle cx="2" cy="8" r="1.1" />
210
+ <circle cx="6" cy="8" r="1.1" />
211
+ <circle cx="2" cy="13" r="1.1" />
212
+ <circle cx="6" cy="13" r="1.1" />
213
+ </svg>
214
+ );
215
+ }
@@ -0,0 +1 @@
1
+ export { LayerStack, type LayerStackItem, type LayerStackProps } from './LayerStack';
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { CheckboxRow } from './PropertyPanel';
4
+
5
+ const meta: Meta<typeof CheckboxRow> = {
6
+ title: 'labkit/UI/Properties/Rows/CheckboxRow',
7
+ component: CheckboxRow,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof CheckboxRow>;
11
+
12
+ function Controlled({ initial, label }: { initial: boolean; label: string }) {
13
+ const [value, setValue] = useState(initial);
14
+ return (
15
+ <div style={{ width: 280 }}>
16
+ <CheckboxRow label={label} value={value} onChange={setValue} />
17
+ </div>
18
+ );
19
+ }
20
+
21
+ export const Default: Story = {
22
+ render: () => <Controlled initial={true} label="Visible" />,
23
+ };
24
+
25
+ export const Unchecked: Story = {
26
+ render: () => <Controlled initial={false} label="Snap to grid" />,
27
+ };
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { ColorRow, PropertyList } from './PropertyPanel';
4
+
5
+ const meta: Meta<typeof ColorRow> = {
6
+ title: 'labkit/UI/Properties/Rows/ColorRow',
7
+ component: ColorRow,
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof ColorRow>;
11
+
12
+ function One({ initial, label }: { initial: string; label: string }) {
13
+ const [value, setValue] = useState(initial);
14
+ return (
15
+ <div style={{ width: 280 }}>
16
+ <ColorRow label={label} value={value} onChange={setValue} />
17
+ </div>
18
+ );
19
+ }
20
+
21
+ export const Single: Story = {
22
+ render: () => <One initial="#b08adb" label="Fill" />,
23
+ };
24
+
25
+ function Pair() {
26
+ const [fill, setFill] = useState('#b08adb');
27
+ const [stroke, setStroke] = useState('#1a1428');
28
+ return (
29
+ <div style={{ width: 320 }}>
30
+ <PropertyList>
31
+ <ColorRow label="Fill" value={fill} onChange={setFill} />
32
+ <ColorRow label="Stroke" value={stroke} onChange={setStroke} />
33
+ </PropertyList>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export const PairInList: Story = {
39
+ render: () => <Pair />,
40
+ };
41
+
42
+ function OneWithAlpha({
43
+ initial,
44
+ initialAlpha,
45
+ label,
46
+ alphaDisabled,
47
+ }: {
48
+ initial: string;
49
+ initialAlpha: number;
50
+ label: string;
51
+ alphaDisabled?: boolean;
52
+ }) {
53
+ const [value, setValue] = useState(initial);
54
+ const [alpha, setAlpha] = useState(initialAlpha);
55
+ return (
56
+ <div style={{ width: 280 }}>
57
+ <ColorRow
58
+ label={label}
59
+ value={value}
60
+ onChange={setValue}
61
+ alpha={alpha}
62
+ onAlphaChange={setAlpha}
63
+ alphaDisabled={alphaDisabled}
64
+ />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export const WithAlpha: Story = {
70
+ render: () => <OneWithAlpha initial="#7ec8e3" initialAlpha={0.65} label="Fill" />,
71
+ };
72
+
73
+ export const AlphaDisabled: Story = {
74
+ render: () => <OneWithAlpha initial="#7ec8e3" initialAlpha={1} label="Fill" alphaDisabled />,
75
+ };
76
+
77
+ function AlphaPair() {
78
+ const [fill, setFill] = useState('#b08adb');
79
+ const [fillA, setFillA] = useState(0.8);
80
+ const [stroke, setStroke] = useState('#1a1428');
81
+ const [strokeA, setStrokeA] = useState(0.4);
82
+ return (
83
+ <div style={{ width: 320 }}>
84
+ <PropertyList>
85
+ <ColorRow
86
+ label="Fill"
87
+ value={fill}
88
+ onChange={setFill}
89
+ alpha={fillA}
90
+ onAlphaChange={setFillA}
91
+ />
92
+ <ColorRow
93
+ label="Stroke"
94
+ value={stroke}
95
+ onChange={setStroke}
96
+ alpha={strokeA}
97
+ onAlphaChange={setStrokeA}
98
+ />
99
+ </PropertyList>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ export const AlphaPairInList: Story = {
105
+ render: () => <AlphaPair />,
106
+ };
@@ -0,0 +1,48 @@
1
+ .lk-curve-field {
2
+ display: grid;
3
+ gap: 6px;
4
+ grid-column: 1 / -1;
5
+ }
6
+
7
+ .lk-curve-field__plot {
8
+ position: relative;
9
+ width: 100%;
10
+ }
11
+
12
+ .lk-curve-field__marks {
13
+ position: absolute;
14
+ inset: 0;
15
+ pointer-events: none;
16
+ }
17
+
18
+ .lk-curve-field__readouts {
19
+ display: flex;
20
+ justify-content: space-between;
21
+ font-variant-numeric: tabular-nums;
22
+ font-size: 0.85rem;
23
+ color: var(--lk-text-dim);
24
+ }
25
+
26
+ .lk-curve-field__readout {
27
+ font-style: normal;
28
+ }
29
+
30
+ .lk-curve-field__actions {
31
+ display: flex;
32
+ gap: 6px;
33
+ justify-self: start;
34
+ }
35
+
36
+ .lk-curve-field__action {
37
+ background: transparent;
38
+ border: 1px solid var(--lk-border);
39
+ border-radius: var(--lk-radius-sm, 6px);
40
+ color: var(--lk-text);
41
+ padding: 4px 10px;
42
+ font-size: 0.78rem;
43
+ cursor: pointer;
44
+ }
45
+
46
+ .lk-curve-field__action:hover {
47
+ background: var(--lk-bg-elevated);
48
+ }
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { CurveField } from './CurveField';
4
+
5
+ const meta: Meta<typeof CurveField> = {
6
+ title: 'labkit/UI/Properties/CurveField',
7
+ component: CurveField,
8
+ };
9
+ export default meta;
10
+
11
+ export const DomeContour: StoryObj<typeof CurveField> = {
12
+ render: () => {
13
+ const [v, setV] = useState<number[]>([0, -1, 0.5, 0.5, 1, 0.7]);
14
+ return (
15
+ <div style={{ width: 280 }}>
16
+ <CurveField values={v} min={-1} max={1} step={0.02} width={280} onChange={setV} />
17
+ </div>
18
+ );
19
+ },
20
+ };
@@ -0,0 +1,127 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // CurveEditor uses weasel's own bundled React and fails in jsdom.
5
+ // Mock it out — these tests only assert on readouts and the flip button.
6
+ vi.mock('../../passthrough/weasel-ui', async (importOriginal) => {
7
+ const real = await importOriginal<typeof import('../../passthrough/weasel-ui')>();
8
+ return { ...real, CurveEditor: () => null };
9
+ });
10
+
11
+ import { CurveField } from './CurveField';
12
+
13
+ describe('CurveField', () => {
14
+ it('flip button mirrors x → 1-x and resorts', () => {
15
+ const onChange = vi.fn();
16
+ render(
17
+ <CurveField
18
+ values={[0, -1, 0.3, 0.4, 1, 0.7]}
19
+ min={-1}
20
+ max={1}
21
+ step={0.02}
22
+ width={200}
23
+ height={110}
24
+ onChange={onChange}
25
+ />,
26
+ );
27
+ screen.getByRole('button', { name: /flip horizontally/i }).click();
28
+ expect(onChange).toHaveBeenCalledTimes(1);
29
+ // After flip: (0,-1) → (1,-1), (0.3,0.4) → (0.7,0.4), (1,0.7) → (0,0.7).
30
+ // Sort by x ascending: (0,0.7), (0.7,0.4), (1,-1).
31
+ expect(onChange.mock.calls[0][0]).toEqual([0, 0.7, 0.7, 0.4, 1, -1]);
32
+ });
33
+
34
+ it('flip vertically mirrors y through (min+max)/2 and keeps x order', () => {
35
+ const onChange = vi.fn();
36
+ render(
37
+ <CurveField
38
+ values={[0, -1, 0.3, 0.4, 1, 0.7]}
39
+ min={-1}
40
+ max={1}
41
+ step={0.02}
42
+ width={200}
43
+ height={110}
44
+ onChange={onChange}
45
+ />,
46
+ );
47
+ screen.getByRole('button', { name: /flip vertically/i }).click();
48
+ expect(onChange).toHaveBeenCalledTimes(1);
49
+ // (min+max)/2 = 0. -1 → 1, 0.4 → -0.4, 0.7 → -0.7. x order preserved.
50
+ expect(onChange.mock.calls[0][0]).toEqual([0, 1, 0.3, -0.4, 1, -0.7]);
51
+ });
52
+
53
+ it('flip vertically with [0,1] range mirrors through 0.5', () => {
54
+ const onChange = vi.fn();
55
+ render(
56
+ <CurveField
57
+ values={[0, 0, 0.5, 0.25, 1, 1]}
58
+ min={0}
59
+ max={1}
60
+ step={0.01}
61
+ width={200}
62
+ height={110}
63
+ onChange={onChange}
64
+ />,
65
+ );
66
+ screen.getByRole('button', { name: /flip vertically/i }).click();
67
+ expect(onChange.mock.calls[0][0]).toEqual([0, 1, 0.5, 0.75, 1, 0]);
68
+ });
69
+
70
+ it('renders a band mark as a positioned overlay rect', () => {
71
+ const { container } = render(
72
+ <CurveField
73
+ values={[0, 0, 1, 1]}
74
+ min={0}
75
+ max={1}
76
+ step={0.01}
77
+ width={200}
78
+ height={110}
79
+ marks={[{ kind: 'band', x: [0.2, 0.4], color: '#ffcc00' }]}
80
+ onChange={() => {}}
81
+ />,
82
+ );
83
+ const overlay = container.querySelector('.lk-curve-field__marks');
84
+ expect(overlay).not.toBeNull();
85
+ const rect = overlay!.querySelector('rect');
86
+ expect(rect).not.toBeNull();
87
+ // x = 0.2 * 200 = 40; width = (0.4 - 0.2) * 200 = 40.
88
+ expect(rect!.getAttribute('x')).toBe('40');
89
+ expect(rect!.getAttribute('width')).toBe('40');
90
+ expect(rect!.getAttribute('fill')).toBe('#ffcc00');
91
+ });
92
+
93
+ it('renders a line mark as a positioned line', () => {
94
+ const { container } = render(
95
+ <CurveField
96
+ values={[0, 0, 1, 1]}
97
+ min={0}
98
+ max={1}
99
+ step={0.01}
100
+ width={200}
101
+ height={110}
102
+ marks={[{ kind: 'line', x: 0.7, color: '#ffcc00' }]}
103
+ onChange={() => {}}
104
+ />,
105
+ );
106
+ const line = container.querySelector('.lk-curve-field__marks line');
107
+ expect(line).not.toBeNull();
108
+ expect(line!.getAttribute('x1')).toBe('140');
109
+ expect(line!.getAttribute('x2')).toBe('140');
110
+ expect(line!.getAttribute('stroke')).toBe('#ffcc00');
111
+ });
112
+
113
+ it('omits the marks overlay when marks is undefined', () => {
114
+ const { container } = render(
115
+ <CurveField
116
+ values={[0, 0, 1, 1]}
117
+ min={0}
118
+ max={1}
119
+ step={0.01}
120
+ width={200}
121
+ height={110}
122
+ onChange={() => {}}
123
+ />,
124
+ );
125
+ expect(container.querySelector('.lk-curve-field__marks')).toBeNull();
126
+ });
127
+ });