@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -0,0 +1,76 @@
1
+ import { NumberField, Slider } from '../passthrough/weasel-ui';
2
+
3
+ /** Props for `<ZoomControl>`. */
4
+ export interface ZoomControlProps {
5
+ /** Current zoom as a scale factor: 1 is actual size. */
6
+ zoom: number;
7
+ /** Fired continuously while the slider drags. */
8
+ onZoomChange: (zoom: number) => void;
9
+ /** Fired when a drag settles or the field is committed. Defaults to
10
+ * `onZoomChange`. */
11
+ onZoomCommit?: (zoom: number) => void;
12
+ /** Smallest selectable zoom. Defaults to 0.1 (10%). */
13
+ min?: number;
14
+ /** Largest selectable zoom. Defaults to 8 (800%). */
15
+ max?: number;
16
+ className?: string;
17
+ }
18
+
19
+ // Zoom is perceptually geometric — halving is as big a step as doubling — so
20
+ // the slider runs on log2 and 100% lands mid-track for a symmetric range.
21
+ const toTrack = (zoom: number): number => Math.log2(zoom);
22
+ const fromTrack = (v: number): number => 2 ** v;
23
+
24
+ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
25
+
26
+ /** Zoom as a log-scaled slider with an editable percentage beside it. Pair it
27
+ * with zoom-in / zoom-out buttons in a `<Toolbar.Group>`; this component owns
28
+ * only the continuous part. */
29
+ export function ZoomControl({
30
+ zoom,
31
+ onZoomChange,
32
+ onZoomCommit,
33
+ min = 0.1,
34
+ max = 8,
35
+ className,
36
+ }: ZoomControlProps) {
37
+ const commit = onZoomCommit ?? onZoomChange;
38
+ const emit = (track: number, done: boolean): void => {
39
+ const next = clamp(fromTrack(track), min, max);
40
+ (done ? commit : onZoomChange)(next);
41
+ };
42
+
43
+ return (
44
+ <div className={`lk-zoom${className ? ` ${className}` : ''}`}>
45
+ <Slider
46
+ className="lk-zoom__slider"
47
+ ariaLabel="Zoom"
48
+ min={toTrack(min)}
49
+ max={toTrack(max)}
50
+ // Detents at the octaves plus actual size, so 25/50/100/200/400% are
51
+ // reachable by drag rather than only by typing.
52
+ stops={[-2, -1, 0, 1, 2]}
53
+ thumbs={[{ value: clamp(toTrack(zoom), toTrack(min), toTrack(max)) }]}
54
+ onInput={(next) => emit(next[0].value, false)}
55
+ onChange={(next) => emit(next[0].value, true)}
56
+ readoutPlacement="none"
57
+ />
58
+ {/* `style: 'percent'` formats a fraction, so the field carries the scale
59
+ factor itself and renders 0.5 as "50%". */}
60
+ <NumberField
61
+ className="lk-zoom__field"
62
+ aria-label="Zoom"
63
+ value={zoom}
64
+ minValue={min}
65
+ maxValue={max}
66
+ step={0.01}
67
+ hideSteppers
68
+ formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
69
+ onChange={(next) => {
70
+ if (!Number.isFinite(next)) return;
71
+ commit(clamp(next, min, max));
72
+ }}
73
+ />
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,14 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { FloatingPanel, Legend } from '../index';
4
+
5
+ describe('floating public entry', () => {
6
+ it('reaches a consumer from the package entry point alone', () => {
7
+ const { container } = render(
8
+ <FloatingPanel>
9
+ <Legend entries={[{ key: 'a', label: 'a', color: '#fff' }]} />
10
+ </FloatingPanel>,
11
+ );
12
+ expect(container.querySelector('.lk-floating-panel .lk-legend')).toBeInTheDocument();
13
+ });
14
+ });
@@ -1,9 +1,17 @@
1
+ export type { FloatingPanelProps } from './FloatingPanel';
2
+ export { FloatingPanel } from './FloatingPanel';
1
3
  export { FpsMeter } from './FpsMeter';
4
+ export type { JobProgressProps } from './JobProgress';
5
+ export { JobProgress } from './JobProgress';
6
+ export type { LegendEntry, LegendMark, LegendProps } from './Legend';
7
+ export { Legend } from './Legend';
2
8
  export type { ScaleIndicatorProps } from './ScaleIndicator';
3
9
  export { ScaleIndicator } from './ScaleIndicator';
4
10
  export type { SidebarProps } from './Sidebar';
5
11
  export { Sidebar } from './Sidebar';
6
- export type { StatusBarProps } from './StatusBar';
12
+ export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
7
13
  export { StatusBar } from './StatusBar';
8
- export type { ToolbarProps } from './Toolbar';
14
+ export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
9
15
  export { Toolbar } from './Toolbar';
16
+ export type { ZoomControlProps } from './ZoomControl';
17
+ export { ZoomControl } from './ZoomControl';
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { nextIndex } from './useRovingTabIndex';
3
+
4
+ describe('nextIndex', () => {
5
+ it('steps forward and wraps', () => {
6
+ expect(nextIndex(0, 'ArrowRight', 3)).toBe(1);
7
+ expect(nextIndex(2, 'ArrowRight', 3)).toBe(0);
8
+ });
9
+
10
+ it('steps back and wraps', () => {
11
+ expect(nextIndex(1, 'ArrowLeft', 3)).toBe(0);
12
+ expect(nextIndex(0, 'ArrowLeft', 3)).toBe(2);
13
+ });
14
+
15
+ it('jumps to the ends', () => {
16
+ expect(nextIndex(1, 'Home', 3)).toBe(0);
17
+ expect(nextIndex(1, 'End', 3)).toBe(2);
18
+ });
19
+
20
+ it('returns null for a key it does not handle', () => {
21
+ expect(nextIndex(1, 'Enter', 3)).toBeNull();
22
+ });
23
+
24
+ it('returns null when there is nothing to move to', () => {
25
+ expect(nextIndex(0, 'ArrowRight', 0)).toBeNull();
26
+ });
27
+ });
@@ -0,0 +1,79 @@
1
+ import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';
2
+
3
+ /** Where a key takes focus within `count` items, or null if it does not move it. */
4
+ export function nextIndex(current: number, key: string, count: number): number | null {
5
+ if (count === 0) return null;
6
+ switch (key) {
7
+ case 'ArrowRight':
8
+ return (current + 1) % count;
9
+ case 'ArrowLeft':
10
+ return (current - 1 + count) % count;
11
+ case 'Home':
12
+ return 0;
13
+ case 'End':
14
+ return count - 1;
15
+ default:
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The APG roving-tabindex contract: exactly one item in the tab order, arrows
22
+ * moving focus within. `tabIndex` is written onto the DOM nodes rather than
23
+ * threaded through props, because the items are arbitrary children — the
24
+ * container never sees them as a list it could index.
25
+ */
26
+ export function useRovingTabIndex<T extends HTMLElement>() {
27
+ const ref = useRef<T | null>(null);
28
+ const stop = useRef(0);
29
+
30
+ const items = useCallback(
31
+ (): HTMLElement[] =>
32
+ ref.current
33
+ ? Array.from(
34
+ ref.current.querySelectorAll<HTMLElement>('button:not([disabled]), [role="button"]'),
35
+ )
36
+ : [],
37
+ [],
38
+ );
39
+
40
+ const setTabStop = useCallback(
41
+ (index: number) => {
42
+ const found = items();
43
+ const at = Math.min(Math.max(index, 0), Math.max(found.length - 1, 0));
44
+ stop.current = at;
45
+ for (const [i, el] of found.entries()) el.tabIndex = i === at ? 0 : -1;
46
+ },
47
+ [items],
48
+ );
49
+
50
+ // Re-establish the tab stop whenever the item set changes — a trial's toolbar
51
+ // contributions are dynamic, and `disabled` moves a button in and out of the
52
+ // set without touching the child list. The focused item keeps the stop.
53
+ useEffect(() => {
54
+ const resync = () => {
55
+ const focused = items().indexOf(document.activeElement as HTMLElement);
56
+ setTabStop(focused === -1 ? stop.current : focused);
57
+ };
58
+ resync();
59
+ if (!ref.current) return;
60
+ const mo = new MutationObserver(resync);
61
+ mo.observe(ref.current, { childList: true, subtree: true, attributeFilter: ['disabled'] });
62
+ return () => mo.disconnect();
63
+ }, [items, setTabStop]);
64
+
65
+ const onKeyDown = useCallback(
66
+ (e: KeyboardEvent<T>) => {
67
+ const found = items();
68
+ const current = found.indexOf(document.activeElement as HTMLElement);
69
+ const next = nextIndex(current === -1 ? 0 : current, e.key, found.length);
70
+ if (next === null) return;
71
+ e.preventDefault();
72
+ found[next]?.focus();
73
+ setTabStop(next);
74
+ },
75
+ [items, setTabStop],
76
+ );
77
+
78
+ return { ref, onKeyDown };
79
+ }
@@ -28,7 +28,7 @@ export interface LabStoreActions {
28
28
  removeTrial: (id: string) => void;
29
29
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
30
30
  updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
31
- updateTrialView: (id: string, view: TrialRecord['view']) => void;
31
+ updateTrialView: (id: string, view: unknown) => void;
32
32
  updateTrialUndoStack: (
33
33
  id: string,
34
34
  next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
@@ -39,6 +39,8 @@ export interface LabStoreActions {
39
39
  deleteSnapshot: (snapshotId: string) => void;
40
40
  listSnapshots: (trialId?: string) => SavedSnapshot[];
41
41
  setMode: (mode: LabMode) => void;
42
+ setLabTool: (id: string | null) => void;
43
+ setTrialTool: (trialId: string, id: string | null) => void;
42
44
  setLayout: (layout: Record<string, unknown>) => void;
43
45
  }
44
46
 
@@ -70,6 +72,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
70
72
  savedSnapshots: hydratedSnapshots,
71
73
  mode: hydratedMode,
72
74
  layout: hydratedLayout,
75
+ activeToolId: null,
73
76
 
74
77
  addTrial: (record) => {
75
78
  set((s) => ({
@@ -89,6 +92,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
89
92
  if (w.id !== id) return w;
90
93
  const nextState =
91
94
  typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
95
+ // An updater that returns its input means "nothing changed", and must not
96
+ // cost a new record: the trial re-renders on record identity, so
97
+ // allocating here turns the standard React bail-out into a render loop.
98
+ if (Object.is(nextState, w.state)) return w;
92
99
  return { ...w, state: nextState };
93
100
  }),
94
101
  }));
@@ -110,7 +117,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
110
117
 
111
118
  updateTrialView: (id, view) => {
112
119
  set((s) => ({
113
- trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
120
+ trials: s.trials.map((w) => (w.id === id && !Object.is(view, w.view) ? { ...w, view } : w)),
114
121
  }));
115
122
  scheduleFlush();
116
123
  },
@@ -197,6 +204,13 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
197
204
  scheduleFlush();
198
205
  },
199
206
 
207
+ setLabTool: (id) => set({ activeToolId: id }),
208
+
209
+ setTrialTool: (trialId, id) =>
210
+ set((s) => ({
211
+ trials: s.trials.map((t) => (t.id === trialId ? { ...t, activeToolId: id } : t)),
212
+ })),
213
+
200
214
  setLayout: (layout) => {
201
215
  set({ layout });
202
216
  scheduleFlush();
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createLabStore } from './store';
3
+
4
+ function store() {
5
+ return createLabStore({ storageKey: 'test', storage: { read: () => null, write: () => {} } });
6
+ }
7
+
8
+ const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
9
+
10
+ describe('the tool slot', () => {
11
+ it('starts empty at both levels', () => {
12
+ const s = store();
13
+ expect(s.getState().activeToolId).toBeNull();
14
+ });
15
+
16
+ it('sets the lab slot', () => {
17
+ const s = store();
18
+ s.getState().setLabTool('brush');
19
+ expect(s.getState().activeToolId).toBe('brush');
20
+ });
21
+
22
+ it('sets a trial slot without touching the lab one', () => {
23
+ const s = store();
24
+ s.getState().addTrial(record);
25
+ s.getState().setLabTool('brush');
26
+ s.getState().setTrialTool('t1', 'eraser');
27
+ expect(s.getState().activeToolId).toBe('brush');
28
+ expect(s.getState().trials[0].activeToolId).toBe('eraser');
29
+ });
30
+
31
+ it('leaves a trial slot undefined until it is set', () => {
32
+ const s = store();
33
+ s.getState().addTrial(record);
34
+ expect(s.getState().trials[0].activeToolId).toBeUndefined();
35
+ });
36
+ });
@@ -7,12 +7,16 @@ export interface UndoStack {
7
7
 
8
8
  /** One trial as the store holds it: which instrument it runs, that
9
9
  * instrument's config and state, the camera, and the undo history. */
10
- export interface TrialRecord<TS = unknown, TC = unknown> {
10
+ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
11
11
  id: string;
12
12
  instrumentName: string;
13
13
  config: TC;
14
14
  state: TS;
15
- view: { zoom: number; pan: { x: number; y: number } };
15
+ /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
16
+ * but never read into. A 3D lab puts an orbit here and keeps all three. */
17
+ view: TV;
18
+ /** This trial's own tool slot. Undefined means it reads the lab's. */
19
+ activeToolId?: string | null;
16
20
  undoStack: UndoStack;
17
21
  }
18
22
 
@@ -37,6 +41,8 @@ export interface LabStoreState {
37
41
  trials: TrialRecord[];
38
42
  savedSnapshots: SavedSnapshot[];
39
43
  mode: LabMode;
44
+ /** The lab's tool slot — what a trial with no slot of its own resolves to. */
45
+ activeToolId: string | null;
40
46
  /** Per-trial tile extents, keyed by trial id. Opaque here — the
41
47
  * shape belongs to whatever lays the trials out. */
42
48
  layout: Record<string, unknown>;
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createMemoryAdapter } from './adapters';
3
+ import { createLabStore } from './store';
4
+ import { as2DView, DEFAULT_VIEW } from './view';
5
+
6
+ interface OrbitView {
7
+ yaw: number;
8
+ pitch: number;
9
+ distance: number;
10
+ }
11
+
12
+ const orbit: OrbitView = { yaw: 1.1, pitch: 0.3, distance: 9 };
13
+
14
+ describe('as2DView', () => {
15
+ it('accepts the 2D shape', () => {
16
+ expect(as2DView({ zoom: 2, pan: { x: 1, y: 3 } })).toEqual({ zoom: 2, pan: { x: 1, y: 3 } });
17
+ });
18
+
19
+ it('copies rather than aliasing, so a caller cannot mutate the record', () => {
20
+ const source = { zoom: 2, pan: { x: 1, y: 3 } };
21
+ const out = as2DView(source);
22
+ expect(out).not.toBe(source);
23
+ expect(out?.pan).not.toBe(source.pan);
24
+ });
25
+
26
+ it('rejects an orbit view', () => {
27
+ expect(as2DView(orbit)).toBeNull();
28
+ });
29
+
30
+ it('rejects a partial 2D view rather than filling in a default', () => {
31
+ expect(as2DView({ zoom: 2 })).toBeNull();
32
+ expect(as2DView({ zoom: 2, pan: { x: 1 } })).toBeNull();
33
+ });
34
+
35
+ it('rejects things that are not objects', () => {
36
+ expect(as2DView(null)).toBeNull();
37
+ expect(as2DView(undefined)).toBeNull();
38
+ expect(as2DView(4)).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('a trial view labkit does not interpret', () => {
43
+ it('stores and returns a view shape that is not zoom/pan', () => {
44
+ const store = createLabStore({ storageKey: 'view-a', storage: createMemoryAdapter() });
45
+ store.getState().addTrial({
46
+ id: 'w1',
47
+ instrumentName: 'gem',
48
+ config: {},
49
+ state: {},
50
+ view: DEFAULT_VIEW,
51
+ });
52
+
53
+ store.getState().updateTrialView('w1', orbit);
54
+
55
+ expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
56
+ });
57
+
58
+ it('round-trips that view through persistence', () => {
59
+ vi.useFakeTimers();
60
+ const mem = createMemoryAdapter();
61
+ const seed = createLabStore({ storageKey: 'view-b', storage: mem });
62
+ seed.getState().addTrial({
63
+ id: 'w1',
64
+ instrumentName: 'gem',
65
+ config: {},
66
+ state: {},
67
+ view: orbit,
68
+ });
69
+ vi.advanceTimersByTime(500);
70
+ vi.useRealTimers();
71
+
72
+ const hydrated = createLabStore({ storageKey: 'view-b', storage: mem });
73
+ expect(hydrated.getState().trials[0]?.view as OrbitView).toEqual(orbit);
74
+ });
75
+ });
76
+
77
+ describe('an unchanged write costs no new record', () => {
78
+ it('returns the same trial object when setState returns its input', () => {
79
+ const store = createLabStore({ storageKey: 'identity-a', storage: createMemoryAdapter() });
80
+ store.getState().addTrial({
81
+ id: 'w1',
82
+ instrumentName: 'gem',
83
+ config: {},
84
+ state: { n: 1 },
85
+ view: DEFAULT_VIEW,
86
+ });
87
+ const before = store.getState().trials[0];
88
+
89
+ store.getState().updateTrialState('w1', (prev: unknown) => prev);
90
+
91
+ // A trial re-renders on record identity, so allocating here would turn the
92
+ // standard React bail-out into a render loop.
93
+ expect(store.getState().trials[0]).toBe(before);
94
+ });
95
+
96
+ it('still replaces the record when the state actually changes', () => {
97
+ const store = createLabStore({ storageKey: 'identity-b', storage: createMemoryAdapter() });
98
+ store.getState().addTrial({
99
+ id: 'w1',
100
+ instrumentName: 'gem',
101
+ config: {},
102
+ state: { n: 1 },
103
+ view: DEFAULT_VIEW,
104
+ });
105
+ const before = store.getState().trials[0];
106
+
107
+ store.getState().updateTrialState('w1', { n: 2 });
108
+
109
+ expect(store.getState().trials[0]).not.toBe(before);
110
+ });
111
+
112
+ it('returns the same trial object when the view is written unchanged', () => {
113
+ const store = createLabStore({ storageKey: 'identity-c', storage: createMemoryAdapter() });
114
+ store.getState().addTrial({
115
+ id: 'w1',
116
+ instrumentName: 'gem',
117
+ config: {},
118
+ state: {},
119
+ view: orbit,
120
+ });
121
+ const before = store.getState().trials[0];
122
+
123
+ store.getState().updateTrialView('w1', orbit);
124
+
125
+ expect(store.getState().trials[0]).toBe(before);
126
+ });
127
+ });
@@ -0,0 +1,18 @@
1
+ import type { ViewTransform } from '../instrument/types';
2
+
3
+ /** The 2D view labkit has always shipped, and what a trial gets when it names no
4
+ * other. Re-exported under its own name so a consumer can say which it means. */
5
+ export type ViewTransform2D = ViewTransform;
6
+
7
+ export const DEFAULT_VIEW: ViewTransform2D = { zoom: 1, pan: { x: 0, y: 0 } };
8
+
9
+ /** A trial's view is opaque to labkit, so anything that needs the 2D shape — the
10
+ * zoom chrome, `CanvasStack` — asks for it and handles not getting it. */
11
+ export function as2DView(view: unknown): ViewTransform2D | null {
12
+ if (typeof view !== 'object' || view === null) return null;
13
+ const v = view as Partial<ViewTransform2D>;
14
+ if (typeof v.zoom !== 'number') return null;
15
+ if (typeof v.pan !== 'object' || v.pan === null) return null;
16
+ if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
17
+ return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
18
+ }
package/src/styles.less CHANGED
@@ -4,11 +4,17 @@
4
4
  @import './lab/LabShell.less';
5
5
  @import './primitives/Toolbar.less';
6
6
  @import './primitives/Sidebar.less';
7
+ @import './primitives/ZoomControl.less';
7
8
  @import './primitives/StatusBar.less';
9
+ @import './primitives/JobProgress.less';
8
10
  @import './primitives/FpsMeter.less';
9
11
  @import './primitives/ScaleIndicator.less';
12
+ @import './primitives/Legend.less';
13
+ @import './primitives/FloatingPanel.less';
10
14
  @import './lab/Lab.less';
11
15
  @import './trial/Trial.less';
16
+ @import './chrome/regions/ViewportRegion.less';
17
+ @import './chrome/regions/PaletteRegion.less';
12
18
  @import './controls/ControlPanel.less';
13
19
  @import './canvas/CanvasStack.less';
14
20
  @import './layers/LayerList.less';
@@ -0,0 +1,64 @@
1
+ # Surface — Agent Guide
2
+
3
+ `src/surface/` lets a lab drive a renderer labkit does not own. labkit publishes
4
+ rects, dirtiness, DPR and one rAF; the consumer keeps the GL.
5
+
6
+ Use this rather than the `canvas` capability when the renderer is not labkit's —
7
+ three.js, a raw WebGL context, anything with its own render loop. One canvas per
8
+ trial is one WebGL context per trial, and browsers cap those around 8–16, so more
9
+ than a few 3D tiles have to share one surface.
10
+
11
+ ## Files
12
+
13
+ | File | Role |
14
+ |---|---|
15
+ | `rect.ts` | The `Rect` and `Box` types |
16
+ | `composeRects.ts` | Tile boxes into surface-relative rects; both are viewport-relative, so it is a subtraction |
17
+ | `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
18
+ | `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
19
+ | `SurfaceContext.ts` | Carries the handle down |
20
+ | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
21
+
22
+ ## Shape of a consumer
23
+
24
+ ```tsx
25
+ const surface = useTiledSurface({
26
+ onFrame: ({ dirty, rects, dpr, size }) => {
27
+ renderer.setPixelRatio(Math.min(dpr, 2));
28
+ renderer.setSize(size.width, size.height, false);
29
+ for (const id of dirty) {
30
+ const rect = rects.get(id);
31
+ if (!rect) continue;
32
+ const v = toDeviceRect(rect, size.height, dpr);
33
+ // ... setViewport / setScissor / render
34
+ }
35
+ },
36
+ });
37
+ ```
38
+
39
+ `onFrame` carries **every** tile's rect, not only the dirty ones — a scissored
40
+ draw has to know where it is drawing relative to a surface that may have resized
41
+ under it.
42
+
43
+ ## The unit is a rect, not a trial
44
+
45
+ `useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
46
+ trial may register one, or none: a trial holding a drawn pane beside an undrawn
47
+ one contributes a single rect, and a trial with nothing to draw contributes none.
48
+
49
+ ## Traps
50
+
51
+ - **`preserveDrawingBuffer` is the consumer's job and is usually required.** A
52
+ partial redraw touches one tile; without it the default framebuffer's contents
53
+ are undefined after the page composites, and every other tile goes black.
54
+ - **Gutters lie outside every scissor.** Clear the whole surface when the tile set
55
+ changes, or a re-tile strands the old tiles' pixels between the new ones.
56
+ - **A tile that moves without resizing** is already handled: `Workspace`
57
+ invalidates rects off the grid's own `node.placementChanged`. A host that moves
58
+ something the grid does not know about calls `invalidateRects()` itself.
59
+
60
+ ## Testing
61
+
62
+ No WebGL in the suite. The arithmetic is pure and tested directly; the hook is
63
+ tested with `getBoundingClientRect` stubbed per element, because jsdom measures
64
+ everything as zero.
@@ -0,0 +1,5 @@
1
+ import { createContext } from 'react';
2
+ import type { SurfaceHandle } from './useTiledSurface';
3
+
4
+ /** Null when no surface owner is above — a lab with no shared surface at all. */
5
+ export const SurfaceContext = createContext<SurfaceHandle | null>(null);
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { composeRects, rectsEqual } from './composeRects';
3
+ import type { Box } from './rect';
4
+
5
+ const box = (left: number, top: number, width: number, height: number): Box => ({
6
+ left,
7
+ top,
8
+ width,
9
+ height,
10
+ });
11
+
12
+ describe('composeRects', () => {
13
+ it('reports tiles relative to the container origin', () => {
14
+ const out = composeRects(box(100, 50, 800, 600), new Map([['a', box(140, 90, 200, 150)]]));
15
+ expect(out.get('a')).toEqual({ x: 40, y: 40, w: 200, h: 150 });
16
+ });
17
+
18
+ it('works through a nested offset parent, because both are viewport-relative', () => {
19
+ const out = composeRects(box(0, 0, 800, 600), new Map([['deep', box(310, 220, 90, 40)]]));
20
+ expect(out.get('deep')).toEqual({ x: 310, y: 220, w: 90, h: 40 });
21
+ });
22
+
23
+ it('handles a container scrolled off the top of the viewport', () => {
24
+ const out = composeRects(box(0, -200, 800, 600), new Map([['a', box(0, -150, 100, 100)]]));
25
+ expect(out.get('a')).toEqual({ x: 0, y: 50, w: 100, h: 100 });
26
+ });
27
+
28
+ it('returns one entry per tile', () => {
29
+ const out = composeRects(
30
+ box(0, 0, 800, 600),
31
+ new Map([
32
+ ['a', box(0, 0, 10, 10)],
33
+ ['b', box(20, 0, 10, 10)],
34
+ ]),
35
+ );
36
+ expect([...out.keys()]).toEqual(['a', 'b']);
37
+ });
38
+ });
39
+
40
+ describe('rectsEqual', () => {
41
+ it('is false when the previous rect is missing', () => {
42
+ expect(rectsEqual(undefined, { x: 0, y: 0, w: 1, h: 1 })).toBe(false);
43
+ });
44
+
45
+ it('compares every field', () => {
46
+ const r = { x: 1, y: 2, w: 3, h: 4 };
47
+ expect(rectsEqual({ ...r }, r)).toBe(true);
48
+ expect(rectsEqual({ ...r, h: 5 }, r)).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,19 @@
1
+ import type { Box, Rect } from './rect';
2
+
3
+ /** Every tile's box expressed against the container's origin. */
4
+ export function composeRects(container: Box, tiles: ReadonlyMap<string, Box>): Map<string, Rect> {
5
+ const out = new Map<string, Rect>();
6
+ for (const [id, tile] of tiles) {
7
+ out.set(id, {
8
+ x: tile.left - container.left,
9
+ y: tile.top - container.top,
10
+ w: tile.width,
11
+ h: tile.height,
12
+ });
13
+ }
14
+ return out;
15
+ }
16
+
17
+ export function rectsEqual(a: Rect | undefined, b: Rect): boolean {
18
+ return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
19
+ }