@weasel-js/labkit 1.1.0 → 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 (79) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-iAP6XbH3.d.ts → index-JFAYj5Tv.d.ts} +12 -0
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-BMNIx3Cy.d.ts} +5 -9
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-C6GJKPUI.js → chunk-73KA7WBO.js} +69 -63
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DJLDIRFN.js → chunk-BOHF3PQO.js} +4375 -3720
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-NRKWVTVT.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-NRKWVTVT.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-3TYUJR7Z.js → chunk-VUU5UXHE.js} +6 -3
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +71 -11
  21. package/dist/index.js +165 -22
  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 +20 -4
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +2 -2
  32. package/dist/state/index.js +2 -2
  33. package/dist/styles.css +10 -0
  34. package/dist/surface/index.d.ts +77 -0
  35. package/dist/surface/index.js +3 -0
  36. package/dist/surface/index.js.map +1 -0
  37. package/dist/ui/layers/index.js +3 -3
  38. package/dist/undo/index.d.ts +2 -1
  39. package/package.json +9 -1
  40. package/src/canvas/AGENTS.md +8 -0
  41. package/src/canvas/useOrbit.test.ts +71 -0
  42. package/src/canvas/useOrbit.ts +149 -0
  43. package/src/index.test.ts +18 -0
  44. package/src/index.ts +13 -0
  45. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  46. package/src/instrument/types.ts +13 -1
  47. package/src/job/index.ts +3 -0
  48. package/src/job/types.ts +47 -0
  49. package/src/job/useJob.test.tsx +210 -0
  50. package/src/job/useJob.ts +134 -0
  51. package/src/lab/Lab.stories.tsx +1 -2
  52. package/src/lab/Workspace.surface.test.tsx +49 -0
  53. package/src/lab/Workspace.tsx +14 -1
  54. package/src/state/store.ts +8 -2
  55. package/src/state/types.ts +4 -2
  56. package/src/state/view.test.ts +127 -0
  57. package/src/state/view.ts +18 -0
  58. package/src/surface/AGENTS.md +64 -0
  59. package/src/surface/SurfaceContext.ts +5 -0
  60. package/src/surface/composeRects.test.ts +50 -0
  61. package/src/surface/composeRects.ts +19 -0
  62. package/src/surface/deviceRect.test.ts +40 -0
  63. package/src/surface/deviceRect.ts +19 -0
  64. package/src/surface/index.ts +7 -0
  65. package/src/surface/rect.ts +16 -0
  66. package/src/surface/useSurfaceTile.test.tsx +67 -0
  67. package/src/surface/useSurfaceTile.ts +32 -0
  68. package/src/surface/useTiledSurface.test.tsx +231 -0
  69. package/src/surface/useTiledSurface.ts +157 -0
  70. package/src/trial/DefaultStatusBar.tsx +2 -2
  71. package/src/trial/Trial.job.test.tsx +75 -0
  72. package/src/trial/Trial.less +12 -0
  73. package/src/trial/Trial.stories.tsx +8 -1
  74. package/src/trial/Trial.tsx +32 -6
  75. package/src/trial/TrialChrome.tsx +33 -6
  76. package/src/trial/slotTypes.ts +2 -1
  77. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  78. package/dist/chunk-C6GJKPUI.js.map +0 -1
  79. package/dist/chunk-DJLDIRFN.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,231 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { useEffect } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import type { SurfaceFrame, SurfaceHandle } from './useTiledSurface';
5
+ import { useTiledSurface } from './useTiledSurface';
6
+
7
+ /** jsdom measures everything as zero, so each element is given a box by hand. */
8
+ function stubBox(el: HTMLElement, left: number, top: number, width: number, height: number): void {
9
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
10
+ left,
11
+ top,
12
+ width,
13
+ height,
14
+ right: left + width,
15
+ bottom: top + height,
16
+ x: left,
17
+ y: top,
18
+ toJSON: () => ({}),
19
+ } as DOMRect);
20
+ }
21
+
22
+ interface HarnessProps {
23
+ frames: SurfaceFrame[];
24
+ onHandle: (h: SurfaceHandle) => void;
25
+ }
26
+
27
+ function Harness({ frames, onHandle }: HarnessProps) {
28
+ const surface = useTiledSurface({
29
+ onFrame: (f) => {
30
+ frames.push(f);
31
+ },
32
+ });
33
+ useEffect(() => {
34
+ onHandle(surface);
35
+ }, [surface, onHandle]);
36
+ return (
37
+ <div
38
+ data-testid="stage"
39
+ ref={(el) => {
40
+ if (!el) return;
41
+ stubBox(el, 0, 0, 800, 600);
42
+ surface.containerRef(el);
43
+ }}
44
+ >
45
+ <div
46
+ data-testid="a"
47
+ ref={(el) => {
48
+ if (!el) return;
49
+ stubBox(el, 0, 0, 400, 600);
50
+ surface.registerTile('a', el);
51
+ }}
52
+ />
53
+ <div
54
+ data-testid="b"
55
+ ref={(el) => {
56
+ if (!el) return;
57
+ stubBox(el, 400, 0, 400, 600);
58
+ surface.registerTile('b', el);
59
+ }}
60
+ />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /** Runs every pending rAF callback. */
66
+ function flushFrames(): void {
67
+ act(() => {
68
+ vi.advanceTimersByTime(64);
69
+ });
70
+ }
71
+
72
+ describe('useTiledSurface', () => {
73
+ beforeEach(() => {
74
+ vi.useFakeTimers();
75
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
76
+ setTimeout(() => cb(0), 16),
77
+ );
78
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
79
+ });
80
+ afterEach(() => {
81
+ vi.useRealTimers();
82
+ vi.unstubAllGlobals();
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ it('coalesces three invalidations in one tick into a single frame', () => {
87
+ const frames: SurfaceFrame[] = [];
88
+ let handle: SurfaceHandle | null = null;
89
+ render(
90
+ <Harness
91
+ frames={frames}
92
+ onHandle={(h) => {
93
+ handle = h;
94
+ }}
95
+ />,
96
+ );
97
+ flushFrames();
98
+ frames.length = 0;
99
+
100
+ act(() => {
101
+ handle?.invalidate('a');
102
+ handle?.invalidate('b');
103
+ handle?.invalidate('a');
104
+ });
105
+ flushFrames();
106
+
107
+ expect(frames).toHaveLength(1);
108
+ expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
109
+ });
110
+
111
+ it('fires nothing on a clean tick', () => {
112
+ const frames: SurfaceFrame[] = [];
113
+ render(<Harness frames={frames} onHandle={() => {}} />);
114
+ flushFrames();
115
+ frames.length = 0;
116
+ flushFrames();
117
+ expect(frames).toHaveLength(0);
118
+ });
119
+
120
+ it('carries every tile rect, not only the dirty ones', () => {
121
+ const frames: SurfaceFrame[] = [];
122
+ let handle: SurfaceHandle | null = null;
123
+ render(
124
+ <Harness
125
+ frames={frames}
126
+ onHandle={(h) => {
127
+ handle = h;
128
+ }}
129
+ />,
130
+ );
131
+ flushFrames();
132
+ frames.length = 0;
133
+
134
+ act(() => handle?.invalidate('a'));
135
+ flushFrames();
136
+
137
+ expect([...(frames[0]?.dirty ?? [])]).toEqual(['a']);
138
+ expect(frames[0]?.rects.get('b')).toEqual({ x: 400, y: 0, w: 400, h: 600 });
139
+ });
140
+
141
+ it('reports the surface size and dpr', () => {
142
+ vi.stubGlobal('devicePixelRatio', 3);
143
+ const frames: SurfaceFrame[] = [];
144
+ let handle: SurfaceHandle | null = null;
145
+ render(
146
+ <Harness
147
+ frames={frames}
148
+ onHandle={(h) => {
149
+ handle = h;
150
+ }}
151
+ />,
152
+ );
153
+ flushFrames();
154
+ frames.length = 0;
155
+
156
+ act(() => handle?.invalidateAll());
157
+ flushFrames();
158
+
159
+ expect(frames[0]?.dpr).toBe(3);
160
+ expect(frames[0]?.size).toEqual({ width: 800, height: 600 });
161
+ });
162
+
163
+ it('marks every tile dirty when the dpr changes', () => {
164
+ vi.stubGlobal('devicePixelRatio', 1);
165
+ const frames: SurfaceFrame[] = [];
166
+ let handle: SurfaceHandle | null = null;
167
+ render(
168
+ <Harness
169
+ frames={frames}
170
+ onHandle={(h) => {
171
+ handle = h;
172
+ }}
173
+ />,
174
+ );
175
+ flushFrames();
176
+ frames.length = 0;
177
+
178
+ vi.stubGlobal('devicePixelRatio', 2);
179
+ act(() => handle?.invalidateRects());
180
+ flushFrames();
181
+
182
+ expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
183
+ expect(frames[0]?.dpr).toBe(2);
184
+ });
185
+
186
+ it('re-measures on invalidateRects, which is how a moved tile is caught', () => {
187
+ const frames: SurfaceFrame[] = [];
188
+ let handle: SurfaceHandle | null = null;
189
+ const { getByTestId } = render(
190
+ <Harness
191
+ frames={frames}
192
+ onHandle={(h) => {
193
+ handle = h;
194
+ }}
195
+ />,
196
+ );
197
+ flushFrames();
198
+ frames.length = 0;
199
+
200
+ // A sibling reflow slides tile b left. ResizeObserver sees no size change.
201
+ stubBox(getByTestId('b'), 320, 0, 400, 600);
202
+
203
+ act(() => handle?.invalidateRects());
204
+ flushFrames();
205
+
206
+ expect(frames[0]?.rects.get('b')).toEqual({ x: 320, y: 0, w: 400, h: 600 });
207
+ });
208
+
209
+ it('drops a tile that unregisters', () => {
210
+ const frames: SurfaceFrame[] = [];
211
+ let handle: SurfaceHandle | null = null;
212
+ render(
213
+ <Harness
214
+ frames={frames}
215
+ onHandle={(h) => {
216
+ handle = h;
217
+ }}
218
+ />,
219
+ );
220
+ flushFrames();
221
+ frames.length = 0;
222
+
223
+ act(() => {
224
+ handle?.registerTile('b', null);
225
+ handle?.invalidate('a');
226
+ });
227
+ flushFrames();
228
+
229
+ expect(frames[0]?.rects.has('b')).toBe(false);
230
+ });
231
+ });