@weasel-js/labkit 1.8.0 → 1.8.1

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 (189) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
  5. package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
  6. package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  7. package/dist/canvas/index.d.ts +4 -4
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +2 -2
  10. package/dist/chrome/index.js +7 -7
  11. package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
  12. package/dist/chunk-4RCVWX4M.js.map +1 -0
  13. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  14. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  15. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  16. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  17. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  18. package/dist/chunk-7ALJOVGP.js.map +1 -0
  19. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  20. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  21. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  22. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  23. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  24. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  25. package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
  26. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  27. package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
  28. package/dist/chunk-NHIO2O3O.js.map +1 -0
  29. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  30. package/dist/chunk-QMAY74TP.js.map +1 -0
  31. package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
  32. package/dist/chunk-RN7T7OBO.js.map +1 -0
  33. package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
  34. package/dist/chunk-RXO5UEGT.js.map +1 -0
  35. package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
  36. package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
  37. package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
  38. package/dist/chunk-T5OEUNCB.js.map +1 -0
  39. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  40. package/dist/chunk-UX66USTN.js.map +1 -0
  41. package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
  42. package/dist/chunk-V4SMGEYY.js.map +1 -0
  43. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  44. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  45. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  46. package/dist/chunk-XWR2P6MR.js.map +1 -0
  47. package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
  48. package/dist/chunk-YZA73U4A.js.map +1 -0
  49. package/dist/config/index.d.ts +2 -2
  50. package/dist/config/index.js +2 -2
  51. package/dist/controls/index.js +5 -5
  52. package/dist/dragdrop/index.d.ts +1 -1
  53. package/dist/dragdrop/index.js +2 -2
  54. package/dist/index.d.ts +157 -15
  55. package/dist/index.js +893 -258
  56. package/dist/index.js.map +1 -1
  57. package/dist/layers/index.d.ts +1 -1
  58. package/dist/layers/index.js +3 -3
  59. package/dist/loupe/index.d.ts +175 -27
  60. package/dist/loupe/index.js +547 -141
  61. package/dist/loupe/index.js.map +1 -1
  62. package/dist/overview/index.js +5 -5
  63. package/dist/passthrough/weasel-ui.js +3 -3
  64. package/dist/primitives/index.d.ts +4 -19
  65. package/dist/primitives/index.js +7 -7
  66. package/dist/state/index.d.ts +5 -7
  67. package/dist/state/index.js +5 -5
  68. package/dist/styles.css +173 -38
  69. package/dist/surface/index.d.ts +20 -1
  70. package/dist/surface/index.js +2 -2
  71. package/dist/undo/index.d.ts +18 -7
  72. package/dist/undo/index.js +1 -1
  73. package/package.json +11 -11
  74. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  75. package/src/annotations/AnnotationOverlay.tsx +10 -1
  76. package/src/annotations/Annotations.less +2 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  78. package/src/canvas/CanvasStack.test.tsx +35 -0
  79. package/src/canvas/CanvasStack.tsx +12 -1
  80. package/src/canvas/useLayerScheduler.ts +14 -0
  81. package/src/chrome/builtins.test.ts +14 -0
  82. package/src/chrome/builtins.tsx +17 -2
  83. package/src/chrome/regions/StatusRegion.tsx +20 -14
  84. package/src/chrome/regions/regions.test.tsx +28 -0
  85. package/src/chrome/types.ts +8 -0
  86. package/src/clock/clock.browser.test.less +5 -0
  87. package/src/clock/clock.browser.test.tsx +77 -0
  88. package/src/clock/clock.integration.test.tsx +95 -0
  89. package/src/clock/clockRegistry.test.ts +33 -0
  90. package/src/clock/clockRegistry.ts +46 -0
  91. package/src/clock/hooks.ts +44 -0
  92. package/src/clock/index.ts +2 -0
  93. package/src/clock/trialClock.test.ts +265 -0
  94. package/src/clock/trialClock.ts +245 -0
  95. package/src/clock/useClockLoop.ts +45 -0
  96. package/src/config/resolve.test.ts +12 -0
  97. package/src/config/resolve.ts +5 -0
  98. package/src/controls/ControlMatrix.tsx +56 -54
  99. package/src/index.ts +3 -2
  100. package/src/instrument/types.ts +21 -1
  101. package/src/lab/Lab.chrome.test.tsx +8 -1
  102. package/src/lab/Lab.presentation.test.tsx +113 -0
  103. package/src/lab/Lab.surface.test.tsx +20 -9
  104. package/src/lab/Lab.tsx +132 -257
  105. package/src/lab/LabLightbox.browser.test.less +17 -0
  106. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  107. package/src/lab/LabShell.less +10 -3
  108. package/src/lab/LabSurface.tsx +129 -0
  109. package/src/lab/Workspace.stories.tsx +18 -0
  110. package/src/lab/Workspace.surface.test.tsx +4 -0
  111. package/src/lab/Workspace.test.tsx +37 -1
  112. package/src/lab/Workspace.tsx +34 -6
  113. package/src/lab/index.ts +2 -0
  114. package/src/lab/nebula.ts +30 -0
  115. package/src/lab/openLab.test.ts +81 -0
  116. package/src/lab/openLab.ts +119 -0
  117. package/src/lab/presentation.browser.test.less +5 -0
  118. package/src/lab/presentation.browser.test.tsx +119 -0
  119. package/src/lab/presentation.less +64 -0
  120. package/src/lab/presentation.tsx +47 -0
  121. package/src/lab/useFocusPick.ts +43 -0
  122. package/src/lightbox/Lightbox.browser.test.less +14 -0
  123. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  124. package/src/lightbox/Lightbox.less +128 -0
  125. package/src/lightbox/Lightbox.test.tsx +195 -0
  126. package/src/lightbox/Lightbox.tsx +205 -0
  127. package/src/lightbox/LightboxContext.ts +38 -0
  128. package/src/lightbox/LightboxLayers.tsx +40 -0
  129. package/src/lightbox/index.ts +8 -0
  130. package/src/lightbox/modal.ts +94 -0
  131. package/src/loupe/AGENTS.md +40 -2
  132. package/src/loupe/CanvasLoupe.tsx +5 -4
  133. package/src/loupe/DomLoupe.tsx +7 -5
  134. package/src/loupe/Loupe.less +15 -0
  135. package/src/loupe/LoupeBubble.tsx +35 -13
  136. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  137. package/src/loupe/SourceLoupe.tsx +60 -0
  138. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  139. package/src/loupe/TrialLoupe.tsx +126 -28
  140. package/src/loupe/canvasLens.ts +20 -17
  141. package/src/loupe/index.ts +26 -2
  142. package/src/loupe/sourceLens.test.ts +65 -0
  143. package/src/loupe/sourceLens.ts +97 -0
  144. package/src/loupe/types.test.ts +6 -0
  145. package/src/loupe/types.ts +109 -7
  146. package/src/loupe/useLoupe.test.tsx +188 -3
  147. package/src/loupe/useLoupe.ts +106 -11
  148. package/src/primitives/index.ts +2 -2
  149. package/src/specimen/Specimen.tsx +0 -7
  150. package/src/state/helpers.test.ts +4 -11
  151. package/src/state/helpers.ts +2 -8
  152. package/src/state/index.ts +0 -2
  153. package/src/state/openLabStore.test.ts +10 -8
  154. package/src/state/openLabStore.ts +1 -1
  155. package/src/state/store.test.ts +2 -2
  156. package/src/state/store.ts +15 -16
  157. package/src/state/types.ts +9 -9
  158. package/src/styles.less +2 -1
  159. package/src/surface/AGENTS.md +13 -0
  160. package/src/surface/index.ts +1 -0
  161. package/src/surface/useSurfaceTile.test.tsx +4 -0
  162. package/src/surface/useSurfaceTile.ts +17 -1
  163. package/src/surface/useTiledSurface.test.tsx +65 -0
  164. package/src/surface/useTiledSurface.ts +68 -1
  165. package/src/trial/Trial.stories.tsx +0 -1
  166. package/src/trial/Trial.test.tsx +40 -3
  167. package/src/trial/Trial.tsx +87 -43
  168. package/src/trial/TrialChrome.tsx +20 -2
  169. package/src/trial/trialOps.test.ts +1 -2
  170. package/src/trial/trialOps.ts +2 -3
  171. package/src/undo/stateOp.ts +21 -0
  172. package/src/undo/undoStack.ts +18 -6
  173. package/dist/chunk-3L4MLLQB.js.map +0 -1
  174. package/dist/chunk-7KAETQSS.js.map +0 -1
  175. package/dist/chunk-EESJUSYR.js.map +0 -1
  176. package/dist/chunk-J5HJJDLF.js.map +0 -1
  177. package/dist/chunk-NVKBL7ZE.js.map +0 -1
  178. package/dist/chunk-PMLZP7RT.js.map +0 -1
  179. package/dist/chunk-RL2LOLNI.js.map +0 -1
  180. package/dist/chunk-T6DLGEJB.js.map +0 -1
  181. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  182. package/dist/chunk-WURZTJGQ.js.map +0 -1
  183. package/dist/chunk-XXACAPVU.js.map +0 -1
  184. package/dist/chunk-YDE6XATS.js.map +0 -1
  185. package/dist/chunk-YX75W6EK.js.map +0 -1
  186. package/src/primitives/StatusBar.less +0 -34
  187. package/src/primitives/StatusBar.stories.tsx +0 -24
  188. package/src/primitives/StatusBar.test.tsx +0 -25
  189. package/src/primitives/StatusBar.tsx +0 -28
@@ -0,0 +1,195 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Lightbox } from './Lightbox';
5
+ import { useLightboxControl } from './LightboxContext';
6
+
7
+ function root(): HTMLElement {
8
+ return document.querySelector('.lk-lightbox') as HTMLElement;
9
+ }
10
+
11
+ const expanded = (): boolean => root().classList.contains('lk-lightbox--expanded');
12
+
13
+ describe('Lightbox', () => {
14
+ it('does not open on a double-click unless asked to', () => {
15
+ render(
16
+ <Lightbox>
17
+ <div data-testid="out">output</div>
18
+ </Lightbox>,
19
+ );
20
+ fireEvent.doubleClick(screen.getByTestId('out'));
21
+ expect(expanded()).toBe(false);
22
+ expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument();
23
+ });
24
+
25
+ it('opens on a double-click on its content when asked to, and closes on another', () => {
26
+ render(
27
+ <Lightbox expandOnDoubleClick>
28
+ <div data-testid="out">output</div>
29
+ </Lightbox>,
30
+ );
31
+ fireEvent.doubleClick(screen.getByTestId('out'));
32
+ expect(expanded()).toBe(true);
33
+ expect(root().getAttribute('role')).toBe('dialog');
34
+ fireEvent.doubleClick(screen.getByTestId('out'));
35
+ expect(expanded()).toBe(false);
36
+ });
37
+
38
+ it('ignores a double-click on a control, on an opted-out region, or one already handled', () => {
39
+ render(
40
+ <Lightbox expandOnDoubleClick>
41
+ <button type="button">Run</button>
42
+ <input aria-label="n" />
43
+ <div data-lk-lightbox-ignore="">
44
+ <span data-testid="ignored">x</span>
45
+ </div>
46
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the subject is a handler that claims the gesture */}
47
+ <div data-testid="claimed" onDoubleClick={(e) => e.preventDefault()} />
48
+ </Lightbox>,
49
+ );
50
+ fireEvent.doubleClick(screen.getByRole('button', { name: 'Run' }));
51
+ fireEvent.doubleClick(screen.getByLabelText('n'));
52
+ fireEvent.doubleClick(screen.getByTestId('ignored'));
53
+ fireEvent.doubleClick(screen.getByTestId('claimed'));
54
+ expect(expanded()).toBe(false);
55
+ });
56
+
57
+ it('opens from the expand button and moves focus to the close button, then back', () => {
58
+ render(
59
+ <Lightbox>
60
+ <div>output</div>
61
+ </Lightbox>,
62
+ );
63
+ const open = screen.getByRole('button', { name: 'Expand' });
64
+ open.focus();
65
+ fireEvent.click(open);
66
+ const close = screen.getByRole('button', { name: 'Close expanded view' });
67
+ expect(document.activeElement).toBe(close);
68
+ fireEvent.click(close);
69
+ expect(expanded()).toBe(false);
70
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Expand' }));
71
+ });
72
+
73
+ it('closes on Escape unless something inside handled it', () => {
74
+ render(
75
+ <Lightbox>
76
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stands in for an overlay that consumes its own Escape */}
77
+ <div data-testid="menu" onKeyDown={(e) => e.preventDefault()} />
78
+ </Lightbox>,
79
+ );
80
+ fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
81
+ fireEvent.keyDown(screen.getByTestId('menu'), { key: 'Escape' });
82
+ expect(expanded()).toBe(true);
83
+ fireEvent.keyDown(document.body, { key: 'Escape' });
84
+ expect(expanded()).toBe(false);
85
+ });
86
+
87
+ it('closes on a click in the margin, not on one in the content', () => {
88
+ render(
89
+ <Lightbox>
90
+ <div data-testid="out">output</div>
91
+ </Lightbox>,
92
+ );
93
+ fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
94
+ fireEvent.click(screen.getByTestId('out'));
95
+ expect(expanded()).toBe(true);
96
+ fireEvent.click(root());
97
+ expect(expanded()).toBe(false);
98
+ });
99
+
100
+ it('makes everything outside it inert while open, and only what it marked', () => {
101
+ render(
102
+ <div>
103
+ <aside data-testid="already" inert />
104
+ <nav data-testid="sibling" />
105
+ <Lightbox>
106
+ <div>output</div>
107
+ </Lightbox>
108
+ </div>,
109
+ );
110
+ fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
111
+ expect(screen.getByTestId('sibling').hasAttribute('inert')).toBe(true);
112
+ fireEvent.keyDown(document.body, { key: 'Escape' });
113
+ expect(screen.getByTestId('sibling').hasAttribute('inert')).toBe(false);
114
+ expect(screen.getByTestId('already').hasAttribute('inert')).toBe(true);
115
+ });
116
+
117
+ it('marks itself a portal host only while open, so overlays stack inside it', () => {
118
+ render(
119
+ <Lightbox>
120
+ <div>output</div>
121
+ </Lightbox>,
122
+ );
123
+ expect(root().hasAttribute('data-wzl-portal-host')).toBe(false);
124
+ fireEvent.click(screen.getByRole('button', { name: 'Expand' }));
125
+ expect(root().hasAttribute('data-wzl-portal-host')).toBe(true);
126
+ });
127
+
128
+ it('drops its corner button while a descendant draws the control', () => {
129
+ function OwnControl() {
130
+ const lb = useLightboxControl();
131
+ return (
132
+ <button type="button" onClick={lb?.toggle}>
133
+ Mine
134
+ </button>
135
+ );
136
+ }
137
+ render(
138
+ <Lightbox>
139
+ <OwnControl />
140
+ </Lightbox>,
141
+ );
142
+ expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
143
+ fireEvent.click(screen.getByRole('button', { name: 'Mine' }));
144
+ expect(expanded()).toBe(true);
145
+ });
146
+
147
+ it('can be controlled', () => {
148
+ const onChange = vi.fn();
149
+ function Host() {
150
+ const [open, setOpen] = useState(true);
151
+ return (
152
+ <Lightbox
153
+ expanded={open}
154
+ onExpandedChange={(next) => {
155
+ onChange(next);
156
+ setOpen(next);
157
+ }}
158
+ >
159
+ <div>output</div>
160
+ </Lightbox>
161
+ );
162
+ }
163
+ render(<Host />);
164
+ expect(expanded()).toBe(true);
165
+ fireEvent.keyDown(document.body, { key: 'Escape' });
166
+ expect(onChange).toHaveBeenCalledWith(false);
167
+ expect(expanded()).toBe(false);
168
+ });
169
+
170
+ it('keeps its content mounted when it is turned off', () => {
171
+ let mounts = 0;
172
+ function Content() {
173
+ useState(() => {
174
+ mounts += 1;
175
+ });
176
+ return <div data-testid="out" />;
177
+ }
178
+ const { rerender } = render(
179
+ <Lightbox expandOnDoubleClick>
180
+ <Content />
181
+ </Lightbox>,
182
+ );
183
+ act(() => {
184
+ rerender(
185
+ <Lightbox disabled expandOnDoubleClick>
186
+ <Content />
187
+ </Lightbox>,
188
+ );
189
+ });
190
+ fireEvent.doubleClick(screen.getByTestId('out'));
191
+ expect(expanded()).toBe(false);
192
+ expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
193
+ expect(mounts).toBe(1);
194
+ });
195
+ });
@@ -0,0 +1,205 @@
1
+ import { useLatest } from '@weasel-js/core';
2
+ import { CloseIcon, FullscreenIcon } from '@weasel-js/ui';
3
+ import {
4
+ type MouseEvent,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
14
+ import { LightboxContext, type LightboxContextValue } from './LightboxContext';
15
+ import { useLightboxLayers } from './LightboxLayers';
16
+ import { inertOutside, liftStack, opensLightbox } from './modal';
17
+
18
+ /** Props for `<Lightbox>`. */
19
+ export interface LightboxProps {
20
+ children: ReactNode;
21
+ /** Names the expanded view for assistive tech. */
22
+ label?: string;
23
+ /** Turns the lightbox off: no expand button, no double-click, and an open
24
+ * one closes. The content stays mounted either way. */
25
+ disabled?: boolean;
26
+ /** Let a double-click on the content open it, and another close it. Off by
27
+ * default — the expand button is always there; a double-click is for
28
+ * content that has no other use for one, such as an image. */
29
+ expandOnDoubleClick?: boolean;
30
+ /** Controlled open state. Omit to let the lightbox keep its own. */
31
+ expanded?: boolean;
32
+ onExpandedChange?: (expanded: boolean) => void;
33
+ /** Extra class on the outer element. */
34
+ className?: string;
35
+ }
36
+
37
+ /** What the root becomes while open: a modal dialog, and the host overlays
38
+ * opened inside it portal into, so they stack above it. */
39
+ function dialogProps(label: string) {
40
+ return {
41
+ role: 'dialog',
42
+ 'aria-modal': true,
43
+ 'aria-label': label,
44
+ 'data-wzl-portal-host': '',
45
+ } as const;
46
+ }
47
+
48
+ /**
49
+ * Shows its content large, over a dimmed page, from the expand button in its
50
+ * corner — or a double-click, where `expandOnDoubleClick` asks for one —
51
+ * without remounting it. The same element is lifted into the browser's top
52
+ * layer (`popover="manual"`), so a WebGL context or a scene inside keeps
53
+ * running and sees only a bigger box: anything sizing itself from a
54
+ * `ResizeObserver` follows on its own. The top layer also escapes an
55
+ * ancestor's transform, clipping, and stacking.
56
+ *
57
+ * Layers declared by a `<LightboxLayers>` above are lifted around it, and a
58
+ * labkit surface above is scoped to it while it is open, so pixels drawn for
59
+ * the content from outside it come along.
60
+ *
61
+ * Escape, a click on the dimmed margin, the close button, or another
62
+ * double-click where those open it closes it, and focus returns to wherever it
63
+ * was. A double-click on a control, inside `[data-lk-lightbox-ignore]`, or one
64
+ * a handler further in called `preventDefault()` on does not open it.
65
+ */
66
+ export function Lightbox({
67
+ children,
68
+ label = 'Expanded view',
69
+ disabled = false,
70
+ expandOnDoubleClick = false,
71
+ expanded: expandedProp,
72
+ onExpandedChange,
73
+ className,
74
+ }: LightboxProps) {
75
+ const [own, setOwn] = useState(false);
76
+ const expanded = !disabled && (expandedProp ?? own);
77
+ const onChangeRef = useLatest(onExpandedChange);
78
+ const expandedRef = useLatest(expanded);
79
+ const rootRef = useRef<HTMLDivElement | null>(null);
80
+ const closeRef = useRef<HTMLButtonElement | null>(null);
81
+ const [claims, setClaims] = useState(0);
82
+ const layers = useLightboxLayers();
83
+ const surface = useSurfaceOptional();
84
+
85
+ const set = useCallback(
86
+ (next: boolean) => {
87
+ if (next === expandedRef.current) return;
88
+ setOwn(next);
89
+ onChangeRef.current?.(next);
90
+ },
91
+ [expandedRef, onChangeRef],
92
+ );
93
+
94
+ const ctx = useMemo<LightboxContextValue | null>(
95
+ () =>
96
+ disabled
97
+ ? null
98
+ : {
99
+ expanded,
100
+ open: () => set(true),
101
+ close: () => set(false),
102
+ toggle: () => set(!expandedRef.current),
103
+ claimControl: () => {
104
+ setClaims((n) => n + 1);
105
+ return () => setClaims((n) => n - 1);
106
+ },
107
+ },
108
+ [disabled, expanded, set, expandedRef],
109
+ );
110
+
111
+ // Lift, trap and focus before paint, so the first frame is already the
112
+ // expanded one. The popover attribute is set here rather than rendered: a
113
+ // closed popover is `display: none`, which would hide the tile.
114
+ useLayoutEffect(() => {
115
+ const el = rootRef.current;
116
+ if (!expanded || !el) return;
117
+ const returnTo = document.activeElement as HTMLElement | null;
118
+ const stack = [...layers.below, el, ...layers.above].filter(
119
+ (layer): layer is HTMLElement => layer !== null,
120
+ );
121
+ const unlift = liftStack(stack, el);
122
+ const release = inertOutside(stack);
123
+ // A shared buffer lifted with this one must stop painting every other tile
124
+ // over it.
125
+ surface?.scope(el);
126
+ closeRef.current?.focus({ preventScroll: true });
127
+ return () => {
128
+ surface?.scope(null);
129
+ release();
130
+ unlift();
131
+ if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
132
+ };
133
+ }, [expanded, layers, surface]);
134
+
135
+ useEffect(() => {
136
+ if (!expanded) return;
137
+ const onKey = (e: KeyboardEvent): void => {
138
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
139
+ e.preventDefault();
140
+ set(false);
141
+ };
142
+ document.addEventListener('keydown', onKey);
143
+ return () => document.removeEventListener('keydown', onKey);
144
+ }, [expanded, set]);
145
+
146
+ const onDoubleClick = (e: MouseEvent<HTMLDivElement>): void => {
147
+ if (disabled || !expandOnDoubleClick || !opensLightbox(e, e.currentTarget)) return;
148
+ e.preventDefault();
149
+ set(!expanded);
150
+ };
151
+
152
+ // Only the margin around the frame is the root's own surface.
153
+ const onClick = (e: MouseEvent<HTMLDivElement>): void => {
154
+ if (expanded && e.target === e.currentTarget) set(false);
155
+ };
156
+
157
+ const classes = ['lk-lightbox'];
158
+ if (expanded) classes.push('lk-lightbox--expanded');
159
+ if (className) classes.push(className);
160
+
161
+ let control: ReactNode = null;
162
+ if (expanded) {
163
+ control = (
164
+ <button
165
+ ref={closeRef}
166
+ type="button"
167
+ className="lk-lightbox__button lk-lightbox__close"
168
+ aria-label="Close expanded view"
169
+ title="Close (Esc)"
170
+ onClick={() => set(false)}
171
+ >
172
+ <CloseIcon size={16} />
173
+ </button>
174
+ );
175
+ } else if (!disabled && claims === 0) {
176
+ control = (
177
+ <button
178
+ type="button"
179
+ className="lk-lightbox__button lk-lightbox__open"
180
+ aria-label="Expand"
181
+ title="Expand"
182
+ onClick={() => set(true)}
183
+ >
184
+ <FullscreenIcon size={16} />
185
+ </button>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <LightboxContext.Provider value={ctx}>
191
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the double-click is an opt-in shortcut; the expand and close buttons are the keyboard path */}
192
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: the margin click mirrors Escape, which is handled on the document */}
193
+ <div
194
+ ref={rootRef}
195
+ className={classes.join(' ')}
196
+ {...(expanded ? dialogProps(label) : null)}
197
+ onDoubleClick={onDoubleClick}
198
+ onClick={onClick}
199
+ >
200
+ <div className="lk-lightbox__frame">{children}</div>
201
+ {control}
202
+ </div>
203
+ </LightboxContext.Provider>
204
+ );
205
+ }
@@ -0,0 +1,38 @@
1
+ import { createContext, useContext, useLayoutEffect } from 'react';
2
+
3
+ /** What a `<Lightbox>` offers the content inside it. */
4
+ export interface LightboxApi {
5
+ /** Whether the content is showing in the lightbox right now. */
6
+ expanded: boolean;
7
+ open: () => void;
8
+ close: () => void;
9
+ toggle: () => void;
10
+ }
11
+
12
+ /** The context value: the public API plus the registration the
13
+ * default affordance steps aside for. */
14
+ export interface LightboxContextValue extends LightboxApi {
15
+ claimControl: () => () => void;
16
+ }
17
+
18
+ export const LightboxContext = createContext<LightboxContextValue | null>(null);
19
+
20
+ /** The nearest `<Lightbox>`, or `null` outside one. Reading it changes
21
+ * nothing; see `useLightboxControl` to replace the default expand button. */
22
+ export function useLightbox(): LightboxApi | null {
23
+ return useContext(LightboxContext);
24
+ }
25
+
26
+ /**
27
+ * The nearest `<Lightbox>`, for a component that draws its own expand
28
+ * control: while a caller is mounted, the lightbox hides its default corner
29
+ * button so the tile does not offer two. `<Trial>` uses this to put the
30
+ * control in its title bar.
31
+ */
32
+ export function useLightboxControl(): LightboxApi | null {
33
+ const ctx = useContext(LightboxContext);
34
+ const claim = ctx?.claimControl;
35
+ // Layout effect, so the corner button is gone before the first paint.
36
+ useLayoutEffect(() => claim?.(), [claim]);
37
+ return ctx;
38
+ }
@@ -0,0 +1,40 @@
1
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
2
+
3
+ /** Elements a lightbox lifts with it, in top-layer order around itself. A
4
+ * null entry is skipped, so a ref that has not landed yet can be passed as
5
+ * it is. */
6
+ export interface LightboxLayerSet {
7
+ below: readonly (HTMLElement | null)[];
8
+ above: readonly (HTMLElement | null)[];
9
+ }
10
+
11
+ const NONE: LightboxLayerSet = { below: [], above: [] };
12
+
13
+ const LightboxLayersContext = createContext<LightboxLayerSet>(NONE);
14
+
15
+ /** Props for `<LightboxLayers>`. Keep the arrays stable across renders. */
16
+ export interface LightboxLayersProps extends Partial<LightboxLayerSet> {
17
+ children: ReactNode;
18
+ }
19
+
20
+ /**
21
+ * Declares layers that belong to every lightbox below it: something drawn
22
+ * for the content from outside it, such as a shared buffer the tiles paint
23
+ * into. An open lightbox lifts each `below` layer, then itself, then each
24
+ * `above` layer into the top layer, each covering the window, so they keep
25
+ * stacking as they did. A layer is measured against the window while lifted,
26
+ * so whatever positions things inside it must re-measure — a labkit surface
27
+ * whose container is one of the layers does.
28
+ */
29
+ export function LightboxLayers({ below, above, children }: LightboxLayersProps) {
30
+ const value = useMemo(
31
+ () => ({ below: below ?? NONE.below, above: above ?? NONE.above }),
32
+ [below, above],
33
+ );
34
+ return <LightboxLayersContext.Provider value={value}>{children}</LightboxLayersContext.Provider>;
35
+ }
36
+
37
+ /** The layers declared above, or none. */
38
+ export function useLightboxLayers(): LightboxLayerSet {
39
+ return useContext(LightboxLayersContext);
40
+ }
@@ -0,0 +1,8 @@
1
+ export { Lightbox, type LightboxProps } from './Lightbox';
2
+ export { type LightboxApi, useLightbox, useLightboxControl } from './LightboxContext';
3
+ export {
4
+ type LightboxLayerSet,
5
+ LightboxLayers,
6
+ type LightboxLayersProps,
7
+ useLightboxLayers,
8
+ } from './LightboxLayers';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Elements whose double-click already means something: a press there edits,
3
+ * selects text, or works a control. `data-lk-lightbox-ignore` is the opt-out
4
+ * for anything else.
5
+ */
6
+ const OWN_DOUBLE_CLICK = [
7
+ 'button',
8
+ 'a[href]',
9
+ 'input',
10
+ 'select',
11
+ 'textarea',
12
+ 'label',
13
+ 'summary',
14
+ '[contenteditable]:not([contenteditable="false"])',
15
+ '[role="button"]',
16
+ '[role="checkbox"]',
17
+ '[role="combobox"]',
18
+ '[role="link"]',
19
+ '[role="menuitem"]',
20
+ '[role="option"]',
21
+ '[role="radio"]',
22
+ '[role="separator"]',
23
+ '[role="slider"]',
24
+ '[role="spinbutton"]',
25
+ '[role="switch"]',
26
+ '[role="tab"]',
27
+ '[role="textbox"]',
28
+ '[data-lk-lightbox-ignore]',
29
+ ].join(',');
30
+
31
+ /** Whether a double-click that reached `root` should open its lightbox:
32
+ * nothing handled it first, and it did not land on a control. */
33
+ export function opensLightbox(
34
+ event: { target: EventTarget | null; defaultPrevented: boolean },
35
+ root: Element,
36
+ ): boolean {
37
+ if (event.defaultPrevented) return false;
38
+ const target = event.target;
39
+ if (!(target instanceof Element)) return true;
40
+ const owner = target.closest(OWN_DOUBLE_CLICK);
41
+ return owner === null || !root.contains(owner);
42
+ }
43
+
44
+ /**
45
+ * Marks everything outside `keep` inert — every sibling of each kept element
46
+ * and of each of their ancestors, up to the body — and returns the undo.
47
+ * Leaves alone what was already inert, so the undo cannot clear someone
48
+ * else's.
49
+ */
50
+ export function inertOutside(keep: readonly Element[]): () => void {
51
+ const path = new Set<Element>();
52
+ for (const el of keep) {
53
+ for (let node: Element | null = el; node && node !== document.body; node = node.parentElement) {
54
+ path.add(node);
55
+ }
56
+ }
57
+ const marked: Element[] = [];
58
+ for (const node of path) {
59
+ const parent: Element | null = node.parentElement;
60
+ if (!parent) continue;
61
+ for (const sibling of Array.from(parent.children)) {
62
+ if (path.has(sibling) || sibling.hasAttribute('inert')) continue;
63
+ sibling.setAttribute('inert', '');
64
+ marked.push(sibling);
65
+ }
66
+ }
67
+ return () => {
68
+ for (const sibling of marked) sibling.removeAttribute('inert');
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Lifts `stack` into the top layer in order, so the first ends up lowest, and
74
+ * returns the undo. The first carries the dimming. Every member but `self` is
75
+ * marked a layer, which the stylesheet stretches over the window. Does nothing
76
+ * where the browser has no popover support.
77
+ */
78
+ export function liftStack(stack: readonly HTMLElement[], self: HTMLElement): () => void {
79
+ if (typeof self.showPopover !== 'function') return () => {};
80
+ stack.forEach((el, i) => {
81
+ el.setAttribute('popover', 'manual');
82
+ if (el !== self) el.setAttribute('data-lk-lightbox-layer', '');
83
+ if (i === 0) el.setAttribute('data-lk-lightbox-scrim', '');
84
+ el.showPopover();
85
+ });
86
+ return () => {
87
+ for (const el of [...stack].reverse()) {
88
+ if (el.matches(':popover-open')) el.hidePopover();
89
+ el.removeAttribute('popover');
90
+ el.removeAttribute('data-lk-lightbox-layer');
91
+ el.removeAttribute('data-lk-lightbox-scrim');
92
+ }
93
+ };
94
+ }
@@ -18,10 +18,12 @@ This directory binds that model to a labkit trial and draws it.
18
18
  | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
19
19
  | `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
20
20
  | `TrialLoupe.tsx` | Finds its host and the trial's switch, picks the painter, mounts the lens |
21
- | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
21
+ | `LoupeBubble.tsx` | The circle or square clip, positioned on the lens's center |
22
22
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
23
23
  | `canvasLens.ts` | That painter's geometry and drawing, with no React in it |
24
24
  | `DomLoupe.tsx` | Painter for DOM content |
25
+ | `SourceLoupe.tsx` | Painter for any canvas named by `source` |
26
+ | `sourceLens.ts` | That painter's source resolution, box measurement and drawing |
25
27
  | `useHostSize.ts` | The host's measured box, for the DOM stage |
26
28
 
27
29
  ## How it reads the trial
@@ -48,11 +50,42 @@ content again; a DOM loupe is always `vector`, since DOM has no framebuffer to
48
50
  enlarge. The canvas painter needs the stack's own pixels, which is why a
49
51
  drawing instrument mounts the lens in its `render`, inside `<CanvasStack>`.
50
52
 
53
+ `source` names a canvas labkit need not own, and wins over the stack: the lens
54
+ enlarges that canvas's pixels (`SourceLoupe`), or, beside `render`, only
55
+ samples its color. The capture rule for WebGL lives with `createCanvasSource`
56
+ in `@weasel-js/loupe`.
57
+
58
+ ## Where the lens goes
59
+
60
+ By default a lens is a `diameter` box centered on the aim. `place` lets the
61
+ host put it elsewhere per aim — a box of any size at `center`, showing what is
62
+ around `shows` at its own `factor` — and `placeBand` (from `@weasel-js/loupe`)
63
+ is the stock fit of a region shown whole. `useLoupe` resolves the result into
64
+ `loupe.lens`; the bubble is drawn at `lens.center` and the painters are aimed at
65
+ `lens.shows`, which are different points once a lens is moved to stay on the
66
+ host. `onColorChange` still reports the aim.
67
+
68
+ `hollow` runs no painter and leaves the bubble's inside clear, for a host that
69
+ draws the magnified view itself; `onLens` tells it the box, on each change in
70
+ value and with `null` once a lens that was up goes away.
71
+
51
72
  ## Traps
52
73
 
74
+ **A placed lens must reach the model, not only the painters.** `pick` maps a
75
+ point inside the lens back to the page through `LoupeSurface.lens()`; the rect
76
+ it returns carries `shows` and `factor`, and a lens drawn somewhere the model
77
+ does not know about picks colors from the wrong place with every test of the
78
+ drawing still green.
79
+
53
80
  **jsdom cannot see magnification.** Everything assertable there is state — aim
54
81
  moved, factor clamped, mode switched, the lens raised and put away. That the
55
- lens shows the right region is a screenshot.
82
+ lens shows the right region is a screenshot, or a pixel read in a browser test
83
+ (`SourceLoupe.browser.test.tsx`).
84
+
85
+ **Retain a source by its resolved identity, not the prop.** An inline
86
+ `source={() => ref.current}` is a new function every render, and every aim
87
+ re-renders. Keyed on the prop, the lens released and re-took the source on each
88
+ aim, and each first reader marks a captured frame stale.
56
89
 
57
90
  **Do not dispose the model when React unmounts.** `dispose` is one-way, and
58
91
  StrictMode mounts / unmounts / mounts every effect — so disposing in the
@@ -70,6 +103,11 @@ reason and the dispatcher falls through to the zoom. Outside a camera — the DO
70
103
  `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
71
104
  `pointerleave` stay hand-attached in `useLoupe`. Everything else routes.
72
105
 
106
+ **The model ignores aims while the lens is down.** So a lens that comes up
107
+ under a still pointer — the peek key, or the lens turned on — has had no aim
108
+ since the last time it was up. `useLoupe` keeps the pointer's last spot over the
109
+ host and aims there as the lens rises; without it the lens opens at 0,0.
110
+
73
111
  **`<LoupeGestures>` mounts outside the visibility gate.** Hold-to-peek is what
74
112
  raises a lens that is down; gate its registration on `loupe.visible` and the
75
113
  peek key stops working entirely.
@@ -1,5 +1,5 @@
1
1
  import { useLatest, useVisibleRaf } from '@weasel-js/core';
2
- import type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
2
+ import { type LoupeMode, type LoupePoint, type LoupeSize, loupeExtent } from '@weasel-js/loupe';
3
3
  import { useEffect, useRef } from 'react';
4
4
  import type { CanvasStackSurface } from '../canvas/CanvasStackContext';
5
5
  import type { WorldFrame, WorldSpec } from '../canvas/worldSpec';
@@ -11,7 +11,8 @@ export interface CanvasLoupeProps {
11
11
  aim: LoupePoint;
12
12
  factor: number;
13
13
  mode: LoupeMode;
14
- diameter: number;
14
+ /** One number for a round or square lens, or a width and a height. */
15
+ diameter: LoupeSize;
15
16
  /** The stack being magnified: its layers, its pixels, and its measured box. */
16
17
  surface: CanvasStackSurface;
17
18
  view: ViewTransform;
@@ -75,8 +76,8 @@ export function CanvasLoupe({
75
76
  <canvas
76
77
  ref={canvasRef}
77
78
  className="lk-loupe__canvas"
78
- width={Math.max(1, Math.round(diameter * dpr))}
79
- height={Math.max(1, Math.round(diameter * dpr))}
79
+ width={Math.max(1, Math.round(loupeExtent(diameter).width * dpr))}
80
+ height={Math.max(1, Math.round(loupeExtent(diameter).height * dpr))}
80
81
  />
81
82
  );
82
83
  }