@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,61 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { WorkspaceGrid } from './WorkspaceGrid';
3
+
4
+ const meta: Meta<typeof WorkspaceGrid> = {
5
+ title: 'labkit/Lab/WorkspaceGrid',
6
+ component: WorkspaceGrid,
7
+ };
8
+ export default meta;
9
+
10
+ type Story = StoryObj<typeof WorkspaceGrid>;
11
+
12
+ const Tile = ({ children }: { children: React.ReactNode }) => (
13
+ <div
14
+ style={{
15
+ background: 'var(--lk-bg-elevated)',
16
+ border: '1px solid var(--lk-border)',
17
+ borderRadius: 'var(--lk-radius)',
18
+ padding: 'var(--lk-spacing-md)',
19
+ minHeight: 120,
20
+ display: 'grid',
21
+ placeItems: 'center',
22
+ }}
23
+ >
24
+ {children}
25
+ </div>
26
+ );
27
+
28
+ export const OneTile: Story = {
29
+ render: () => (
30
+ <div style={{ height: 400 }}>
31
+ <WorkspaceGrid>
32
+ <Tile>1</Tile>
33
+ </WorkspaceGrid>
34
+ </div>
35
+ ),
36
+ };
37
+
38
+ export const ThreeTiles: Story = {
39
+ render: () => (
40
+ <div style={{ height: 400 }}>
41
+ <WorkspaceGrid>
42
+ <Tile>1</Tile>
43
+ <Tile>2</Tile>
44
+ <Tile>3</Tile>
45
+ </WorkspaceGrid>
46
+ </div>
47
+ ),
48
+ };
49
+
50
+ export const SevenTiles: Story = {
51
+ render: () => (
52
+ <div style={{ height: 600 }}>
53
+ <WorkspaceGrid>
54
+ {Array.from({ length: 7 }).map((_, i) => (
55
+ // biome-ignore lint/suspicious/noArrayIndexKey: static demo list
56
+ <Tile key={i}>{i + 1}</Tile>
57
+ ))}
58
+ </WorkspaceGrid>
59
+ </div>
60
+ ),
61
+ };
@@ -0,0 +1,40 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { WorkspaceGrid } from './WorkspaceGrid';
4
+
5
+ describe('WorkspaceGrid', () => {
6
+ test('renders all children', () => {
7
+ render(
8
+ <WorkspaceGrid>
9
+ <div>one</div>
10
+ <div>two</div>
11
+ <div>three</div>
12
+ </WorkspaceGrid>,
13
+ );
14
+ expect(screen.getByText('one')).toBeInTheDocument();
15
+ expect(screen.getByText('two')).toBeInTheDocument();
16
+ expect(screen.getByText('three')).toBeInTheDocument();
17
+ });
18
+
19
+ test('sets CSS custom properties for grid dimensions based on child count', () => {
20
+ const { container } = render(
21
+ <WorkspaceGrid>
22
+ <div>a</div>
23
+ <div>b</div>
24
+ <div>c</div>
25
+ </WorkspaceGrid>,
26
+ );
27
+ const grid = container.firstChild as HTMLElement;
28
+ expect(grid.style.getPropertyValue('--lk-grid-cols')).toBe('2');
29
+ expect(grid.style.getPropertyValue('--lk-grid-rows')).toBe('2');
30
+ });
31
+
32
+ test('uses lk-workspace-grid class', () => {
33
+ const { container } = render(
34
+ <WorkspaceGrid>
35
+ <div />
36
+ </WorkspaceGrid>,
37
+ );
38
+ expect((container.firstChild as HTMLElement).className).toBe('lk-workspace-grid');
39
+ });
40
+ });
@@ -0,0 +1,20 @@
1
+ import { Children, type CSSProperties, type ReactNode } from 'react';
2
+ import { gridDims } from './gridDims';
3
+
4
+ export interface WorkspaceGridProps {
5
+ children: ReactNode;
6
+ }
7
+
8
+ export function WorkspaceGrid({ children }: WorkspaceGridProps) {
9
+ const count = Children.count(children);
10
+ const { cols, rows } = gridDims(count);
11
+ const style = {
12
+ '--lk-grid-cols': String(cols),
13
+ '--lk-grid-rows': String(rows),
14
+ } as CSSProperties;
15
+ return (
16
+ <div className="lk-workspace-grid" style={style}>
17
+ {children}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { gridDims } from './gridDims';
3
+
4
+ describe('gridDims', () => {
5
+ test('returns 1x1 for 0 or 1', () => {
6
+ expect(gridDims(0)).toEqual({ cols: 1, rows: 1 });
7
+ expect(gridDims(1)).toEqual({ cols: 1, rows: 1 });
8
+ });
9
+
10
+ test('returns 2x1 for 2', () => {
11
+ expect(gridDims(2)).toEqual({ cols: 2, rows: 1 });
12
+ });
13
+
14
+ test('returns 2x2 for 3 or 4', () => {
15
+ expect(gridDims(3)).toEqual({ cols: 2, rows: 2 });
16
+ expect(gridDims(4)).toEqual({ cols: 2, rows: 2 });
17
+ });
18
+
19
+ test('returns 3x2 for 5 or 6', () => {
20
+ expect(gridDims(5)).toEqual({ cols: 3, rows: 2 });
21
+ expect(gridDims(6)).toEqual({ cols: 3, rows: 2 });
22
+ });
23
+
24
+ test('returns 3x3 for 9', () => {
25
+ expect(gridDims(9)).toEqual({ cols: 3, rows: 3 });
26
+ });
27
+
28
+ test('returns 4x4 for 16', () => {
29
+ expect(gridDims(16)).toEqual({ cols: 4, rows: 4 });
30
+ });
31
+
32
+ test('returns 4x4 for 13 (sqrt rounds up to 4)', () => {
33
+ expect(gridDims(13)).toEqual({ cols: 4, rows: 4 });
34
+ });
35
+ });
@@ -0,0 +1,11 @@
1
+ export interface GridDims {
2
+ cols: number;
3
+ rows: number;
4
+ }
5
+
6
+ export function gridDims(count: number): GridDims {
7
+ if (count <= 1) return { cols: 1, rows: 1 };
8
+ const cols = Math.ceil(Math.sqrt(count));
9
+ const rows = Math.ceil(count / cols);
10
+ return { cols, rows };
11
+ }
@@ -0,0 +1,10 @@
1
+ export type { GridDims } from './gridDims';
2
+ export { gridDims } from './gridDims';
3
+ export type { LabProps } from './Lab';
4
+ export { Lab } from './Lab';
5
+ export type { LabContextValue } from './LabContext';
6
+ export { LabContext, useLabContext } from './LabContext';
7
+ export type { LabShellProps, LabTheme } from './LabShell';
8
+ export { LabShell } from './LabShell';
9
+ export type { WorkspaceGridProps } from './WorkspaceGrid';
10
+ export { WorkspaceGrid } from './WorkspaceGrid';
@@ -0,0 +1,65 @@
1
+ # Layers — Agent Guide
2
+
3
+ The `src/layers/` directory implements the `<LayerList>` UI used by the `layers` instrument capability.
4
+
5
+ ## Files
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `LayerList.tsx` | Layer toggle/reorder list (sidebar widget) |
10
+ | `LayerList.less` | Row styling, drag handle, pinned-row variant |
11
+
12
+ ## Props
13
+
14
+ ```ts
15
+ interface LayerListProps {
16
+ layers: LayerDescriptor[]; // { id, label, alwaysOn? }
17
+ visibility: Record<string, boolean>; // missing key → visible
18
+ onReorder: (newOrder: LayerDescriptor[]) => void;
19
+ onToggle: (id: string, visible: boolean) => void;
20
+ className?: string;
21
+ }
22
+ ```
23
+
24
+ `LayerDescriptor` lives in `src/instrument/types.ts`.
25
+
26
+ ## How it integrates
27
+
28
+ The instrument declares layer ids:
29
+
30
+ ```ts
31
+ layers: { ids: ['grid', 'plants'] }
32
+ ```
33
+
34
+ `Workspace.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
35
+
36
+ There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the workspace assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
37
+
38
+ ## `alwaysOn` semantics
39
+
40
+ Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
41
+
42
+ 1. The row renders with a 🔒 badge instead of a drag handle and visibility checkbox.
43
+ 2. The row is excluded from reorder operations — pinned rows always sort to the bottom of the rendered list.
44
+
45
+ This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
46
+
47
+ ## Drag handle customization
48
+
49
+ The drag handle is a button with text content `⋮⋮` and class `lk-layer-list__handle`. Its row height is hardcoded to `28px` for delta computation (`moveDrag` in `LayerList.tsx`). If you change row height in CSS, update the `rowHeight` constant to match.
50
+
51
+ Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dragging works across the document without requiring window-level listeners.
52
+
53
+ ## Empty state
54
+
55
+ If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Workspace already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
56
+
57
+ ## When to fork
58
+
59
+ Fork this component if you need:
60
+ - Multi-select reorder (current implementation is single-row)
61
+ - Right-click context menu
62
+ - Layer groups or nesting
63
+ - Solo/mute UI common in DAW-style apps
64
+
65
+ The implementation is small (~100 lines) and self-contained — only depends on `LayerDescriptor` from instrument types.
@@ -0,0 +1,53 @@
1
+ .lk-layer-list {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 2px;
5
+ padding: var(--lk-spacing-sm, 8px);
6
+ }
7
+
8
+ .lk-layer-list__empty {
9
+ color: var(--lk-color-text-muted, #888);
10
+ font-size: var(--lk-font-size-sm, 12px);
11
+ font-style: italic;
12
+ }
13
+
14
+ .lk-layer-list__row {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--lk-spacing-sm, 8px);
18
+ padding: 4px 6px;
19
+ border-radius: 3px;
20
+ user-select: none;
21
+ }
22
+
23
+ .lk-layer-list__row:hover {
24
+ background: var(--lk-color-hover, rgba(0, 0, 0, 0.04));
25
+ }
26
+
27
+ .lk-layer-list__row--dragging {
28
+ background: var(--lk-color-active, rgba(0, 0, 0, 0.08));
29
+ opacity: 0.7;
30
+ }
31
+
32
+ .lk-layer-list__row--pinned {
33
+ opacity: 0.7;
34
+ }
35
+
36
+ .lk-layer-list__handle {
37
+ cursor: grab;
38
+ color: var(--lk-color-text-muted, #888);
39
+ font-family: monospace;
40
+ }
41
+
42
+ .lk-layer-list__handle:active {
43
+ cursor: grabbing;
44
+ }
45
+
46
+ .lk-layer-list__lock {
47
+ color: var(--lk-color-text-muted, #888);
48
+ }
49
+
50
+ .lk-layer-list__label {
51
+ flex: 1;
52
+ font-size: var(--lk-font-size-sm, 12px);
53
+ }
@@ -0,0 +1,49 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { LayerDescriptor } from '../instrument/types';
4
+ import { LayerList } from './LayerList';
5
+
6
+ describe('<LayerList>', () => {
7
+ it('renders one row per layer', () => {
8
+ const layers: LayerDescriptor[] = [
9
+ { id: 'a', label: 'A' },
10
+ { id: 'b', label: 'B' },
11
+ ];
12
+ const { container } = render(
13
+ <LayerList
14
+ layers={layers}
15
+ visibility={{ a: true, b: true }}
16
+ onReorder={vi.fn()}
17
+ onToggle={vi.fn()}
18
+ />,
19
+ );
20
+ expect(container.querySelectorAll('.lk-layer-list__row')).toHaveLength(2);
21
+ });
22
+
23
+ it('renders empty state when no layers', () => {
24
+ render(<LayerList layers={[]} visibility={{}} onReorder={vi.fn()} onToggle={vi.fn()} />);
25
+ expect(screen.getByText('No layers')).toBeInTheDocument();
26
+ });
27
+
28
+ it('alwaysOn rows have no checkbox and show lock icon', () => {
29
+ const layers: LayerDescriptor[] = [{ id: 'a', label: 'A', alwaysOn: true }];
30
+ render(<LayerList layers={layers} visibility={{}} onReorder={vi.fn()} onToggle={vi.fn()} />);
31
+ expect(screen.queryByLabelText('Toggle A')).toBeNull();
32
+ expect(screen.getByLabelText('Always on')).toBeInTheDocument();
33
+ });
34
+
35
+ it('onToggle fires on checkbox click', () => {
36
+ const onToggle = vi.fn();
37
+ const layers: LayerDescriptor[] = [{ id: 'a', label: 'A' }];
38
+ render(
39
+ <LayerList
40
+ layers={layers}
41
+ visibility={{ a: true }}
42
+ onReorder={vi.fn()}
43
+ onToggle={onToggle}
44
+ />,
45
+ );
46
+ fireEvent.click(screen.getByLabelText('Toggle A'));
47
+ expect(onToggle).toHaveBeenCalledWith('a', false);
48
+ });
49
+ });
@@ -0,0 +1,105 @@
1
+ import { type PointerEvent, useRef, useState } from 'react';
2
+ import type { LayerDescriptor } from '../instrument/types';
3
+
4
+ export interface LayerListProps {
5
+ layers: LayerDescriptor[];
6
+ visibility: Record<string, boolean>;
7
+ onReorder: (newOrder: LayerDescriptor[]) => void;
8
+ onToggle: (id: string, visible: boolean) => void;
9
+ className?: string;
10
+ }
11
+
12
+ interface DragState {
13
+ pointerId: number;
14
+ fromIndex: number;
15
+ startY: number;
16
+ }
17
+
18
+ export function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {
19
+ const reorderable = layers.filter((l) => !l.alwaysOn);
20
+ const pinned = layers.filter((l) => l.alwaysOn);
21
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
22
+ const dragRef = useRef<DragState | null>(null);
23
+
24
+ if (layers.length === 0) {
25
+ return (
26
+ <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
27
+ <div className="lk-layer-list__empty">No layers</div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ const startDrag = (e: PointerEvent<HTMLElement>, index: number) => {
33
+ e.currentTarget.setPointerCapture(e.pointerId);
34
+ dragRef.current = { pointerId: e.pointerId, fromIndex: index, startY: e.clientY };
35
+ setDragIndex(index);
36
+ };
37
+
38
+ const moveDrag = (e: PointerEvent<HTMLElement>) => {
39
+ const drag = dragRef.current;
40
+ if (!drag || drag.pointerId !== e.pointerId) return;
41
+ const rowHeight = 28;
42
+ const delta = Math.round((e.clientY - drag.startY) / rowHeight);
43
+ const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));
44
+ setDragIndex(target);
45
+ };
46
+
47
+ const endDrag = (e: PointerEvent<HTMLElement>) => {
48
+ const drag = dragRef.current;
49
+ if (!drag || drag.pointerId !== e.pointerId) return;
50
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
51
+ e.currentTarget.releasePointerCapture(e.pointerId);
52
+ }
53
+ if (dragIndex !== null && dragIndex !== drag.fromIndex) {
54
+ const next = [...reorderable];
55
+ const [moved] = next.splice(drag.fromIndex, 1);
56
+ if (moved) next.splice(dragIndex, 0, moved);
57
+ onReorder([...next, ...pinned]);
58
+ }
59
+ dragRef.current = null;
60
+ setDragIndex(null);
61
+ };
62
+
63
+ return (
64
+ <div className={className ? `lk-layer-list ${className}` : 'lk-layer-list'}>
65
+ {reorderable.map((layer, i) => {
66
+ const isDragging = dragIndex === i;
67
+ return (
68
+ <div
69
+ key={layer.id}
70
+ className={
71
+ isDragging ? 'lk-layer-list__row lk-layer-list__row--dragging' : 'lk-layer-list__row'
72
+ }
73
+ >
74
+ <button
75
+ type="button"
76
+ className="lk-layer-list__handle"
77
+ aria-label={`Reorder ${layer.label}`}
78
+ onPointerDown={(e) => startDrag(e, i)}
79
+ onPointerMove={moveDrag}
80
+ onPointerUp={endDrag}
81
+ >
82
+ ⋮⋮
83
+ </button>
84
+ <input
85
+ className="lk-layer-list__check"
86
+ type="checkbox"
87
+ checked={visibility[layer.id] !== false}
88
+ onChange={(e) => onToggle(layer.id, e.target.checked)}
89
+ aria-label={`Toggle ${layer.label}`}
90
+ />
91
+ <span className="lk-layer-list__label">{layer.label}</span>
92
+ </div>
93
+ );
94
+ })}
95
+ {pinned.map((layer) => (
96
+ <div key={layer.id} className="lk-layer-list__row lk-layer-list__row--pinned">
97
+ <span className="lk-layer-list__lock" role="img" aria-label="Always on">
98
+ 🔒
99
+ </span>
100
+ <span className="lk-layer-list__label">{layer.label}</span>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,3 @@
1
+ export type { LayerCapability, LayerDescriptor } from '../instrument/types';
2
+ export type { LayerListProps } from './LayerList';
3
+ export { LayerList } from './LayerList';
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildSceneViewCommands,
4
+ computeFitView,
5
+ computeIndicatorCommand,
6
+ FALLBACK_FIT_VIEW,
7
+ MinimapCanvas,
8
+ renderSceneToCanvas,
9
+ SceneViewCanvas,
10
+ } from './weasel-canvas';
11
+
12
+ describe('weasel-canvas passthrough', () => {
13
+ it('re-exports detached-view components', () => {
14
+ for (const C of [SceneViewCanvas, MinimapCanvas]) {
15
+ expect(C).toBeDefined();
16
+ expect(['function', 'object']).toContain(typeof C);
17
+ }
18
+ });
19
+
20
+ it('re-exports scene-render helpers', () => {
21
+ expect(typeof buildSceneViewCommands).toBe('function');
22
+ expect(typeof renderSceneToCanvas).toBe('function');
23
+ });
24
+
25
+ it('re-exports minimap math helpers and the fit-view fallback', () => {
26
+ expect(typeof computeFitView).toBe('function');
27
+ expect(typeof computeIndicatorCommand).toBe('function');
28
+ expect(FALLBACK_FIT_VIEW).toBeDefined();
29
+ });
30
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Re-exports of detached scene-view + minimap primitives from
3
+ * `@weasel-js/core` so consumers reach them through
4
+ * `@weasel-js/labkit/weasel-canvas` rather than depending on the weasel
5
+ * canvas package directly. This passthrough is narrow on purpose —
6
+ * weasel's full surface is engine-sized, and we only widen as labs
7
+ * demand it.
8
+ */
9
+ export {
10
+ buildSceneViewCommands,
11
+ type ComputeFitViewOptions,
12
+ computeFitView,
13
+ computeIndicatorCommand,
14
+ FALLBACK_FIT_VIEW,
15
+ type IndicatorStyle,
16
+ MinimapCanvas,
17
+ type MinimapCanvasProps,
18
+ type MinimapFit,
19
+ renderSceneToCanvas,
20
+ SceneViewCanvas,
21
+ type SceneViewCanvasProps,
22
+ type SceneViewDrawOne,
23
+ } from '@weasel-js/core';
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ Button,
4
+ Checkbox,
5
+ ComboBox,
6
+ CurveEditor,
7
+ chromaAt,
8
+ Dialog,
9
+ Field,
10
+ formatNumber,
11
+ Input,
12
+ KeyCap,
13
+ MINUS_SIGN,
14
+ NumberField,
15
+ oklchToHex,
16
+ Plot2D,
17
+ PointPlotter,
18
+ paintGradientTrack,
19
+ RadioGroup,
20
+ RangeSlider,
21
+ Select,
22
+ Sidebar,
23
+ Slider,
24
+ Switch,
25
+ Tabs,
26
+ ToolPalette,
27
+ useReorderDragList,
28
+ } from './weasel-ui';
29
+
30
+ describe('weasel-ui passthrough', () => {
31
+ it('re-exports formatNumber with MINUS_SIGN convention', () => {
32
+ expect(formatNumber(-3.14)).toBe(`${MINUS_SIGN}3.14`);
33
+ });
34
+
35
+ it('re-exports utility functions', () => {
36
+ expect(typeof useReorderDragList).toBe('function');
37
+ expect(typeof paintGradientTrack).toBe('function');
38
+ expect(typeof oklchToHex).toBe('function');
39
+ expect(typeof chromaAt).toBe('function');
40
+ });
41
+
42
+ it('re-exports form primitives as React components', () => {
43
+ for (const C of [
44
+ Button,
45
+ Checkbox,
46
+ ComboBox,
47
+ Dialog,
48
+ Field,
49
+ Input,
50
+ NumberField,
51
+ RadioGroup,
52
+ RangeSlider,
53
+ Select,
54
+ Slider,
55
+ Switch,
56
+ Tabs,
57
+ ]) {
58
+ expect(C).toBeDefined();
59
+ // React components are either plain functions or forwardRef/memo objects
60
+ expect(['function', 'object']).toContain(typeof C);
61
+ }
62
+ });
63
+
64
+ it('re-exports plot / curve primitives as React components', () => {
65
+ for (const C of [CurveEditor, Plot2D, PointPlotter]) {
66
+ expect(C).toBeDefined();
67
+ // React components are either plain functions or forwardRef/memo objects
68
+ expect(['function', 'object']).toContain(typeof C);
69
+ }
70
+ });
71
+
72
+ it('re-exports app-chrome primitives as React components', () => {
73
+ for (const C of [Sidebar, ToolPalette, KeyCap]) {
74
+ expect(C).toBeDefined();
75
+ // React components are either plain functions or forwardRef/memo objects
76
+ expect(['function', 'object']).toContain(typeof C);
77
+ }
78
+ });
79
+ });