@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,34 @@
1
+ // Built to dist/theme-interstellar.css. Forces interstellar regardless of OS preference.
2
+ .lk-theme-interstellar {
3
+ --lk-bg: #0a0a14;
4
+ --lk-bg-elevated: rgba(20, 20, 36, 0.55);
5
+ --lk-bg-canvas: #02020a;
6
+ --lk-border: rgba(255, 255, 255, 0.14);
7
+ --lk-divider: rgba(255, 255, 255, 0.08);
8
+ --lk-glass-blur: 12px;
9
+
10
+ --lk-text: #e8e8f0;
11
+ --lk-text-muted: #a8a8c0;
12
+ --lk-text-disabled: #5a5a78;
13
+
14
+ --lk-accent: #b08adb;
15
+ --lk-accent-hover: #c6a3ee;
16
+ --lk-focus-ring: rgba(176, 138, 219, 0.45);
17
+
18
+ --lk-swatch-green: #2fdd18;
19
+ --lk-swatch-pink: #ff5885;
20
+ --lk-swatch-cyan: #00dfff;
21
+ --lk-swatch-gold: #dcb700;
22
+ --lk-swatch-amber: #ff8c00;
23
+ --lk-swatch-violet: #a497ff;
24
+ --lk-swatch-mint: #00e7af;
25
+ --lk-swatch-sky: #00b8ff;
26
+ --lk-swatch-orange: #ff6b00;
27
+ --lk-swatch-magenta: #ff6eff;
28
+
29
+ --lk-space-nebula:
30
+ radial-gradient(ellipse 60% 80% at 18% 30%, rgba(150, 80, 200, 0.22), transparent 70%),
31
+ radial-gradient(ellipse 55% 90% at 78% 70%, rgba(220, 100, 140, 0.18), transparent 65%),
32
+ radial-gradient(ellipse 50% 70% at 50% 50%, rgba(60, 110, 200, 0.18), transparent 75%),
33
+ radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%);
34
+ }
@@ -0,0 +1,14 @@
1
+ // Built to dist/theme-light.css. Forces light regardless of OS preference.
2
+ .lk-theme-light {
3
+ --lk-bg: #fafaf7;
4
+ --lk-bg-elevated: #ffffff;
5
+ --lk-bg-canvas: #f0ebe2;
6
+ --lk-border: #c9bba5;
7
+ --lk-divider: rgba(0, 0, 0, 0.08);
8
+ --lk-text: #2a2018;
9
+ --lk-text-muted: #6b5c4a;
10
+ --lk-text-disabled: #a89b85;
11
+ --lk-accent: #a86f3c;
12
+ --lk-accent-hover: #c2854f;
13
+ --lk-focus-ring: rgba(168, 111, 60, 0.35);
14
+ }
@@ -0,0 +1,85 @@
1
+ // tokens.less — the contract.
2
+ // Interstellar is the base; @media (prefers-color-scheme: light) overrides for OS-following.
3
+ // Explicit .lk-theme-light / .lk-theme-interstellar classes (added by <Lab>) win via specificity.
4
+
5
+ :root {
6
+ // Surface
7
+ --lk-bg: #0a0a14;
8
+ --lk-bg-elevated: rgba(20, 20, 36, 0.55);
9
+ --lk-bg-canvas: #02020a;
10
+ --lk-border: rgba(255, 255, 255, 0.14);
11
+ --lk-divider: rgba(255, 255, 255, 0.08);
12
+ --lk-glass-blur: 12px;
13
+
14
+ // Text
15
+ --lk-text: #e8e8f0;
16
+ --lk-text-muted: #a8a8c0;
17
+ --lk-text-disabled: #5a5a78;
18
+
19
+ // Accent / interactive
20
+ --lk-accent: #b08adb;
21
+ --lk-accent-hover: #c6a3ee;
22
+ --lk-focus-ring: rgba(176, 138, 219, 0.45);
23
+
24
+ // Accent swatches — bright spot colors for plotting, status, data viz.
25
+ --lk-swatch-green: #2fdd18;
26
+ --lk-swatch-pink: #ff5885;
27
+ --lk-swatch-cyan: #00dfff;
28
+ --lk-swatch-gold: #dcb700;
29
+ --lk-swatch-amber: #ff8c00;
30
+ --lk-swatch-violet: #a497ff;
31
+ --lk-swatch-mint: #00e7af;
32
+ --lk-swatch-sky: #00b8ff;
33
+ --lk-swatch-orange: #ff6b00;
34
+ --lk-swatch-magenta: #ff6eff;
35
+
36
+ // Cosmic background — layered fill on .lk-lab.
37
+ --lk-space-nebula:
38
+ radial-gradient(ellipse 60% 80% at 18% 30%, rgba(150, 80, 200, 0.22), transparent 70%),
39
+ radial-gradient(ellipse 55% 90% at 78% 70%, rgba(220, 100, 140, 0.18), transparent 65%),
40
+ radial-gradient(ellipse 50% 70% at 50% 50%, rgba(60, 110, 200, 0.18), transparent 75%),
41
+ radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%);
42
+
43
+ // Sizing
44
+ --lk-radius: 6px;
45
+ --lk-radius-sm: 3px;
46
+ --lk-radius-lg: 14px;
47
+ --lk-control-height: 28px;
48
+ --lk-spacing-xs: 4px;
49
+ --lk-spacing-sm: 8px;
50
+ --lk-spacing-md: 12px;
51
+ --lk-spacing-lg: 16px;
52
+
53
+ // Typography
54
+ --lk-font: 'Oswald', system-ui, -apple-system, sans-serif;
55
+ --lk-font-mono: ui-monospace, SFMono-Regular, monospace;
56
+ --lk-font-display: 'Oswald', system-ui, sans-serif;
57
+ --lk-font-size: 13px;
58
+ --lk-font-size-sm: 11px;
59
+ --lk-font-weight-light: 300;
60
+ --lk-font-weight-medium: 500;
61
+ --lk-font-weight-bold: 700;
62
+
63
+ // Z-layers
64
+ --lk-z-toolbar: 10;
65
+ --lk-z-overlay: 20;
66
+ --lk-z-modal: 30;
67
+ }
68
+
69
+ @media (prefers-color-scheme: light) {
70
+ :root {
71
+ --lk-bg: #fafaf7;
72
+ --lk-bg-elevated: #ffffff;
73
+ --lk-bg-canvas: #f0ebe2;
74
+ --lk-border: #c9bba5;
75
+ --lk-divider: rgba(0, 0, 0, 0.08);
76
+
77
+ --lk-text: #2a2018;
78
+ --lk-text-muted: #6b5c4a;
79
+ --lk-text-disabled: #a89b85;
80
+
81
+ --lk-accent: #a86f3c;
82
+ --lk-accent-hover: #c2854f;
83
+ --lk-focus-ring: rgba(168, 111, 60, 0.35);
84
+ }
85
+ }
@@ -0,0 +1,19 @@
1
+ // Display formatting helpers shared across the UI.
2
+ //
3
+ // House style: render negative numbers with the typographic MINUS SIGN
4
+ // (U+2212, "−") instead of ASCII hyphen-minus ("-"). Editing surfaces should
5
+ // accept either glyph on input via `parseSignedNumber`.
6
+
7
+ const MINUS = '−';
8
+
9
+ /** Format a finite number for display, swapping the leading "-" to U+2212. */
10
+ export function formatNumber(n: number, fractionDigits?: number): string {
11
+ if (!Number.isFinite(n)) return String(n);
12
+ const s = fractionDigits == null ? String(n) : n.toFixed(fractionDigits);
13
+ return s.startsWith('-') ? MINUS + s.slice(1) : s;
14
+ }
15
+
16
+ /** Parse a string that may use U+2212 or ASCII hyphen-minus for the negative sign. */
17
+ export function parseSignedNumber(s: string): number {
18
+ return Number(s.replace(MINUS, '-'));
19
+ }
@@ -0,0 +1,143 @@
1
+ .lk-layer-stack {
2
+ display: grid;
3
+ gap: 8px;
4
+ }
5
+
6
+ .lk-layer-stack__head {
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: space-between;
10
+ gap: 8px;
11
+ }
12
+
13
+ .lk-layer-stack__title {
14
+ margin: 0;
15
+ font-family: var(--lk-font-display);
16
+ font-weight: var(--lk-font-weight-light, 300);
17
+ font-size: 1rem;
18
+ color: var(--lk-text);
19
+ }
20
+
21
+ .lk-layer-stack__palette {
22
+ display: flex;
23
+ gap: 4px;
24
+ }
25
+
26
+ .lk-layer-stack__add {
27
+ background: transparent;
28
+ border: 1px dashed var(--lk-border);
29
+ border-radius: var(--lk-radius-sm, 6px);
30
+ padding: 2px 8px;
31
+ font-size: 0.78rem;
32
+ color: var(--lk-text-dim);
33
+ cursor: pointer;
34
+ }
35
+
36
+ .lk-layer-stack__add:hover {
37
+ background: var(--lk-bg-elevated);
38
+ color: var(--lk-text);
39
+ }
40
+
41
+ .lk-layer-stack__list {
42
+ display: grid;
43
+ gap: 6px;
44
+ }
45
+
46
+ .lk-layer-stack__drop-hint {
47
+ height: 2px;
48
+ background: var(--lk-accent, #6cf);
49
+ border-radius: 1px;
50
+ }
51
+
52
+ .lk-layer-stack__empty {
53
+ color: var(--lk-text-dim);
54
+ font-size: 0.85rem;
55
+ padding: 8px;
56
+ text-align: center;
57
+ }
58
+
59
+ .lk-layer-card {
60
+ background: var(--lk-bg-elevated);
61
+ border: 1px solid var(--lk-border);
62
+ border-radius: var(--lk-radius-md, 8px);
63
+ overflow: hidden;
64
+ }
65
+
66
+ .lk-layer-card.has-accent {
67
+ // Re-bind --lk-accent inside the card so descendant controls (slider tracks
68
+ // and thumbs, readouts, etc.) tint to the per-card accent instead of the
69
+ // global theme accent. Mirrors EffectCard's behavior.
70
+ --lk-accent: var(--lk-layer-card-accent);
71
+ border-left: 3px solid var(--lk-layer-card-accent);
72
+ }
73
+
74
+ .lk-layer-card.is-dragging {
75
+ opacity: 0.5;
76
+ }
77
+
78
+ .lk-layer-card__head {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 6px;
82
+ padding: 6px 8px;
83
+ }
84
+
85
+ // Prefixed with `.lk-root` to outrank the .lk-root button rule in base.less,
86
+ // which would otherwise paint a chrome border + elevated bg on these buttons.
87
+ .lk-root .lk-layer-card__handle {
88
+ background: transparent;
89
+ border: 0;
90
+ color: var(--lk-text-dim);
91
+ padding: 2px 4px;
92
+ cursor: grab;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ min-width: 22px;
97
+ height: 22px;
98
+ border-radius: var(--lk-radius-sm, 6px);
99
+ font-weight: 600;
100
+ font-size: 0.85rem;
101
+ }
102
+
103
+ .lk-layer-card.has-accent .lk-layer-card__handle {
104
+ background: var(--lk-layer-card-accent);
105
+ color: white;
106
+ }
107
+
108
+ .lk-layer-card__handle:active {
109
+ cursor: grabbing;
110
+ }
111
+
112
+ .lk-layer-card__kind,
113
+ .lk-layer-card__primary {
114
+ flex: 1;
115
+ font-size: 0.9rem;
116
+ }
117
+
118
+ .lk-layer-card__primary {
119
+ background: transparent;
120
+ border: 0;
121
+ color: var(--lk-text);
122
+ font: inherit;
123
+ text-transform: uppercase;
124
+ letter-spacing: 0.06em;
125
+ }
126
+
127
+ .lk-root .lk-layer-card__remove {
128
+ background: transparent;
129
+ border: 0;
130
+ color: var(--lk-text-dim);
131
+ font-size: 1rem;
132
+ cursor: pointer;
133
+ padding: 2px 6px;
134
+ }
135
+
136
+ .lk-layer-card__remove:hover {
137
+ color: var(--lk-text);
138
+ }
139
+
140
+ .lk-layer-card__body {
141
+ padding: 8px;
142
+ border-top: 1px solid var(--lk-border);
143
+ }
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { LayerStack, type LayerStackItem } from './LayerStack';
4
+
5
+ const meta: Meta<typeof LayerStack> = {
6
+ title: 'labkit/UI/Layers/LayerStack',
7
+ component: LayerStack,
8
+ };
9
+ export default meta;
10
+
11
+ const seed: LayerStackItem[] = [
12
+ { id: 1, kind: 'fill', primaryValue: 'dome', primaryOptions: ['aqua', 'bevel', 'dome'] },
13
+ { id: 2, kind: 'tail', accent: '#f55', badge: '1' },
14
+ { id: 3, kind: 'shadow' },
15
+ ];
16
+
17
+ export const Basic: StoryObj<typeof LayerStack> = {
18
+ render: () => {
19
+ const [items, setItems] = useState(seed);
20
+ let next = 4;
21
+ return (
22
+ <div style={{ width: 320 }}>
23
+ <LayerStack
24
+ title="Layers"
25
+ items={items}
26
+ paletteKinds={['fill', 'tail', 'shadow']}
27
+ onAdd={(kind) => setItems([...items, { id: next++, kind }])}
28
+ onRemove={(id) => setItems(items.filter((i) => i.id !== id))}
29
+ onReorder={(ids) => {
30
+ const byId = new Map(items.map((i) => [i.id, i]));
31
+ const reordered = ids.flatMap((id) => {
32
+ const found = byId.get(id);
33
+ return found ? [found] : [];
34
+ });
35
+ setItems(reordered);
36
+ }}
37
+ onPrimaryChange={(id, value) =>
38
+ setItems(items.map((i) => (i.id === id ? { ...i, primaryValue: value } : i)))
39
+ }
40
+ renderBody={(item) => <div style={{ color: '#888' }}>body for {item.kind}</div>}
41
+ />
42
+ </div>
43
+ );
44
+ },
45
+ };
@@ -0,0 +1,170 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // useReorderDragList pulls its own React copy from weasel/node_modules, which
5
+ // breaks hooks in jsdom. Stub it out with a no-op implementation.
6
+ vi.mock('../../passthrough/weasel-ui', () => ({
7
+ dlog: () => {},
8
+ useReorderDragList: () => ({
9
+ rowProps: (_id: string, _i: number) => ({ onPointerDown: () => {} }),
10
+ containerProps: {
11
+ ref: () => {},
12
+ onPointerMove: () => {},
13
+ onPointerUp: () => {},
14
+ onPointerCancel: () => {},
15
+ },
16
+ state: { draggedIds: null, targetIndex: null },
17
+ }),
18
+ }));
19
+
20
+ import { LayerStack, type LayerStackItem } from './LayerStack';
21
+
22
+ const items: LayerStackItem[] = [
23
+ { id: 1, kind: 'fill', primaryValue: 'aqua', primaryOptions: ['aqua', 'bevel', 'dome'] },
24
+ { id: 2, kind: 'tail', accent: '#f44', badge: '1' },
25
+ { id: 3, kind: 'shadow' },
26
+ ];
27
+
28
+ describe('LayerStack', () => {
29
+ it('renders header + add buttons + each item', () => {
30
+ render(
31
+ <LayerStack
32
+ title="Fill"
33
+ items={items}
34
+ paletteKinds={['fill', 'tail', 'shadow']}
35
+ onAdd={() => {}}
36
+ onRemove={() => {}}
37
+ onReorder={() => {}}
38
+ onPrimaryChange={() => {}}
39
+ renderBody={(item) => <div>body-{item.id}</div>}
40
+ />,
41
+ );
42
+ expect(screen.getByText('Fill')).toBeInTheDocument();
43
+ for (const k of ['fill', 'tail', 'shadow']) {
44
+ expect(screen.getByRole('button', { name: new RegExp(`add ${k}`, 'i') })).toBeInTheDocument();
45
+ }
46
+ expect(screen.getAllByTestId(/lk-layer-card-/)).toHaveLength(3);
47
+ });
48
+
49
+ it('clicking a palette button calls onAdd with that kind', () => {
50
+ const onAdd = vi.fn();
51
+ render(
52
+ <LayerStack
53
+ title="Fill"
54
+ items={items}
55
+ paletteKinds={['fill', 'tail']}
56
+ onAdd={onAdd}
57
+ onRemove={() => {}}
58
+ onReorder={() => {}}
59
+ onPrimaryChange={() => {}}
60
+ renderBody={() => null}
61
+ />,
62
+ );
63
+ fireEvent.click(screen.getByRole('button', { name: /add tail/i }));
64
+ expect(onAdd).toHaveBeenCalledWith('tail');
65
+ });
66
+
67
+ it('clicking the remove button calls onRemove with that id', () => {
68
+ const onRemove = vi.fn();
69
+ render(
70
+ <LayerStack
71
+ title="Fill"
72
+ items={items}
73
+ paletteKinds={[]}
74
+ onAdd={() => {}}
75
+ onRemove={onRemove}
76
+ onReorder={() => {}}
77
+ onPrimaryChange={() => {}}
78
+ renderBody={() => null}
79
+ />,
80
+ );
81
+ const removes = screen.getAllByRole('button', { name: /remove layer/i });
82
+ fireEvent.click(removes[1]);
83
+ expect(onRemove).toHaveBeenCalledWith(2);
84
+ });
85
+
86
+ it('shows empty state when items is empty', () => {
87
+ render(
88
+ <LayerStack
89
+ title="Empty"
90
+ items={[]}
91
+ paletteKinds={['fill']}
92
+ onAdd={() => {}}
93
+ onRemove={() => {}}
94
+ onReorder={() => {}}
95
+ onPrimaryChange={() => {}}
96
+ renderBody={() => null}
97
+ />,
98
+ );
99
+ expect(screen.getByText(/no layers/i)).toBeInTheDocument();
100
+ });
101
+
102
+ it('changing the primary select calls onPrimaryChange', () => {
103
+ const onPrimaryChange = vi.fn();
104
+ render(
105
+ <LayerStack
106
+ title="Fill"
107
+ items={items}
108
+ paletteKinds={[]}
109
+ onAdd={() => {}}
110
+ onRemove={() => {}}
111
+ onReorder={() => {}}
112
+ onPrimaryChange={onPrimaryChange}
113
+ renderBody={() => null}
114
+ />,
115
+ );
116
+ const sel = screen.getByLabelText(/primary select for layer 1/i) as HTMLSelectElement;
117
+ fireEvent.change(sel, { target: { value: 'bevel' } });
118
+ expect(onPrimaryChange).toHaveBeenCalledWith(1, 'bevel');
119
+ });
120
+
121
+ it('newly added items render expanded by default', () => {
122
+ const initial: LayerStackItem[] = [{ id: 1, kind: 'fill' }];
123
+ const { rerender } = render(
124
+ <LayerStack
125
+ title="Fill"
126
+ items={initial}
127
+ paletteKinds={[]}
128
+ onAdd={() => {}}
129
+ onRemove={() => {}}
130
+ onReorder={() => {}}
131
+ onPrimaryChange={() => {}}
132
+ renderBody={(item) => <div data-testid={`body-${item.id}`}>b{item.id}</div>}
133
+ />,
134
+ );
135
+ expect(screen.getByTestId('body-1')).toBeInTheDocument();
136
+ rerender(
137
+ <LayerStack
138
+ title="Fill"
139
+ items={[...initial, { id: 2, kind: 'tail' }]}
140
+ paletteKinds={[]}
141
+ onAdd={() => {}}
142
+ onRemove={() => {}}
143
+ onReorder={() => {}}
144
+ onPrimaryChange={() => {}}
145
+ renderBody={(item) => <div data-testid={`body-${item.id}`}>b{item.id}</div>}
146
+ />,
147
+ );
148
+ expect(screen.getByTestId('body-2')).toBeInTheDocument();
149
+ });
150
+
151
+ it('items with defaultExpanded: false render collapsed', () => {
152
+ render(
153
+ <LayerStack
154
+ title="Fill"
155
+ items={[{ id: 1, kind: 'fill', defaultExpanded: false }]}
156
+ paletteKinds={[]}
157
+ onAdd={() => {}}
158
+ onRemove={() => {}}
159
+ onReorder={() => {}}
160
+ onPrimaryChange={() => {}}
161
+ renderBody={(item) => <div data-testid={`body-${item.id}`}>b{item.id}</div>}
162
+ />,
163
+ );
164
+ expect(screen.queryByTestId('body-1')).not.toBeInTheDocument();
165
+ });
166
+
167
+ // TODO: reorder off-by-one fix (Bug 1) is covered by manual integration
168
+ // testing in speech-balloons once LayerStack is consumed there (B3).
169
+ // useReorderDragList is mocked in unit tests, making onReorder untestable here.
170
+ });