@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,25 @@
1
+ .lk-theme-interstellar {
2
+ --lk-bg: #0a0a14;
3
+ --lk-bg-elevated: rgba(20, 20, 36, 0.55);
4
+ --lk-bg-canvas: #02020a;
5
+ --lk-border: rgba(255, 255, 255, 0.14);
6
+ --lk-divider: rgba(255, 255, 255, 0.08);
7
+ --lk-glass-blur: 12px;
8
+ --lk-text: #e8e8f0;
9
+ --lk-text-muted: #a8a8c0;
10
+ --lk-text-disabled: #5a5a78;
11
+ --lk-accent: #b08adb;
12
+ --lk-accent-hover: #c6a3ee;
13
+ --lk-focus-ring: rgba(176, 138, 219, 0.45);
14
+ --lk-swatch-green: #2fdd18;
15
+ --lk-swatch-pink: #ff5885;
16
+ --lk-swatch-cyan: #00dfff;
17
+ --lk-swatch-gold: #dcb700;
18
+ --lk-swatch-amber: #ff8c00;
19
+ --lk-swatch-violet: #a497ff;
20
+ --lk-swatch-mint: #00e7af;
21
+ --lk-swatch-sky: #00b8ff;
22
+ --lk-swatch-orange: #ff6b00;
23
+ --lk-swatch-magenta: #ff6eff;
24
+ --lk-space-nebula: radial-gradient(ellipse 60% 80% at 18% 30%, rgba(150, 80, 200, 0.22), transparent 70%), radial-gradient(ellipse 55% 90% at 78% 70%, rgba(220, 100, 140, 0.18), transparent 65%), radial-gradient(ellipse 50% 70% at 50% 50%, rgba(60, 110, 200, 0.18), transparent 75%), radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%);
25
+ }
@@ -0,0 +1,13 @@
1
+ .lk-theme-light {
2
+ --lk-bg: #fafaf7;
3
+ --lk-bg-elevated: #ffffff;
4
+ --lk-bg-canvas: #f0ebe2;
5
+ --lk-border: #c9bba5;
6
+ --lk-divider: rgba(0, 0, 0, 0.08);
7
+ --lk-text: #2a2018;
8
+ --lk-text-muted: #6b5c4a;
9
+ --lk-text-disabled: #a89b85;
10
+ --lk-accent: #a86f3c;
11
+ --lk-accent-hover: #c2854f;
12
+ --lk-focus-ring: rgba(168, 111, 60, 0.35);
13
+ }
@@ -0,0 +1,42 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ 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
+ interface LayerStackProps {
25
+ title: string;
26
+ items: LayerStackItem[];
27
+ /** Kinds the user can add via the header palette. */
28
+ paletteKinds: string[];
29
+ onAdd: (kind: string) => void;
30
+ onRemove: (id: number | string) => void;
31
+ onReorder: (orderedIds: Array<number | string>) => void;
32
+ onPrimaryChange: (id: number | string, nextValue: string) => void;
33
+ /** Render the body controls for each item. */
34
+ renderBody: (item: LayerStackItem) => ReactNode;
35
+ /** Hide the title + palette row (used when an outer wrap renders its
36
+ * own head — see speech-balloons Tails panel). */
37
+ hideHead?: boolean;
38
+ }
39
+ declare function LayerStack({ title, items, paletteKinds, onAdd, onRemove, onReorder, onPrimaryChange, renderBody, hideHead, }: LayerStackProps): react_jsx_runtime.JSX.Element;
40
+
41
+ export { LayerStack };
42
+ export type { LayerStackItem, LayerStackProps };
@@ -0,0 +1,5 @@
1
+ export { LayerStack } from '../../chunk-SD3HDXR7.js';
2
+ import '../../chunk-6FO7G5BB.js';
3
+ import '../../chunk-B5ZUYRKN.js';
4
+ //# sourceMappingURL=index.js.map
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,30 @@
1
+ export { U as UndoCapability } from '../_dts/index-C6Yze7sQ.js';
2
+ import 'react';
3
+ import '../_dts/types-Si4Fw-1F.js';
4
+
5
+ type EventListener = () => void;
6
+ interface EventBus {
7
+ on(event: string, listener: EventListener): () => void;
8
+ emit(event: string): void;
9
+ clear(): void;
10
+ }
11
+ declare function createEventBus(): EventBus;
12
+
13
+ interface UndoStack {
14
+ past: unknown[];
15
+ future: unknown[];
16
+ }
17
+ declare function emptyStack(): UndoStack;
18
+ declare function pushSnapshot(stack: UndoStack, snapshot: unknown, maxDepth: number): UndoStack;
19
+ declare function undo(stack: UndoStack, current: unknown): {
20
+ stack: UndoStack;
21
+ snapshot: unknown;
22
+ } | null;
23
+ declare function redo(stack: UndoStack, current: unknown): {
24
+ stack: UndoStack;
25
+ snapshot: unknown;
26
+ } | null;
27
+ declare function clearUndo(_stack: UndoStack): UndoStack;
28
+
29
+ export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo };
30
+ export type { EventBus, EventListener, UndoStack };
@@ -0,0 +1,3 @@
1
+ export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from '../chunk-7BKDG73Z.js';
2
+ //# sourceMappingURL=index.js.map
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@weasel-js/labkit",
3
+ "version": "0.1.0",
4
+ "description": "React widgets for building self-contained interactive lab pages",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./primitives": {
17
+ "types": "./dist/primitives/index.d.ts",
18
+ "import": "./dist/primitives/index.js"
19
+ },
20
+ "./controls": {
21
+ "types": "./dist/controls/index.d.ts",
22
+ "import": "./dist/controls/index.js"
23
+ },
24
+ "./canvas": {
25
+ "types": "./dist/canvas/index.d.ts",
26
+ "import": "./dist/canvas/index.js"
27
+ },
28
+ "./layers": {
29
+ "types": "./dist/layers/index.d.ts",
30
+ "import": "./dist/layers/index.js"
31
+ },
32
+ "./undo": {
33
+ "types": "./dist/undo/index.d.ts",
34
+ "import": "./dist/undo/index.js"
35
+ },
36
+ "./dragdrop": {
37
+ "types": "./dist/dragdrop/index.d.ts",
38
+ "import": "./dist/dragdrop/index.js"
39
+ },
40
+ "./state": {
41
+ "types": "./dist/state/index.d.ts",
42
+ "import": "./dist/state/index.js"
43
+ },
44
+ "./weasel-ui": {
45
+ "types": "./dist/passthrough/weasel-ui.d.ts",
46
+ "import": "./dist/passthrough/weasel-ui.js"
47
+ },
48
+ "./weasel-canvas": {
49
+ "types": "./dist/passthrough/weasel-canvas.d.ts",
50
+ "import": "./dist/passthrough/weasel-canvas.js"
51
+ },
52
+ "./ui/layers": {
53
+ "types": "./dist/ui/layers/index.d.ts",
54
+ "import": "./dist/ui/layers/index.js"
55
+ },
56
+ "./styles.css": "./dist/styles.css",
57
+ "./theme-light.css": "./dist/theme-light.css",
58
+ "./theme-interstellar.css": "./dist/theme-interstellar.css"
59
+ },
60
+ "peerDependencies": {
61
+ "react": "^19.0.0",
62
+ "react-dom": "^19.0.0"
63
+ },
64
+ "dependencies": {
65
+ "earcut": "2.2.4",
66
+ "polygon-clipping": "^0.15.7",
67
+ "react-aria-components": "^1.5.0",
68
+ "zustand": "^5.0.12"
69
+ },
70
+ "devDependencies": {
71
+ "@biomejs/biome": "^2.4.12",
72
+ "@rollup/plugin-alias": "^6.0.0",
73
+ "@testing-library/jest-dom": "^6.6.0",
74
+ "@weasel-js/modes": "*",
75
+ "@weasel-js/ui": "*",
76
+ "less": "^4.2.0",
77
+ "rollup-plugin-dts": "^6.4.1",
78
+ "tsx": "^4.19.0",
79
+ "vitepress": "^1.6.4"
80
+ },
81
+ "scripts": {
82
+ "dev": "vite",
83
+ "dev:drag": "LABKIT_EXAMPLE=drag-lab vite",
84
+ "dev:weasel": "LABKIT_EXAMPLE=weasel-lab vite",
85
+ "build": "tsup && npm run build:dts && npm run build:css && npm run build:fonts",
86
+ "build:dts": "tsx scripts/build-dts.mts",
87
+ "build:css": "lessc src/styles.less dist/styles.css && lessc src/theme/light.less dist/theme-light.css && lessc src/theme/interstellar.less dist/theme-interstellar.css",
88
+ "build:fonts": "mkdir -p dist/fonts && cp src/fonts/*.woff2 dist/fonts/",
89
+ "test": "vitest run",
90
+ "test:watch": "vitest",
91
+ "test:smoke:consumer": "node scripts/smoke-consumer-bundle.mjs",
92
+ "lint": "biome check . && tsx scripts/check-class-prefix.ts",
93
+ "lint:fix": "biome check --write .",
94
+ "format": "biome format --write .",
95
+ "docs:dev": "vitepress dev docs",
96
+ "docs:build": "vitepress build docs",
97
+ "docs:preview": "vitepress preview docs"
98
+ }
99
+ }
@@ -0,0 +1,72 @@
1
+ # Canvas — Agent Guide
2
+
3
+ The `src/canvas/` directory implements the layered canvas primitive used by the `canvas` instrument capability.
4
+
5
+ ## Files
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `CanvasStack.tsx` | Layered `<canvas>` container; owns sizing, pan/zoom handlers, and per-layer canvas refs |
10
+ | `CanvasStackContext.ts` | React context exposing the current `view` to descendants |
11
+ | `useLayerScheduler.ts` | DPR-aware rAF scheduler; redraws dirty layers on view/state changes |
12
+ | `usePanZoom.ts` | Pointer + wheel handlers that mutate `view` via `onViewChange` |
13
+ | `canvasCoords.ts` | Pure `screenToWorld` / `worldToScreen` helpers |
14
+ | `CanvasStack.less` | Container + canvas + overlay positioning |
15
+
16
+ ## Props (`<CanvasStack>`)
17
+
18
+ ```ts
19
+ interface CanvasStackProps {
20
+ layers: CanvasLayerDescriptor[]; // { id, visible, render(ctx, view) }
21
+ view: ViewTransform; // { zoom, pan: { x, y } }
22
+ onViewChange: (v: ViewTransform) => void;
23
+ width?: number | string; // default '100%'
24
+ height?: number | string; // default '100%'
25
+ className?: string;
26
+ onHitTest?: (worldPos: Point) => void; // fired on tap (no drag)
27
+ children?: ReactNode; // rendered into the overlay layer
28
+ }
29
+ ```
30
+
31
+ ## How rendering works
32
+
33
+ 1. The container measures itself with `ResizeObserver` and reports `{width, height, dpr}`.
34
+ 2. `useLayerScheduler` watches `layers`, `view`, and `size`, and on any change schedules a single `requestAnimationFrame` redraw.
35
+ 3. Each layer's `render(ctx, view)` is called with a fresh transform: the canvas is sized to `width * dpr × height * dpr` so coordinates are in CSS pixels.
36
+ 4. Layers with `visible: false` are skipped and their canvas is `display: none` (DOM is preserved to avoid remounts).
37
+
38
+ ## Adding a new layer type
39
+
40
+ A layer is defined by the instrument's `canvas.layers[]` (type `CanvasLayer`), then translated to a `CanvasLayerDescriptor` inside `Workspace.tsx` before being passed to `<CanvasStack>`. To add a layer, push it into `instrument.canvas.layers`:
41
+
42
+ ```ts
43
+ canvas: {
44
+ layers: [
45
+ {
46
+ id: 'grid',
47
+ draw: (ctx, { state, config, zoom }) => { /* ... */ },
48
+ },
49
+ ],
50
+ }
51
+ ```
52
+
53
+ Layer order in the array = paint order (first drawn = bottom).
54
+
55
+ ## Overlay (children)
56
+
57
+ The `children` prop renders in `.lk-canvas-stack__overlay`, which is `position: absolute; inset: 0; pointer-events: none`. Direct children re-enable pointer events. Use this for HUDs, scale indicators, or interactive overlays that don't belong in a canvas layer.
58
+
59
+ ## Coordinate conversions
60
+
61
+ - `screenToWorld({x, y}, view)` → world coords given a CSS-pixel offset relative to the container's top-left
62
+ - `worldToScreen({x, y}, view)` → inverse
63
+
64
+ Both are pure. Pan is applied in screen space; zoom multiplies world coordinates.
65
+
66
+ ## Pan/zoom behavior
67
+
68
+ Implemented in `usePanZoom`. Mouse-wheel zoom is anchored at the cursor; primary-button drag pans. `isDragging()` is exposed so consumers can suppress click handlers during a pan.
69
+
70
+ ## Testing notes
71
+
72
+ JSDOM does not provide a real `CanvasRenderingContext2D`. Tests in `CanvasStack.test.tsx` rely on the scheduler's defensive checks; they do not assert pixels. For pixel testing, use Storybook's `chromatic` or visual snapshots in a real browser.
@@ -0,0 +1,22 @@
1
+ .lk-canvas-stack {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--lk-color-canvas-bg, #f6f6f6);
5
+ touch-action: none;
6
+ }
7
+
8
+ .lk-canvas-stack__canvas {
9
+ position: absolute;
10
+ top: 0;
11
+ left: 0;
12
+ }
13
+
14
+ .lk-canvas-stack__overlay {
15
+ position: absolute;
16
+ inset: 0;
17
+ pointer-events: none;
18
+ }
19
+
20
+ .lk-canvas-stack__overlay > * {
21
+ pointer-events: auto;
22
+ }
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { ScaleIndicator } from '../primitives/ScaleIndicator';
4
+ import { CanvasStack } from './CanvasStack';
5
+ import type { CanvasLayerDescriptor } from './useLayerScheduler';
6
+
7
+ const meta: Meta<typeof CanvasStack> = {
8
+ title: 'labkit/Canvas/CanvasStack',
9
+ component: CanvasStack,
10
+ parameters: { layout: 'fullscreen' },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof CanvasStack>;
15
+
16
+ function makeColoredLayer(id: string, color: string, x: number, y: number): CanvasLayerDescriptor {
17
+ return {
18
+ id,
19
+ visible: true,
20
+ render: (ctx, view) => {
21
+ ctx.fillStyle = color;
22
+ ctx.fillRect(
23
+ x * view.zoom + view.pan.x,
24
+ y * view.zoom + view.pan.y,
25
+ 80 * view.zoom,
26
+ 80 * view.zoom,
27
+ );
28
+ },
29
+ };
30
+ }
31
+
32
+ function Harness({ layers }: { layers: CanvasLayerDescriptor[] }) {
33
+ const [view, setView] = useState({ zoom: 1, pan: { x: 50, y: 50 } });
34
+ return (
35
+ <div style={{ width: '100vw', height: '100vh' }}>
36
+ <CanvasStack layers={layers} view={view} onViewChange={setView}>
37
+ <div style={{ position: 'absolute', top: 8, right: 8 }}>
38
+ <ScaleIndicator />
39
+ </div>
40
+ </CanvasStack>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export const Default: Story = {
46
+ render: () => <Harness layers={[makeColoredLayer('a', '#3a86ff', 0, 0)]} />,
47
+ };
48
+
49
+ export const MultiLayer: Story = {
50
+ render: () => (
51
+ <Harness
52
+ layers={[
53
+ makeColoredLayer('a', '#3a86ff', 0, 0),
54
+ makeColoredLayer('b', '#ff006e', 60, 60),
55
+ makeColoredLayer('c', '#ffbe0b', 120, 120),
56
+ ]}
57
+ />
58
+ ),
59
+ };
60
+
61
+ export const WithScaleIndicator: Story = {
62
+ render: () => <Harness layers={[makeColoredLayer('a', '#06d6a0', 0, 0)]} />,
63
+ };
@@ -0,0 +1,82 @@
1
+ import { render } from '@testing-library/react';
2
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { CanvasStack } from './CanvasStack';
4
+ import type { CanvasLayerDescriptor } from './useLayerScheduler';
5
+
6
+ beforeAll(() => {
7
+ // jsdom's <canvas>.getContext returns null by default; stub a minimal 2D context.
8
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
9
+ save: vi.fn(),
10
+ restore: vi.fn(),
11
+ setTransform: vi.fn(),
12
+ clearRect: vi.fn(),
13
+ fillRect: vi.fn(),
14
+ beginPath: vi.fn(),
15
+ arc: vi.fn(),
16
+ fill: vi.fn(),
17
+ stroke: vi.fn(),
18
+ })) as unknown as HTMLCanvasElement['getContext'];
19
+ });
20
+
21
+ function makeLayers(count: number): CanvasLayerDescriptor[] {
22
+ return Array.from({ length: count }, (_, i) => ({
23
+ id: `layer-${i}`,
24
+ visible: true,
25
+ render: vi.fn(),
26
+ }));
27
+ }
28
+
29
+ describe('<CanvasStack>', () => {
30
+ it('renders one canvas per layer', () => {
31
+ const { container } = render(
32
+ <CanvasStack
33
+ layers={makeLayers(3)}
34
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
35
+ onViewChange={vi.fn()}
36
+ />,
37
+ );
38
+ expect(container.querySelectorAll('canvas')).toHaveLength(3);
39
+ });
40
+
41
+ it('calls render functions for visible layers', async () => {
42
+ const layers = makeLayers(2);
43
+ render(
44
+ <CanvasStack
45
+ layers={layers}
46
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
47
+ onViewChange={vi.fn()}
48
+ />,
49
+ );
50
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
51
+ expect(layers[0]?.render).toHaveBeenCalled();
52
+ expect(layers[1]?.render).toHaveBeenCalled();
53
+ });
54
+
55
+ it('does not call render for invisible layers', async () => {
56
+ const layers = makeLayers(2);
57
+ if (layers[1]) layers[1].visible = false;
58
+ render(
59
+ <CanvasStack
60
+ layers={layers}
61
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
62
+ onViewChange={vi.fn()}
63
+ />,
64
+ );
65
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
66
+ expect(layers[0]?.render).toHaveBeenCalled();
67
+ expect(layers[1]?.render).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('renders overlay children', () => {
71
+ const { getByText } = render(
72
+ <CanvasStack
73
+ layers={makeLayers(1)}
74
+ view={{ zoom: 1, pan: { x: 0, y: 0 } }}
75
+ onViewChange={vi.fn()}
76
+ >
77
+ <span>overlay-content</span>
78
+ </CanvasStack>,
79
+ );
80
+ expect(getByText('overlay-content')).toBeInTheDocument();
81
+ });
82
+ });
@@ -0,0 +1,108 @@
1
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { Point, ViewTransform } from '../instrument/types';
3
+ import { CanvasStackContext } from './CanvasStackContext';
4
+ import { screenToWorld } from './canvasCoords';
5
+ import { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';
6
+ import { usePanZoom } from './usePanZoom';
7
+
8
+ export interface CanvasStackProps {
9
+ layers: CanvasLayerDescriptor[];
10
+ view: ViewTransform;
11
+ onViewChange: (v: ViewTransform) => void;
12
+ width?: number | string;
13
+ height?: number | string;
14
+ className?: string;
15
+ onHitTest?: (worldPos: Point) => void;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ export function CanvasStack({
20
+ layers,
21
+ view,
22
+ onViewChange,
23
+ width = '100%',
24
+ height = '100%',
25
+ className,
26
+ onHitTest,
27
+ children,
28
+ }: CanvasStackProps) {
29
+ const containerRef = useRef<HTMLDivElement | null>(null);
30
+ const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());
31
+ const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });
32
+
33
+ const setCanvasRef = (id: string, el: HTMLCanvasElement | null): void => {
34
+ if (el) canvasMap.current.set(id, el);
35
+ else canvasMap.current.delete(id);
36
+ };
37
+
38
+ useEffect(() => {
39
+ const el = containerRef.current;
40
+ if (!el) return;
41
+ const update = () => {
42
+ const rect = el.getBoundingClientRect();
43
+ const dpr = window.devicePixelRatio ?? 1;
44
+ setSize((prev) => {
45
+ if (prev.width === rect.width && prev.height === rect.height && prev.dpr === dpr) {
46
+ return prev;
47
+ }
48
+ return { width: rect.width, height: rect.height, dpr };
49
+ });
50
+ };
51
+ update();
52
+ if (typeof ResizeObserver === 'undefined') return;
53
+ const ro = new ResizeObserver(update);
54
+ ro.observe(el);
55
+ return () => ro.disconnect();
56
+ }, []);
57
+
58
+ const handlers = usePanZoom({ view, onViewChange });
59
+ useLayerScheduler({ layers, view, canvasRefs: canvasMap, size });
60
+
61
+ const ctxValue = useMemo(() => ({ view }), [view]);
62
+
63
+ const containerStyle: CSSProperties = { width, height };
64
+ const canvasPx = {
65
+ width: Math.max(0, Math.round(size.width * size.dpr)),
66
+ height: Math.max(0, Math.round(size.height * size.dpr)),
67
+ };
68
+ const canvasCss: CSSProperties = {
69
+ width: size.width,
70
+ height: size.height,
71
+ };
72
+
73
+ const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
74
+ const wasDragging = handlers.isDragging();
75
+ handlers.onPointerUp(e);
76
+ if (!wasDragging && onHitTest && containerRef.current) {
77
+ const rect = containerRef.current.getBoundingClientRect();
78
+ const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
79
+ onHitTest(screenToWorld(screen, view));
80
+ }
81
+ };
82
+
83
+ return (
84
+ <CanvasStackContext.Provider value={ctxValue}>
85
+ <div
86
+ ref={containerRef}
87
+ className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}
88
+ style={containerStyle}
89
+ onWheel={handlers.onWheel}
90
+ onPointerDown={handlers.onPointerDown}
91
+ onPointerMove={handlers.onPointerMove}
92
+ onPointerUp={handlePointerUp}
93
+ >
94
+ {layers.map((layer) => (
95
+ <canvas
96
+ key={layer.id}
97
+ ref={(el) => setCanvasRef(layer.id, el)}
98
+ className="lk-canvas-stack__canvas"
99
+ width={canvasPx.width}
100
+ height={canvasPx.height}
101
+ style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}
102
+ />
103
+ ))}
104
+ <div className="lk-canvas-stack__overlay">{children}</div>
105
+ </div>
106
+ </CanvasStackContext.Provider>
107
+ );
108
+ }
@@ -0,0 +1,8 @@
1
+ import { createContext } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+
4
+ export interface CanvasStackContextValue {
5
+ view: ViewTransform;
6
+ }
7
+
8
+ export const CanvasStackContext = createContext<CanvasStackContextValue | null>(null);
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { screenToWorld, worldToScreen } from './canvasCoords';
3
+
4
+ describe('canvasCoords', () => {
5
+ it('identity: zoom=1 pan=0 leaves coords unchanged', () => {
6
+ const v = { zoom: 1, pan: { x: 0, y: 0 } };
7
+ expect(worldToScreen({ x: 7, y: 9 }, v)).toEqual({ x: 7, y: 9 });
8
+ expect(screenToWorld({ x: 7, y: 9 }, v)).toEqual({ x: 7, y: 9 });
9
+ });
10
+
11
+ it('zoom only scales coords', () => {
12
+ const v = { zoom: 2, pan: { x: 0, y: 0 } };
13
+ expect(worldToScreen({ x: 3, y: 4 }, v)).toEqual({ x: 6, y: 8 });
14
+ expect(screenToWorld({ x: 6, y: 8 }, v)).toEqual({ x: 3, y: 4 });
15
+ });
16
+
17
+ it('pan only translates coords', () => {
18
+ const v = { zoom: 1, pan: { x: 10, y: 20 } };
19
+ expect(worldToScreen({ x: 1, y: 2 }, v)).toEqual({ x: 11, y: 22 });
20
+ expect(screenToWorld({ x: 11, y: 22 }, v)).toEqual({ x: 1, y: 2 });
21
+ });
22
+
23
+ it('round-trip is identity', () => {
24
+ const v = { zoom: 1.5, pan: { x: 7, y: -3 } };
25
+ const p = { x: 12.34, y: 56.78 };
26
+ const back = screenToWorld(worldToScreen(p, v), v);
27
+ expect(back.x).toBeCloseTo(p.x, 10);
28
+ expect(back.y).toBeCloseTo(p.y, 10);
29
+ });
30
+ });
@@ -0,0 +1,15 @@
1
+ import type { Point, ViewTransform } from '../instrument/types';
2
+
3
+ export function worldToScreen(world: Point, view: ViewTransform): Point {
4
+ return {
5
+ x: world.x * view.zoom + view.pan.x,
6
+ y: world.y * view.zoom + view.pan.y,
7
+ };
8
+ }
9
+
10
+ export function screenToWorld(screen: Point, view: ViewTransform): Point {
11
+ return {
12
+ x: (screen.x - view.pan.x) / view.zoom,
13
+ y: (screen.y - view.pan.y) / view.zoom,
14
+ };
15
+ }
@@ -0,0 +1,6 @@
1
+ export type { CanvasStackProps } from './CanvasStack';
2
+ export { CanvasStack } from './CanvasStack';
3
+ export type { CanvasStackContextValue } from './CanvasStackContext';
4
+ export { CanvasStackContext } from './CanvasStackContext';
5
+ export { screenToWorld, worldToScreen } from './canvasCoords';
6
+ export type { CanvasLayerDescriptor } from './useLayerScheduler';