@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,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
+ });
@@ -0,0 +1,161 @@
1
+ import { useVisibleRaf } from '@weasel-js/core';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ import { composeRects, rectsEqual } from './composeRects';
4
+ import type { Box, Rect } from './rect';
5
+
6
+ /** What a surface owner is handed once per animation frame. `rects` carries every
7
+ * tile, not only the dirty ones: a scissored draw has to know where it is drawing
8
+ * relative to a surface that may have resized under it. */
9
+ export interface SurfaceFrame {
10
+ dirty: ReadonlySet<string>;
11
+ rects: ReadonlyMap<string, Rect>;
12
+ dpr: number;
13
+ size: { width: number; height: number };
14
+ }
15
+
16
+ /** The invalidators and the two ref callbacks that publish geometry. */
17
+ export interface SurfaceHandle {
18
+ /** Mark one tile for redraw. */
19
+ invalidate: (id: string) => void;
20
+ /** Mark every tile — what a resize or a tile-set change means. */
21
+ invalidateAll: () => void;
22
+ /** Re-measure before the next frame. The escape hatch for a host that knows it
23
+ * moved something a ResizeObserver cannot see. */
24
+ invalidateRects: () => void;
25
+ registerTile: (id: string, el: HTMLElement | null) => void;
26
+ containerRef: (el: HTMLElement | null) => void;
27
+ }
28
+
29
+ export interface UseTiledSurfaceOptions {
30
+ onFrame: (frame: SurfaceFrame) => void;
31
+ }
32
+
33
+ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle {
34
+ const container = useRef<HTMLElement | null>(null);
35
+ const tiles = useRef(new Map<string, HTMLElement>());
36
+ const rects = useRef(new Map<string, Rect>());
37
+ const dirty = useRef(new Set<string>());
38
+ const needsMeasure = useRef(true);
39
+ const observer = useRef<ResizeObserver | null>(null);
40
+ const lastDpr = useRef(0);
41
+
42
+ // Held in a ref so a caller passing an inline closure does not re-create every
43
+ // callback below on each render.
44
+ const onFrameRef = useRef(onFrame);
45
+ onFrameRef.current = onFrame;
46
+
47
+ const measure = useCallback((): boolean => {
48
+ const el = container.current;
49
+ if (!el) return false;
50
+ const boxes = new Map<string, Box>();
51
+ for (const [id, tile] of tiles.current) boxes.set(id, tile.getBoundingClientRect());
52
+ const next = composeRects(el.getBoundingClientRect(), boxes);
53
+ let changed = next.size !== rects.current.size;
54
+ for (const [id, rect] of next) {
55
+ if (!rectsEqual(rects.current.get(id), rect)) changed = true;
56
+ }
57
+ rects.current = next;
58
+ return changed;
59
+ }, []);
60
+
61
+ const frameLoop = useVisibleRaf(
62
+ () => {
63
+ const el = container.current;
64
+ if (!el) {
65
+ dirty.current.clear();
66
+ return;
67
+ }
68
+ if (needsMeasure.current) {
69
+ needsMeasure.current = false;
70
+ if (measure()) for (const id of rects.current.keys()) dirty.current.add(id);
71
+ }
72
+ const dpr = globalThis.devicePixelRatio ?? 1;
73
+ if (dpr !== lastDpr.current) {
74
+ lastDpr.current = dpr;
75
+ for (const id of rects.current.keys()) dirty.current.add(id);
76
+ }
77
+ if (dirty.current.size === 0) return;
78
+ const box = el.getBoundingClientRect();
79
+ onFrameRef.current({
80
+ dirty: new Set(dirty.current),
81
+ rects: new Map(rects.current),
82
+ dpr,
83
+ size: { width: box.width, height: box.height },
84
+ });
85
+ dirty.current.clear();
86
+ },
87
+ // The host attaches its container through `containerRef`, which may land
88
+ // well after this hook's first effect; the gate re-resolves it per request.
89
+ { target: () => container.current },
90
+ );
91
+
92
+ const schedule = useCallback(() => {
93
+ frameLoop.request();
94
+ }, [frameLoop]);
95
+
96
+ const invalidate = useCallback(
97
+ (id: string) => {
98
+ dirty.current.add(id);
99
+ schedule();
100
+ },
101
+ [schedule],
102
+ );
103
+
104
+ const invalidateAll = useCallback(() => {
105
+ for (const id of tiles.current.keys()) dirty.current.add(id);
106
+ schedule();
107
+ }, [schedule]);
108
+
109
+ const invalidateRects = useCallback(() => {
110
+ needsMeasure.current = true;
111
+ schedule();
112
+ }, [schedule]);
113
+
114
+ const registerTile = useCallback(
115
+ (id: string, el: HTMLElement | null) => {
116
+ const known = tiles.current.get(id);
117
+ if (known === el) return;
118
+ if (known) observer.current?.unobserve(known);
119
+ if (el) {
120
+ tiles.current.set(id, el);
121
+ observer.current?.observe(el);
122
+ } else {
123
+ tiles.current.delete(id);
124
+ rects.current.delete(id);
125
+ dirty.current.delete(id);
126
+ }
127
+ needsMeasure.current = true;
128
+ schedule();
129
+ },
130
+ [schedule],
131
+ );
132
+
133
+ const containerRef = useCallback(
134
+ (el: HTMLElement | null) => {
135
+ if (container.current === el) return;
136
+ if (container.current) observer.current?.unobserve(container.current);
137
+ container.current = el;
138
+ if (el) observer.current?.observe(el);
139
+ needsMeasure.current = true;
140
+ schedule();
141
+ },
142
+ [schedule],
143
+ );
144
+
145
+ useEffect(() => {
146
+ const ro = new ResizeObserver(() => {
147
+ needsMeasure.current = true;
148
+ schedule();
149
+ });
150
+ observer.current = ro;
151
+ if (container.current) ro.observe(container.current);
152
+ for (const el of tiles.current.values()) ro.observe(el);
153
+ return () => {
154
+ ro.disconnect();
155
+ observer.current = null;
156
+ frameLoop.cancel();
157
+ };
158
+ }, [schedule, frameLoop]);
159
+
160
+ return { invalidate, invalidateAll, invalidateRects, registerTile, containerRef };
161
+ }
package/src/test-setup.ts CHANGED
@@ -31,6 +31,32 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
31
31
  } as unknown as typeof ResizeObserver;
32
32
  }
33
33
 
34
+ // jsdom ships no PointerEvent, so `fireEvent.pointerDown` builds a bare Event
35
+ // and drops clientX/clientY — a drag handler then reads `undefined` and computes
36
+ // NaN deltas, which the DOM rejects as invalid CSS rather than reporting.
37
+ // MouseEvent already carries the coordinate fields.
38
+ if (typeof globalThis.PointerEvent === 'undefined') {
39
+ class PointerEventPolyfill extends MouseEvent {
40
+ readonly pointerId: number;
41
+ readonly pointerType: string;
42
+ readonly isPrimary: boolean;
43
+ constructor(type: string, init: PointerEventInit = {}) {
44
+ super(type, init);
45
+ this.pointerId = init.pointerId ?? 1;
46
+ this.pointerType = init.pointerType ?? 'mouse';
47
+ this.isPrimary = init.isPrimary ?? true;
48
+ }
49
+ }
50
+ globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent;
51
+ }
52
+
53
+ // jsdom implements neither side of pointer capture.
54
+ if (typeof Element.prototype.setPointerCapture === 'undefined') {
55
+ Element.prototype.setPointerCapture = () => {};
56
+ Element.prototype.releasePointerCapture = () => {};
57
+ Element.prototype.hasPointerCapture = () => false;
58
+ }
59
+
34
60
  afterEach(() => {
35
61
  cleanup();
36
62
  });
@@ -16,6 +16,10 @@
16
16
 
17
17
  // Resets and global element defaults.
18
18
  .lk-root {
19
+ // labkit's own vocabulary over the theme's raw scale — named for the thing
20
+ // rather than the step, so "the workspace is too tight" has one place to go.
21
+ --lk-workspace-pad: var(--wzl-space-sm);
22
+
19
23
  font-family: var(--wzl-font-ui);
20
24
  font-size: var(--wzl-font-size);
21
25
  font-weight: var(--wzl-font-weight-light);
@@ -26,8 +30,15 @@
26
30
  *, *::before, *::after {
27
31
  box-sizing: border-box;
28
32
  }
33
+ }
29
34
 
30
- button {
35
+ // Defaults for labkit's own bare markup. `.lk-root` is inside :where() too, so
36
+ // the whole selector scores (0,0,0): a weasel-ui module class beats it on
37
+ // specificity instead of on which stylesheet happened to be injected last.
38
+ // Deliberate overrides of a weasel-ui component go the other way — see the
39
+ // `.lk-root` prefix convention below.
40
+ :where(.lk-root) {
41
+ :where(button) {
31
42
  font: inherit;
32
43
  color: inherit;
33
44
  background: var(--wzl-surface-raised);
@@ -55,11 +66,17 @@
55
66
  }
56
67
  }
57
68
 
58
- input[type="range"] {
69
+ :where(input[type="range"]) {
59
70
  accent-color: var(--wzl-accent);
60
71
  }
61
72
  }
62
73
 
74
+ // The `.lk-root` prefix convention: a labkit class handed to a weasel-ui
75
+ // component through `className` lands beside that component's own CSS-module
76
+ // class at equal specificity, so injection order — not intent — decides the
77
+ // winner. Prefixing with `.lk-root` scores (0,2,0) and settles it. Use it only
78
+ // where labkit means to override the component; plain `.lk-*` elsewhere.
79
+
63
80
  // Cosmic background applied to the Lab root. The starscape sits as a fixed
64
81
  // pseudo-element so panning the trial doesn't shift the field of stars.
65
82
  .lk-lab {
@@ -28,7 +28,9 @@
28
28
  "fg-subtle": { "$value": "#5a5a78" },
29
29
  "accent": { "$value": "#b08adb" },
30
30
  "accent-hover": { "$value": "#c6a3ee" },
31
- "focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" }
31
+ "focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" },
32
+ "shadow": { "$value": "rgba(0, 0, 0, 0.7)" },
33
+ "border-raised": { "$value": "rgba(255, 255, 255, 0.36)" }
32
34
  },
33
35
  "gradient": {
34
36
  "$type": "gradient",
@@ -50,7 +52,9 @@
50
52
  "fg-subtle": { "$value": "#a89b85" },
51
53
  "accent": { "$value": "#a86f3c" },
52
54
  "accent-hover": { "$value": "#c2854f" },
53
- "focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" }
55
+ "focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" },
56
+ "shadow": { "$value": "rgba(80, 60, 40, 0.18)" },
57
+ "border-raised": { "$value": "#9e8b71" }
54
58
  }
55
59
  }
56
60
  }