@weasel-js/labkit 1.0.4 → 1.2.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 (122) hide show
  1. package/README.md +33 -6
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/chunk-VUU5UXHE.js +491 -0
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +211 -151
  21. package/dist/index.js +317 -169
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +29 -6
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +12 -12
  32. package/dist/state/index.js +5 -5
  33. package/dist/state/index.js.map +1 -1
  34. package/dist/styles.css +31 -13
  35. package/dist/surface/index.d.ts +77 -0
  36. package/dist/surface/index.js +3 -0
  37. package/dist/surface/index.js.map +1 -0
  38. package/dist/ui/layers/index.js +3 -3
  39. package/dist/undo/index.d.ts +2 -1
  40. package/package.json +9 -1
  41. package/src/canvas/AGENTS.md +14 -2
  42. package/src/canvas/useOrbit.test.ts +71 -0
  43. package/src/canvas/useOrbit.ts +149 -0
  44. package/src/dragdrop/dragDrop.test.tsx +4 -7
  45. package/src/index.test.ts +70 -0
  46. package/src/index.ts +24 -7
  47. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  48. package/src/instrument/capabilityDetector.ts +1 -1
  49. package/src/instrument/types.ts +30 -6
  50. package/src/job/index.ts +3 -0
  51. package/src/job/types.ts +47 -0
  52. package/src/job/useJob.test.tsx +210 -0
  53. package/src/job/useJob.ts +134 -0
  54. package/src/lab/Lab.less +16 -0
  55. package/src/lab/Lab.stories.tsx +6 -7
  56. package/src/lab/Lab.test.tsx +25 -25
  57. package/src/lab/Lab.tsx +50 -50
  58. package/src/lab/LabContext.ts +12 -12
  59. package/src/lab/LabShell.tsx +1 -1
  60. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  61. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  62. package/src/lab/Workspace.surface.test.tsx +49 -0
  63. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  64. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
  65. package/src/lab/index.ts +2 -2
  66. package/src/layers/AGENTS.md +3 -3
  67. package/src/primitives/Toolbar.stories.tsx +2 -2
  68. package/src/state/SingletonExperiment.test.tsx +8 -8
  69. package/src/state/SingletonExperiment.tsx +9 -9
  70. package/src/state/context.tsx +12 -12
  71. package/src/state/document.test.ts +369 -0
  72. package/src/state/document.ts +194 -0
  73. package/src/state/helpers.test.ts +74 -46
  74. package/src/state/helpers.ts +21 -29
  75. package/src/state/index.ts +16 -8
  76. package/src/state/store.test.ts +440 -41
  77. package/src/state/store.ts +165 -102
  78. package/src/state/types.ts +32 -13
  79. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  80. package/src/state/useTrialState.ts +29 -0
  81. package/src/state/view.test.ts +127 -0
  82. package/src/state/view.ts +18 -0
  83. package/src/styles.less +2 -2
  84. package/src/surface/AGENTS.md +64 -0
  85. package/src/surface/SurfaceContext.ts +5 -0
  86. package/src/surface/composeRects.test.ts +50 -0
  87. package/src/surface/composeRects.ts +19 -0
  88. package/src/surface/deviceRect.test.ts +40 -0
  89. package/src/surface/deviceRect.ts +19 -0
  90. package/src/surface/index.ts +7 -0
  91. package/src/surface/rect.ts +16 -0
  92. package/src/surface/useSurfaceTile.test.tsx +67 -0
  93. package/src/surface/useSurfaceTile.ts +32 -0
  94. package/src/surface/useTiledSurface.test.tsx +231 -0
  95. package/src/surface/useTiledSurface.ts +157 -0
  96. package/src/theme/base.less +1 -1
  97. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  98. package/src/trial/DefaultStatusBar.tsx +19 -0
  99. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  100. package/src/trial/Trial.canvas.test.tsx +80 -0
  101. package/src/trial/Trial.job.test.tsx +75 -0
  102. package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
  103. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
  104. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  105. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
  106. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
  107. package/src/{workspace → trial}/index.ts +13 -13
  108. package/src/{workspace → trial}/slotTypes.ts +18 -17
  109. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  110. package/src/trial/trialOps.ts +99 -0
  111. package/src/ui/format.test.ts +33 -0
  112. package/src/ui/format.ts +11 -0
  113. package/src/ui/properties/CurveField.tsx +6 -6
  114. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  115. package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
  116. package/dist/chunk-BAPZPDDA.js.map +0 -1
  117. package/dist/chunk-DWV7SFKR.js.map +0 -1
  118. package/dist/chunk-KSTEW2AF.js +0 -367
  119. package/dist/chunk-KSTEW2AF.js.map +0 -1
  120. package/src/state/useExperimentState.ts +0 -31
  121. package/src/workspace/DefaultStatusBar.tsx +0 -18
  122. package/src/workspace/workspaceOps.ts +0 -102
@@ -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
@@ -1,6 +1,6 @@
1
1
  // Imports compile-time only. Component .less files are added in later steps.
2
2
  @import './theme/base.less';
3
- @import './lab/WorkspaceGrid.less';
3
+ @import './lab/Workspace.less';
4
4
  @import './lab/LabShell.less';
5
5
  @import './primitives/Toolbar.less';
6
6
  @import './primitives/Sidebar.less';
@@ -8,7 +8,7 @@
8
8
  @import './primitives/FpsMeter.less';
9
9
  @import './primitives/ScaleIndicator.less';
10
10
  @import './lab/Lab.less';
11
- @import './workspace/Workspace.less';
11
+ @import './trial/Trial.less';
12
12
  @import './controls/ControlPanel.less';
13
13
  @import './canvas/CanvasStack.less';
14
14
  @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
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { toDeviceRect } from './deviceRect';
3
+
4
+ const onGrid = (v: number, dpr: number) => Math.abs(v * dpr - Math.round(v * dpr)) < 1e-9;
5
+
6
+ describe('toDeviceRect', () => {
7
+ it('measures y from the bottom of the surface', () => {
8
+ const r = toDeviceRect({ x: 0, y: 0, w: 40, h: 10 }, 100, 1);
9
+ expect(r.y).toBe(90);
10
+ expect(r.h).toBe(10);
11
+ });
12
+
13
+ it('leaves x and width alone', () => {
14
+ const r = toDeviceRect({ x: 5, y: 0, w: 40, h: 10 }, 100, 1);
15
+ expect(r.x).toBe(5);
16
+ expect(r.w).toBe(40);
17
+ });
18
+
19
+ it('puts every edge on the device grid', () => {
20
+ const r = toDeviceRect({ x: 33.3, y: 7.7, w: 33.3, h: 21.4 }, 100, 2);
21
+ expect(onGrid(r.x, 2)).toBe(true);
22
+ expect(onGrid(r.x + r.w, 2)).toBe(true);
23
+ expect(onGrid(r.y, 2)).toBe(true);
24
+ expect(onGrid(r.y + r.h, 2)).toBe(true);
25
+ });
26
+
27
+ it('does not strand a column between neighbours', () => {
28
+ const a = toDeviceRect({ x: 0, y: 0, w: 33.3333, h: 10 }, 100, 2);
29
+ const b = toDeviceRect({ x: 33.3333, y: 0, w: 33.3333, h: 10 }, 100, 2);
30
+ const c = toDeviceRect({ x: 66.6666, y: 0, w: 33.3334, h: 10 }, 100, 2);
31
+ expect(a.x + a.w).toBe(b.x);
32
+ expect(b.x + b.w).toBe(c.x);
33
+ });
34
+
35
+ it('snaps a stacked pair without overlapping them', () => {
36
+ const top = toDeviceRect({ x: 0, y: 0, w: 10, h: 33.3333 }, 100, 3);
37
+ const bottom = toDeviceRect({ x: 0, y: 33.3333, w: 10, h: 33.3333 }, 100, 3);
38
+ expect(bottom.y + bottom.h).toBe(top.y);
39
+ });
40
+ });
@@ -0,0 +1,19 @@
1
+ import type { Rect } from './rect';
2
+
3
+ /**
4
+ * A DOM rect as a GL viewport rect: origin at the bottom-left, every edge snapped
5
+ * to the device-pixel grid. Still CSS pixels, because three.js applies its own
6
+ * pixel ratio — snapping here is what stops a tile and its neighbour rounding
7
+ * apart and leaving a hairline column between them.
8
+ */
9
+ export function toDeviceRect(rect: Rect, surfaceHeight: number, dpr: number): Rect {
10
+ const snap = (v: number) => Math.round(v * dpr) / dpr;
11
+ const x = snap(rect.x);
12
+ const y = snap(surfaceHeight - rect.y - rect.h);
13
+ return {
14
+ x,
15
+ y,
16
+ w: snap(rect.x + rect.w) - x,
17
+ h: snap(surfaceHeight - rect.y) - y,
18
+ };
19
+ }
@@ -0,0 +1,7 @@
1
+ export { composeRects, rectsEqual } from './composeRects';
2
+ export { toDeviceRect } from './deviceRect';
3
+ export type { Box, Rect } from './rect';
4
+ export { SurfaceContext } from './SurfaceContext';
5
+ export { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
6
+ export type { SurfaceFrame, SurfaceHandle, UseTiledSurfaceOptions } from './useTiledSurface';
7
+ export { useTiledSurface } from './useTiledSurface';
@@ -0,0 +1,16 @@
1
+ /** A box in CSS pixels, measured from the surface's own top-left corner. */
2
+ export interface Rect {
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ }
8
+
9
+ /** The subset of `DOMRect` this package reads. Accepting the subset rather than
10
+ * `DOMRect` is what lets the pure functions be tested without a DOM. */
11
+ export interface Box {
12
+ left: number;
13
+ top: number;
14
+ width: number;
15
+ height: number;
16
+ }
@@ -0,0 +1,67 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { SurfaceContext } from './SurfaceContext';
4
+ import { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
5
+ import type { SurfaceHandle } from './useTiledSurface';
6
+
7
+ function fakeHandle(): SurfaceHandle {
8
+ return {
9
+ invalidate: vi.fn(),
10
+ invalidateAll: vi.fn(),
11
+ invalidateRects: vi.fn(),
12
+ registerTile: vi.fn(),
13
+ containerRef: vi.fn(),
14
+ };
15
+ }
16
+
17
+ function Tile({ id }: { id: string }) {
18
+ const ref = useSurfaceTile(id);
19
+ return <div ref={ref} data-testid={id} />;
20
+ }
21
+
22
+ describe('useSurfaceTile', () => {
23
+ it('registers its element with the surface on mount', () => {
24
+ const handle = fakeHandle();
25
+ const { getByTestId } = render(
26
+ <SurfaceContext.Provider value={handle}>
27
+ <Tile id="a" />
28
+ </SurfaceContext.Provider>,
29
+ );
30
+ expect(handle.registerTile).toHaveBeenCalledWith('a', getByTestId('a'));
31
+ });
32
+
33
+ it('unregisters on unmount', () => {
34
+ const handle = fakeHandle();
35
+ const { unmount } = render(
36
+ <SurfaceContext.Provider value={handle}>
37
+ <Tile id="a" />
38
+ </SurfaceContext.Provider>,
39
+ );
40
+ unmount();
41
+ expect(handle.registerTile).toHaveBeenCalledWith('a', null);
42
+ });
43
+
44
+ it('is inert with no surface above it, so a 2D lab is unaffected', () => {
45
+ expect(() => render(<Tile id="a" />)).not.toThrow();
46
+ });
47
+ });
48
+
49
+ describe('useSurface', () => {
50
+ it('throws outside a provider, because a caller asking for it needs one', () => {
51
+ function Consumer() {
52
+ useSurface();
53
+ return null;
54
+ }
55
+ expect(() => render(<Consumer />)).toThrow(/requires a surface/i);
56
+ });
57
+
58
+ it('returns null from the optional form outside a provider', () => {
59
+ let seen: SurfaceHandle | null | undefined;
60
+ function Consumer() {
61
+ seen = useSurfaceOptional();
62
+ return null;
63
+ }
64
+ render(<Consumer />);
65
+ expect(seen).toBeNull();
66
+ });
67
+ });
@@ -0,0 +1,32 @@
1
+ import { useCallback, useContext } from 'react';
2
+ import { SurfaceContext } from './SurfaceContext';
3
+ import type { SurfaceHandle } from './useTiledSurface';
4
+
5
+ /** The surface above, or null. Use this where a surface is genuinely optional. */
6
+ export function useSurfaceOptional(): SurfaceHandle | null {
7
+ return useContext(SurfaceContext);
8
+ }
9
+
10
+ /** The surface above. Throws where a caller cannot work without one. */
11
+ export function useSurface(): SurfaceHandle {
12
+ const surface = useContext(SurfaceContext);
13
+ if (!surface) throw new Error('[labkit] useSurface requires a surface owner above it');
14
+ return surface;
15
+ }
16
+
17
+ /**
18
+ * A ref callback that publishes this element's rect to the surface under `id`.
19
+ *
20
+ * Attach it to whichever element the surface should draw into — that is not
21
+ * necessarily the trial's own element, since a trial may hold a drawn pane beside
22
+ * an undrawn one, or none at all.
23
+ */
24
+ export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
25
+ const surface = useSurfaceOptional();
26
+ return useCallback(
27
+ (el: HTMLElement | null) => {
28
+ surface?.registerTile(id, el);
29
+ },
30
+ [surface, id],
31
+ );
32
+ }