@weasel-js/labkit 1.5.2 → 1.6.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 (217) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  3. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  4. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +83 -0
  5. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  6. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +404 -0
  7. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-Cr8-zoup.d.ts} +105 -15
  8. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  9. package/dist/_dts/{index-DlKolEIZ.d.ts → index-CKyX0ME_.d.ts} +12 -13
  10. package/dist/_dts/{frac-B4hFJMiN.d.ts → types-B7yBTTF9.d.ts} +709 -651
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-DTuViBvc.d.ts} +21 -2
  12. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +41 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +6 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-2RVCB2PT.js +3 -0
  20. package/dist/chunk-2RVCB2PT.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/{chunk-CKUKF72R.js → chunk-4TT3PNPR.js} +68 -55
  24. package/dist/chunk-4TT3PNPR.js.map +1 -0
  25. package/dist/{chunk-KJCUERQF.js → chunk-6FLXL3I7.js} +9 -164
  26. package/dist/chunk-6FLXL3I7.js.map +1 -0
  27. package/dist/chunk-ASOK76ZH.js +431 -0
  28. package/dist/chunk-ASOK76ZH.js.map +1 -0
  29. package/dist/chunk-AWQG7FCC.js +19 -0
  30. package/dist/chunk-AWQG7FCC.js.map +1 -0
  31. package/dist/chunk-CGHF6RZV.js +300 -0
  32. package/dist/chunk-CGHF6RZV.js.map +1 -0
  33. package/dist/chunk-DWMSB4CA.js +228 -0
  34. package/dist/chunk-DWMSB4CA.js.map +1 -0
  35. package/dist/chunk-GHGO52SO.js +92 -0
  36. package/dist/chunk-GHGO52SO.js.map +1 -0
  37. package/dist/{chunk-LRI3MBGR.js → chunk-HKV7LRHY.js} +4 -4
  38. package/dist/{chunk-LRI3MBGR.js.map → chunk-HKV7LRHY.js.map} +1 -1
  39. package/dist/{chunk-EMXAIRUJ.js → chunk-IRTGUQ5O.js} +5 -4
  40. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/chunk-KHK5FGER.js +145 -0
  44. package/dist/chunk-KHK5FGER.js.map +1 -0
  45. package/dist/chunk-NEXR37YJ.js +144 -0
  46. package/dist/chunk-NEXR37YJ.js.map +1 -0
  47. package/dist/{chunk-A2ACAGW6.js → chunk-O6FFFOHS.js} +32 -7
  48. package/dist/chunk-O6FFFOHS.js.map +1 -0
  49. package/dist/chunk-QGMOA2YE.js +731 -0
  50. package/dist/chunk-QGMOA2YE.js.map +1 -0
  51. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  52. package/dist/chunk-S4NCI32V.js.map +1 -0
  53. package/dist/{chunk-753Y6HE6.js → chunk-SLAQPUI4.js} +4 -4
  54. package/dist/chunk-SLAQPUI4.js.map +1 -0
  55. package/dist/{chunk-REOBGYMC.js → chunk-TJ77A4A3.js} +3 -5
  56. package/dist/chunk-TJ77A4A3.js.map +1 -0
  57. package/dist/chunk-TYN6MLXX.js +13864 -0
  58. package/dist/chunk-TYN6MLXX.js.map +1 -0
  59. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  60. package/dist/chunk-WPLAANH4.js.map +1 -0
  61. package/dist/config/index.d.ts +51 -7
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +92 -5
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -6
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +108 -154
  68. package/dist/index.js +456 -600
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +5 -35
  71. package/dist/layers/index.js +3 -3
  72. package/dist/loupe/index.d.ts +21 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +288 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +434 -231
  78. package/dist/passthrough/weasel-ui.js +2 -1
  79. package/dist/primitives/index.d.ts +19 -54
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -7
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +360 -174
  85. package/dist/undo/index.d.ts +5 -4
  86. package/package.json +13 -13
  87. package/src/annotations/frac.test.ts +7 -0
  88. package/src/annotations/index.ts +1 -0
  89. package/src/annotations/paint.test.ts +21 -0
  90. package/src/annotations/paint.ts +12 -0
  91. package/src/annotations/store.test.ts +57 -0
  92. package/src/annotations/store.ts +25 -6
  93. package/src/annotations/toolMap.test.ts +37 -2
  94. package/src/annotations/toolMap.ts +13 -0
  95. package/src/annotations/types.ts +30 -2
  96. package/src/canvas/AGENTS.md +8 -10
  97. package/src/canvas/CameraInput.test.tsx +136 -0
  98. package/src/canvas/CameraInput.tsx +311 -0
  99. package/src/canvas/CanvasStack.tsx +42 -58
  100. package/src/canvas/LinkedCursor.less +16 -0
  101. package/src/canvas/LinkedCursor.tsx +54 -0
  102. package/src/canvas/Stage.test.tsx +18 -0
  103. package/src/canvas/Stage.tsx +42 -37
  104. package/src/canvas/cameraView.test.ts +60 -0
  105. package/src/canvas/cameraView.ts +61 -0
  106. package/src/canvas/index.ts +11 -2
  107. package/src/chrome/regions/SidebarRegion.less +197 -0
  108. package/src/chrome/regions/SidebarRegion.test.tsx +27 -0
  109. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  110. package/src/chrome/types.ts +5 -2
  111. package/src/config/autoConfig.ts +2 -2
  112. package/src/config/builder.ts +21 -2
  113. package/src/config/entry.test.ts +10 -0
  114. package/src/config/fromConfigField.ts +1 -0
  115. package/src/config/index.ts +5 -0
  116. package/src/config/resolve.ts +11 -1
  117. package/src/config/rules.ts +2 -0
  118. package/src/config/sectionTree.test.ts +152 -0
  119. package/src/config/sectionTree.ts +134 -0
  120. package/src/config/types.ts +21 -0
  121. package/src/controls/ControlPanel.less +4 -0
  122. package/src/controls/ControlPanel.stories.tsx +55 -2
  123. package/src/controls/ControlPanel.test.tsx +40 -3
  124. package/src/controls/ControlPanel.tsx +134 -336
  125. package/src/controls/inDialog.test.tsx +100 -0
  126. package/src/controls/inDialog.tsx +38 -0
  127. package/src/controls/index.ts +1 -0
  128. package/src/index.test.ts +1 -1
  129. package/src/index.ts +18 -29
  130. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  131. package/src/instrument/types.ts +15 -3
  132. package/src/lab/Lab.nebula.test.tsx +40 -0
  133. package/src/lab/Lab.test.tsx +3 -1
  134. package/src/lab/Lab.tsx +42 -19
  135. package/src/lab/LabBoundary.test.tsx +66 -0
  136. package/src/lab/LabBoundary.tsx +56 -0
  137. package/src/lab/LabHeader.tsx +4 -23
  138. package/src/lab/LabRoot.tsx +2 -3
  139. package/src/lab/LabShell.less +0 -1
  140. package/src/lab/LabShell.stories.tsx +15 -0
  141. package/src/lab/LabShell.test.tsx +18 -0
  142. package/src/lab/LabShell.tsx +14 -1
  143. package/src/lab/LabSwitcher.less +1 -1
  144. package/src/lab/LabSwitcher.test.tsx +41 -0
  145. package/src/lab/LabSwitcher.tsx +30 -8
  146. package/src/lab/index.ts +1 -0
  147. package/src/layers/AGENTS.md +18 -74
  148. package/src/layers/index.ts +4 -2
  149. package/src/loupe/AGENTS.md +7 -6
  150. package/src/loupe/LoupeGestures.tsx +14 -9
  151. package/src/loupe/TrialLoupe.tsx +5 -5
  152. package/src/loupe/index.ts +1 -0
  153. package/src/loupe/loupeActions.ts +6 -2
  154. package/src/overview/AGENTS.md +38 -0
  155. package/src/overview/OverviewMarks.test.tsx +100 -0
  156. package/src/overview/OverviewMarks.tsx +93 -0
  157. package/src/overview/TrialOverview.less +53 -0
  158. package/src/overview/TrialOverview.stories.tsx +50 -0
  159. package/src/overview/TrialOverview.test.tsx +116 -0
  160. package/src/overview/TrialOverview.tsx +374 -0
  161. package/src/overview/index.ts +2 -0
  162. package/src/passthrough/weasel-ui.ts +32 -10
  163. package/src/primitives/index.ts +0 -2
  164. package/src/specimen/Specimen.tsx +59 -56
  165. package/src/state/labRecords.test.ts +4 -1
  166. package/src/state/store.test.ts +4 -4
  167. package/src/state/types.ts +2 -1
  168. package/src/state/useTrialState.test.tsx +1 -1
  169. package/src/styles.less +6 -2
  170. package/src/theme/Interstellar.stories.tsx +8 -13
  171. package/src/theme/base.less +3 -20
  172. package/src/theme/interstellar.test.ts +6 -3
  173. package/src/theme/interstellar.theme.json +0 -56
  174. package/src/theme/interstellar.ts +2 -2
  175. package/src/tools/labTool.ts +6 -2
  176. package/src/trial/Trial.annotations.test.tsx +16 -0
  177. package/src/trial/Trial.canvas.test.tsx +1 -1
  178. package/src/trial/Trial.loupe.test.tsx +73 -1
  179. package/src/trial/Trial.stories.tsx +2 -0
  180. package/src/trial/Trial.tsx +47 -9
  181. package/src/trial/trialOps.test.ts +4 -4
  182. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  183. package/dist/chunk-2AYGEN57.js +0 -32
  184. package/dist/chunk-2AYGEN57.js.map +0 -1
  185. package/dist/chunk-6BUQ77ZV.js +0 -18
  186. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  187. package/dist/chunk-753Y6HE6.js.map +0 -1
  188. package/dist/chunk-A2ACAGW6.js.map +0 -1
  189. package/dist/chunk-CKUKF72R.js.map +0 -1
  190. package/dist/chunk-D35PFTS7.js +0 -170
  191. package/dist/chunk-D35PFTS7.js.map +0 -1
  192. package/dist/chunk-EMXAIRUJ.js.map +0 -1
  193. package/dist/chunk-ERFMC6WU.js.map +0 -1
  194. package/dist/chunk-FKLT3ITC.js +0 -590
  195. package/dist/chunk-FKLT3ITC.js.map +0 -1
  196. package/dist/chunk-KJCUERQF.js.map +0 -1
  197. package/dist/chunk-LASCBWW7.js +0 -13456
  198. package/dist/chunk-LASCBWW7.js.map +0 -1
  199. package/dist/chunk-REOBGYMC.js.map +0 -1
  200. package/dist/chunk-RS3HRQWU.js.map +0 -1
  201. package/dist/chunk-TT5C5S6R.js +0 -368
  202. package/dist/chunk-TT5C5S6R.js.map +0 -1
  203. package/dist/ui/layers/index.d.ts +0 -66
  204. package/dist/ui/layers/index.js +0 -3
  205. package/dist/ui/layers/index.js.map +0 -1
  206. package/src/canvas/usePanZoom.test.ts +0 -244
  207. package/src/canvas/usePanZoom.ts +0 -147
  208. package/src/lab/useSystemMode.ts +0 -30
  209. package/src/layers/LayerList.less +0 -93
  210. package/src/layers/LayerList.stories.tsx +0 -66
  211. package/src/layers/LayerList.test.tsx +0 -232
  212. package/src/layers/LayerList.tsx +0 -268
  213. package/src/primitives/Sidebar.less +0 -112
  214. package/src/primitives/Sidebar.stories.tsx +0 -38
  215. package/src/primitives/Sidebar.test.tsx +0 -74
  216. package/src/primitives/Sidebar.tsx +0 -35
  217. package/src/ui/layers/index.ts +0 -5
@@ -0,0 +1,56 @@
1
+ import { ThemeContext } from '@weasel-js/theme/react';
2
+ import type { ReactNode } from 'react';
3
+ import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
+ import { AnnotationPreloadContext } from '../annotations/preload';
5
+ import { CameraWheelContext } from '../canvas/CameraWheelContext';
6
+ import { CanvasStackContext } from '../canvas/CanvasStackContext';
7
+ import { LabStoreContext, TrialIdContext } from '../state/context';
8
+ import { PersistenceContext } from '../state/Persistence';
9
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
10
+ import { TrialDragContext } from '../trial/TrialDragContext';
11
+ import { LabContext } from './LabContext';
12
+ import { PanelHostContext } from './panelHost';
13
+
14
+ /** Props for `<LabBoundary>`. */
15
+ export interface LabBoundaryProps {
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Renders its children as though no lab, trial or theme were above them: every context a `<Lab>` or a
21
+ * `<Trial>` publishes reads as absent below it, so a labkit piece mounted inside a trial behaves the way it
22
+ * does on a bare page. A `<LabRoot>` under it applies its own theme instead of deferring to the lab's, and a
23
+ * `usePersistedState` under it keeps local state instead of writing into the enclosing lab's records.
24
+ *
25
+ * For a host that shows arbitrary content inside a trial, such as a component workshop; a lab's own chrome
26
+ * never needs it.
27
+ */
28
+ export function LabBoundary({ children }: LabBoundaryProps) {
29
+ return (
30
+ <LabStoreContext.Provider value={null}>
31
+ <PersistenceContext.Provider value={null}>
32
+ <AnnotationPreloadContext.Provider value={null}>
33
+ <LabContext.Provider value={null}>
34
+ <PanelHostContext.Provider value={null}>
35
+ <SurfaceContext.Provider value={null}>
36
+ <SurfaceCanvasContext.Provider value={{ over: null, under: null }}>
37
+ <TrialIdContext.Provider value={null}>
38
+ <AnnotationsContext.Provider value={null}>
39
+ <CameraWheelContext.Provider value={null}>
40
+ <CanvasStackContext.Provider value={null}>
41
+ <TrialDragContext.Provider value={null}>
42
+ <ThemeContext.Provider value={null}>{children}</ThemeContext.Provider>
43
+ </TrialDragContext.Provider>
44
+ </CanvasStackContext.Provider>
45
+ </CameraWheelContext.Provider>
46
+ </AnnotationsContext.Provider>
47
+ </TrialIdContext.Provider>
48
+ </SurfaceCanvasContext.Provider>
49
+ </SurfaceContext.Provider>
50
+ </PanelHostContext.Provider>
51
+ </LabContext.Provider>
52
+ </AnnotationPreloadContext.Provider>
53
+ </PersistenceContext.Provider>
54
+ </LabStoreContext.Provider>
55
+ );
56
+ }
@@ -1,22 +1,7 @@
1
- import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
2
- import { MenuButton, ToggleBar } from '../passthrough/weasel-ui';
3
- import type { LabMode } from '../state/types';
1
+ import { AddIcon } from '@weasel-js/ui';
2
+ import { ColorModeControl, MenuButton } from '../passthrough/weasel-ui';
4
3
  import { useLabContext } from './LabContext';
5
4
 
6
- // The glyph is the segment's content and the word is its accessible name, so
7
- // the bar stays a three-way radiogroup announcing Auto / Light / Dark.
8
- const MODES = [
9
- { value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
10
- { value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
11
- { value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
12
- ];
13
-
14
- const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
15
- value,
16
- label: glyph,
17
- ariaLabel: label,
18
- }));
19
-
20
5
  /** The controls `<Lab>` puts in its header: add a trial, and choose the color
21
6
  * mode. Both drive `LabContext`, which carried them with no UI at all — so
22
7
  * every consumer rebuilt these two. Rendered before a consumer's own header
@@ -45,15 +30,11 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
45
30
  />
46
31
  )}
47
32
 
48
- <ToggleBar
33
+ <ColorModeControl
49
34
  className="lk-lab-header__mode"
50
- ariaLabel="Color mode"
51
35
  variant="flat"
52
- items={MODE_ITEMS}
53
36
  value={lab.mode}
54
- onChange={(next) => {
55
- if (next) lab.setMode(next);
56
- }}
37
+ onChange={lab.setMode}
57
38
  />
58
39
  </>
59
40
  );
@@ -1,8 +1,7 @@
1
- import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
1
+ import { ThemeProvider, useResolvedColorMode, useThemeOptional } from '@weasel-js/theme/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { interstellarTheme } from '../theme/interstellar';
5
- import { useResolvedMode } from './useSystemMode';
6
5
 
7
6
  /** Props for `<LabRoot>`. */
8
7
  export interface LabRootProps {
@@ -24,7 +23,7 @@ export interface LabRootProps {
24
23
  * Pair it with `import '@weasel-js/labkit/styles.css'`, which is what defines
25
24
  * the rules this element scopes. */
26
25
  export function LabRoot({ children, mode = 'auto', className }: LabRootProps) {
27
- const resolved = useResolvedMode(mode);
26
+ const resolved = useResolvedColorMode(mode);
28
27
  const outer = useThemeOptional();
29
28
 
30
29
  // Overlays portal here rather than to the themed wrapper above: `.lk-root`
@@ -64,7 +64,6 @@
64
64
  min-height: 0;
65
65
  min-width: 0;
66
66
  overflow: auto;
67
- padding: var(--lk-workspace-pad);
68
67
  }
69
68
 
70
69
  .lk-lab__body {
@@ -86,3 +86,18 @@ export const HeaderControlsLineUp: Story = {
86
86
  await expect(middles).toEqual(middles.map(() => middles[0]));
87
87
  },
88
88
  };
89
+
90
+ // One document serving its pages from the hash: the open route is the one the
91
+ // switcher marks, and each entry is a link to its fragment.
92
+ export const HashRoutedPages: Story = {
93
+ args: {
94
+ title: 'Registry',
95
+ pages: [
96
+ { href: '/app/', label: 'App' },
97
+ { href: '#/dev/tools', label: 'Tools' },
98
+ { href: '#/dev/registry', label: 'Registry' },
99
+ ],
100
+ path: '/app/#/dev/registry?entry=rect',
101
+ children: <p>Open the title to see the current route marked.</p>,
102
+ },
103
+ };
@@ -47,6 +47,24 @@ describe('LabShell', () => {
47
47
  expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
48
48
  });
49
49
 
50
+ test('holds the document title while mounted when asked to', () => {
51
+ document.title = 'before';
52
+ const { unmount } = render(
53
+ <LabShell title="t" documentTitle="Page title">
54
+ x
55
+ </LabShell>,
56
+ );
57
+ expect(document.title).toBe('Page title');
58
+ unmount();
59
+ expect(document.title).toBe('before');
60
+ });
61
+
62
+ test('leaves the document title alone by default', () => {
63
+ document.title = 'before';
64
+ render(<LabShell title="t">x</LabShell>);
65
+ expect(document.title).toBe('before');
66
+ });
67
+
50
68
  test('always applies lk-root class', () => {
51
69
  const { container } = render(<LabShell title="t">x</LabShell>);
52
70
  expect(container.querySelector('.lk-root')).not.toBeNull();
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode, useEffect } from 'react';
2
2
  import type { LabMode } from '../state/types';
3
3
  import { LabRoot } from './LabRoot';
4
4
  import type { LabPage } from './LabSwitcher';
@@ -20,6 +20,9 @@ export interface LabShellProps {
20
20
  /** The path the switcher reads to mark the open page. Defaults to the
21
21
  * current location. */
22
22
  path?: string;
23
+ /** Set as `document.title` while the shell is mounted, and the previous
24
+ * title restored after. Omitted, the document's title is left alone. */
25
+ documentTitle?: string;
23
26
  }
24
27
 
25
28
  /** Page frame for a lab: a titled header, a body, and an optional footer,
@@ -33,7 +36,17 @@ export function LabShell({
33
36
  mode = 'auto',
34
37
  pages,
35
38
  path,
39
+ documentTitle,
36
40
  }: LabShellProps) {
41
+ useEffect(() => {
42
+ if (documentTitle === undefined) return;
43
+ const previous = document.title;
44
+ document.title = documentTitle;
45
+ return () => {
46
+ document.title = previous;
47
+ };
48
+ }, [documentTitle]);
49
+
37
50
  return (
38
51
  <LabRoot mode={mode} className="lk-shell">
39
52
  <header className="lk-shell-header">
@@ -26,7 +26,7 @@
26
26
  // hyphen and the control becomes three ragged lines with a caret adrift.
27
27
  white-space: nowrap;
28
28
 
29
- &:hover { color: var(--wzl-accent); }
29
+ &:hover { color: var(--wzl-accent-fg); }
30
30
 
31
31
  &:focus-visible {
32
32
  outline: 2px solid var(--wzl-focus-ring, currentColor);
@@ -9,6 +9,8 @@ const PAGES = [
9
9
  { href: '/ingest', label: 'Ingestion' },
10
10
  ];
11
11
 
12
+ const open2 = (name: string) =>
13
+ fireEvent.click(screen.getByRole('button', { name: new RegExp(name) }));
12
14
  const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
13
15
 
14
16
  describe('currentPage', () => {
@@ -27,6 +29,45 @@ describe('currentPage', () => {
27
29
  it('reports -1 for a page that is not one of them', () => {
28
30
  expect(currentPage('/bench', PAGES)).toBe(-1);
29
31
  });
32
+
33
+ // One document serving its pages from the hash: every href shares a path, so
34
+ // only the fragment can tell them apart.
35
+ const ROUTES = [
36
+ { href: '/app/', label: 'App' },
37
+ { href: '#/dev/tools', label: 'Tools' },
38
+ { href: '#/dev/registry', label: 'Registry' },
39
+ ];
40
+
41
+ it('tells hash routes on one document apart', () => {
42
+ expect(currentPage('/app/#/dev/registry', ROUTES)).toBe(2);
43
+ expect(currentPage('/app/#/dev/tools', ROUTES)).toBe(1);
44
+ });
45
+
46
+ it('matches a hash route through its own query and sub-routes', () => {
47
+ expect(currentPage('/app/#/dev/tools?bundle=minimal', ROUTES)).toBe(1);
48
+ expect(currentPage('/app/#/dev/tools/', ROUTES)).toBe(1);
49
+ expect(currentPage('/app/#/dev/registry/tool/rect', ROUTES)).toBe(2);
50
+ expect(currentPage('/app/#/dev/toolsets', ROUTES)).toBe(0);
51
+ });
52
+
53
+ it('falls back to the page without a hash when no route matches', () => {
54
+ expect(currentPage('/app/', ROUTES)).toBe(0);
55
+ expect(currentPage('/app/#/elsewhere', ROUTES)).toBe(0);
56
+ });
57
+
58
+ it('reads the hash off the current location by default', () => {
59
+ window.history.replaceState(null, '', '/app/#/dev/registry');
60
+ try {
61
+ render(<LabSwitcher title="Registry" pages={ROUTES} />);
62
+ open2('Registry');
63
+ expect(screen.getByRole('menuitem', { name: 'Registry' })).toHaveAttribute(
64
+ 'aria-current',
65
+ 'page',
66
+ );
67
+ } finally {
68
+ window.history.replaceState(null, '', '/');
69
+ }
70
+ });
30
71
  });
31
72
 
32
73
  describe('<LabSwitcher>', () => {
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
2
 
3
3
  /** One page in a project's set of labs. */
4
4
  export interface LabPage {
5
- /** The page's own URL, and how the switcher knows which page is open. */
5
+ /** The page's own URL, and how the switcher knows which page is open. A
6
+ * hash route (`#/dev/tools`) names a page on the current document. */
6
7
  href: string;
7
8
  label: string;
8
9
  }
@@ -18,18 +19,39 @@ export interface LabSwitcherProps {
18
19
  className?: string;
19
20
  }
20
21
 
22
+ /** A URL split into the document it names and the hash route on it, each
23
+ * without its query, trailing slash or `.html`. */
24
+ function locate(url: string): { doc: string; route: string } {
25
+ const hash = url.indexOf('#');
26
+ const trim = (part: string) => part.replace(/\?.*$/, '').replace(/\/$/, '');
27
+ return {
28
+ doc: trim(hash < 0 ? url : url.slice(0, hash)).replace(/\.html$/, ''),
29
+ route: hash < 0 ? '' : trim(url.slice(hash + 1)),
30
+ };
31
+ }
32
+
21
33
  /** Which of `pages` a path is on, or -1.
22
34
  *
23
35
  * Matched on the end of the path so a query string, a trailing slash or a
24
36
  * leftover `.html` cannot lose it: a project whose dev server maps `/stats`
25
37
  * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
- * lost their extension still resolves to the same page. */
38
+ * lost their extension still resolves to the same page.
39
+ *
40
+ * A page whose `href` carries a hash is a route on a single document and wins
41
+ * when the path's hash is that route or one under it. A page without one
42
+ * ignores the hash, so an in-page anchor does not lose it. */
27
43
  export function currentPage(path: string, pages: readonly LabPage[]): number {
28
- const here = path
29
- .replace(/[?#].*$/, '')
30
- .replace(/\/$/, '')
31
- .replace(/\.html$/, '');
32
- return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
44
+ const here = locate(path);
45
+ const onDoc = (doc: string) => here.doc.endsWith(doc);
46
+ const routed = pages.findIndex((p) => {
47
+ if (!p.href.includes('#')) return false;
48
+ const page = locate(p.href);
49
+ return (
50
+ onDoc(page.doc) && (here.route === page.route || here.route.startsWith(`${page.route}/`))
51
+ );
52
+ });
53
+ if (routed >= 0) return routed;
54
+ return pages.findIndex((p) => !p.href.includes('#') && onDoc(locate(p.href).doc));
33
55
  }
34
56
 
35
57
  /** A project's lab title, doubling as the way to reach its other labs.
@@ -47,7 +69,7 @@ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps)
47
69
  const root = useRef<HTMLDivElement>(null);
48
70
  const menuId = useId();
49
71
  const here = currentPage(
50
- path ?? (typeof window === 'undefined' ? '' : window.location.pathname),
72
+ path ?? (typeof window === 'undefined' ? '' : window.location.pathname + window.location.hash),
51
73
  pages,
52
74
  );
53
75
 
package/src/lab/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { LabProps } from './Lab';
2
2
  export { Lab } from './Lab';
3
+ export { LabBoundary, type LabBoundaryProps } from './LabBoundary';
3
4
  export type { LabContextValue } from './LabContext';
4
5
  export { LabContext, useLabContext } from './LabContext';
5
6
  export type { LabPaletteProps } from './LabPalette';
@@ -1,33 +1,11 @@
1
1
  # Layers — Agent Guide
2
2
 
3
- The `src/layers/` directory implements the `<LayerList>` UI used by the `layers` instrument capability.
3
+ `src/layers/` re-exports `<LayerList>` from `@weasel-js/ui`, along with the
4
+ `layers` capability types. The component itself — rows, cards, nesting,
5
+ selection, visibility, reorder — is documented at its source in
6
+ `packages/ui/src/components/LayerList/`.
4
7
 
5
- ## Files
6
-
7
- | File | Role |
8
- |---|---|
9
- | `LayerList.tsx` | Layer toggle/reorder list, flat or nested (sidebar widget) |
10
- | `LayerList.less` | Row styling, drag handle, pinned-row variant, subtree indent |
11
- | `LayerList.stories.tsx` | Flat and nested renderings |
12
-
13
- ## Props
14
-
15
- ```ts
16
- interface LayerListProps {
17
- layers: LayerTreeNode[]; // LayerDescriptor + { children?, defaultCollapsed? }
18
- visibility: Record<string, boolean>; // missing key → visible
19
- onReorder: (newOrder: LayerTreeNode[]) => void;
20
- onToggle: (id: string, visible: boolean) => void;
21
- collapsedIds?: readonly string[]; // supplying it makes collapse controlled
22
- onCollapsedChange?: (ids: string[]) => void;
23
- className?: string;
24
- }
25
- ```
26
-
27
- `LayerDescriptor` lives in `src/instrument/types.ts`; `LayerTreeNode` extends it
28
- in `LayerList.tsx`, so a plain `LayerDescriptor[]` is still a valid `layers`.
29
-
30
- ## How it integrates
8
+ ## How the capability uses it
31
9
 
32
10
  The instrument declares layer ids:
33
11
 
@@ -35,53 +13,19 @@ The instrument declares layer ids:
35
13
  layers: { ids: ['grid', 'plants'] }
36
14
  ```
37
15
 
38
- `Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
39
-
40
- There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
41
-
42
- ## `alwaysOn` semantics
43
-
44
- Setting `alwaysOn: true` on a `LayerDescriptor` does two things:
45
-
46
- 1. The row renders with a 🔒 badge instead of a drag handle and visibility checkbox.
47
- 2. The row is excluded from reorder operations — pinned rows always sort to the bottom of the rendered list.
48
-
49
- This is intended for legend/HUD layers that should never be toggled off. Visibility for pinned rows is **not** read from the `visibility` prop; they always render.
50
-
51
- ## Nesting
52
-
53
- A node with `children` renders an expandable subtree. The expand control is
54
- `<Disclosure>` from `@weasel-js/ui` — the same twisty every other collapsible
55
- surface uses.
56
-
57
- Reordering is scoped to siblings: a drag moves a row within its own parent's
58
- child list and never reparents it. `onReorder` still receives the whole tree,
59
- with only that sibling group's order changed.
60
-
61
- The twisty column appears only when some node in the tree has children — a flat
62
- list renders exactly the rows it always did. Within a tree, childless rows get
63
- `.lk-layer-list__twisty-gap` so labels stay aligned down a level.
64
-
65
- Collapse is uncontrolled by default, seeded once from each node's
66
- `defaultCollapsed`. Pass `collapsedIds` to own it; `onCollapsedChange` fires
67
- either way.
68
-
69
- ## Drag handle customization
70
-
71
- The drag handle is a button with class `lk-layer-list__handle` holding `<DragHandleGlyph>` (from `@weasel-js/ui`), the same grip `LayerStack` uses. Its padding is transparent and cancelled by an equal negative margin, so the grab target is larger than the drawn dots without widening the row. Row pitch — height plus row gap — is measured from the DOM when a drag starts, so restyling the row does not skew drag distance. It used to be a hardcoded `28`, which had already drifted from the rendered height by the time it was found.
72
-
73
- Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dragging works across the document without requiring window-level listeners.
74
-
75
- ## Empty state
76
-
77
- If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
16
+ `Trial.tsx` turns each id into a `LayerDescriptor` (`label === id` unless the
17
+ entry is a descriptor), keeps `visibility` and `layerOrder` in local state, and
18
+ builds the list's items from them in the order the canvas draws. The order a
19
+ drag produces is applied to `instrument.canvas.layers` before they reach
20
+ `<CanvasStack>`, and each toggle and reorder emits `layers.toggle` /
21
+ `layers.reorder` on the bus.
78
22
 
79
- ## When to fork
23
+ `layers.ids` and `canvas.layers[].id` are assumed to match. A canvas layer not
24
+ in `layers.ids` keeps its default order and is always visible.
80
25
 
81
- Fork this component if you need:
82
- - Multi-select reorder (current implementation is single-row)
83
- - Right-click context menu
84
- - Reparenting by drag (nesting renders, but a drag stays within one parent)
85
- - Solo/mute UI common in DAW-style apps
26
+ ## `alwaysOn`
86
27
 
87
- It is self-contained: `LayerDescriptor` from instrument types and `Disclosure`/`DragHandleGlyph` from `@weasel-js/ui`.
28
+ A descriptor with `alwaysOn: true` becomes a `locked` item, pinned below the
29
+ rest: it shows a lock in place of the grip and has no visibility checkbox,
30
+ cannot be dragged, and no drag can cross it. It always renders, whatever
31
+ `visibility` says. Intended for legend and HUD layers.
@@ -1,3 +1,5 @@
1
+ // LayerList lives in `@weasel-js/ui`. Named, not `export *` — a star re-export
2
+ // of an external package emits no binding in the bundle.
3
+ export type { LayerListItem, LayerListProps, LayerMove } from '@weasel-js/ui';
4
+ export { LayerList, moveLayers } from '@weasel-js/ui';
1
5
  export type { LayerCapability, LayerDescriptor } from '../instrument/types';
2
- export type { LayerListProps, LayerTreeNode } from './LayerList';
3
- export { LayerList } from './LayerList';
@@ -14,7 +14,7 @@ This directory binds that model to a labkit trial and draws it.
14
14
  | `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
15
15
  | `useLoupe.ts` | The model over a host element, and pointer aiming |
16
16
  | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
17
- | `LoupeGestures.tsx` | Registers those and mounts the dispatcher on the host |
17
+ | `LoupeGestures.tsx` | Registers those, and mounts a dispatcher on the host when no camera has one there |
18
18
  | `TrialLoupe.tsx` | Picks the painter and mounts the lens |
19
19
  | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
20
20
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
@@ -46,11 +46,12 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
46
46
  cleanup leaves a magnifier that draws but silently ignores every aim. It owns
47
47
  no resources; unmounting only reports the lens gone.
48
48
 
49
- **The wheel is taken from pan/zoom by declining it, not by capturing it.**
50
- `loupe.magnify`'s `enabled` returns a disabled reason while the lens is down, so
51
- the dispatcher leaves the event unhandled and `usePanZoom` — a React handler on
52
- the same element — sees it as usual. While the lens is up the action fires, the
53
- dispatcher stops propagation, and React's root listener never runs.
49
+ **The wheel is taken from the camera by rank, and handed back by declining.**
50
+ In a trial the loupe's actions join the camera's dispatcher (`CameraInput`).
51
+ `loupe.magnify` sits in the hotkey tier, so while the lens is up it outranks
52
+ the camera's wheel zoom; while it is down its `enabled` returns a disabled
53
+ reason and the dispatcher falls through to the zoom. Outside a camera — the DOM
54
+ `.lk-trial__loupe-host` — `<LoupeGestures>` mounts a dispatcher of its own.
54
55
 
55
56
  **Aiming is a plain listener because a hover is not a gesture.**
56
57
  `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Mounts the gesture dispatcher on a loupe's host and registers the loupe's
3
- * actions on it, so the peek key and the wheel route the way every other
4
- * weasel binding does.
2
+ * Registers the loupe's actions, and mounts the gesture dispatcher on the
3
+ * loupe's host when no camera already has one there, so the peek key and the
4
+ * wheel route the way every other weasel binding does.
5
5
  *
6
6
  * Three of the dispatcher's four element channels are off. Only `wheel` is
7
7
  * wanted here; `contextMenu` suppresses the native menu unconditionally and
@@ -14,7 +14,8 @@ import {
14
14
  useActionsRegistry,
15
15
  useGestureDispatcher,
16
16
  } from '@weasel-js/core';
17
- import { type RefObject, useEffect, useMemo } from 'react';
17
+ import { type RefObject, useContext, useEffect, useMemo } from 'react';
18
+ import { CameraContext } from '../canvas/CameraInput';
18
19
  import { createLoupeActions, type LoupeInputApi } from './loupeActions';
19
20
 
20
21
  /** Props for `<LoupeGestures>`. */
@@ -35,7 +36,8 @@ function LoupeDispatch({
35
36
  input,
36
37
  peekKey,
37
38
  registry,
38
- }: LoupeGesturesProps & { registry: ActionsRegistry }) {
39
+ ownDispatcher,
40
+ }: LoupeGesturesProps & { registry: ActionsRegistry; ownDispatcher: boolean }) {
39
41
  const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
40
42
 
41
43
  useEffect(() => {
@@ -50,18 +52,21 @@ function LoupeDispatch({
50
52
  actions: registry,
51
53
  toolsById: NO_TOOLS,
52
54
  channels: CHANNELS,
55
+ enabled: ownDispatcher,
53
56
  });
54
57
 
55
58
  return null;
56
59
  }
57
60
 
58
61
  /**
59
- * The loupe's input, routed. Render it inside a `<WeaselProvider isolate>` —
60
- * with no registry in scope it renders nothing rather than registering
61
- * actions that could never fire.
62
+ * The loupe's input, routed. Inside a camera (`<CanvasStack>`, `<Stage>`) its
63
+ * actions join the camera's dispatcher, which already listens on the host;
64
+ * elsewhere it mounts a dispatcher of its own. With no registry in scope it
65
+ * renders nothing rather than registering actions that could never fire.
62
66
  */
63
67
  export function LoupeGestures(props: LoupeGesturesProps) {
64
68
  const registry = useActionsRegistry();
69
+ const camera = useContext(CameraContext);
65
70
  if (!registry) return null;
66
- return <LoupeDispatch {...props} registry={registry} />;
71
+ return <LoupeDispatch {...props} registry={registry} ownDispatcher={camera === null} />;
67
72
  }
@@ -1,5 +1,5 @@
1
- import { WeaselProvider } from '@weasel-js/core';
2
1
  import { type RefObject, useContext, useMemo } from 'react';
2
+ import { CameraScope } from '../canvas/CameraInput';
3
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
4
4
  import type { WorldSpec } from '../canvas/worldSpec';
5
5
  import type { ViewTransform } from '../instrument/types';
@@ -63,12 +63,12 @@ export function TrialLoupe({
63
63
 
64
64
  // The gestures mount outside the visibility gate: hold-to-peek is what
65
65
  // raises a lens that is down, so its binding has to be live while it is.
66
- // One isolated scope per loupe, for the reason `<AnnotationOverlay>` gives
67
- // — an actions registry holds one dispatcher.
66
+ // In a trial they join the trial's scope, and with it the camera's
67
+ // dispatcher; outside one, `<CameraScope>` gives them an isolated scope.
68
68
  const gestures = (
69
- <WeaselProvider isolate>
69
+ <CameraScope>
70
70
  <LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
71
- </WeaselProvider>
71
+ </CameraScope>
72
72
  );
73
73
 
74
74
  if (!loupe.visible) return gestures;
@@ -1,3 +1,4 @@
1
+ export type { LoupeMode, LoupePoint } from '@weasel-js/loupe';
1
2
  export type { CanvasLoupeProps } from './CanvasLoupe';
2
3
  export { CanvasLoupe } from './CanvasLoupe';
3
4
  export type { LensCamera, SourceRect } from './canvasLens';
@@ -21,8 +21,8 @@ export const WHEEL_RATE = 0.002;
21
21
  * registration. */
22
22
  export interface LoupeInputApi {
23
23
  /** Whether the lens is up. The wheel belongs to the loupe only while it is;
24
- * otherwise `loupe.magnify` declines and the dispatcher leaves the event
25
- * unhandled, which is what hands it back to the lab's own pan/zoom. */
24
+ * otherwise `loupe.magnify` declines and the dispatcher falls through to
25
+ * the trial camera's zoom. */
26
26
  shown: () => boolean;
27
27
  /** Raise or drop the hold-to-peek flag. */
28
28
  setPeeking: (on: boolean) => void;
@@ -43,6 +43,10 @@ export function createLoupeActions(
43
43
  id: LOUPE_MAGNIFY_ID,
44
44
  label: 'Magnify (loupe)',
45
45
  group: 'loupe',
46
+ // Hotkey scope, so while the lens is up it outranks the trial camera's
47
+ // wheel zoom on the same dispatcher; while it is down `enabled` declines
48
+ // and the zoom runs.
49
+ scope: 'hotkey',
46
50
  // Every modifier optional: the lens claims the wheel whenever it is up,
47
51
  // which is what the hand-rolled capture-phase listener did. A bare spec
48
52
  // would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
@@ -0,0 +1,38 @@
1
+ # Overview — Agent Guide
2
+
3
+ `src/overview/` is `@weasel-js/labkit/overview`: `<TrialOverview>`, a trial's
4
+ whole content in a floating panel. Its own bundle entry; nothing in the main
5
+ bundle imports it.
6
+
7
+ | File | Role |
8
+ |---|---|
9
+ | `TrialOverview.tsx` | The panel: content, chrome canvas, and its own dispatcher |
10
+ | `OverviewMarks.tsx` | Every annotation target's marks, read-only, as SVG |
11
+
12
+ ## How it reads the trial
13
+
14
+ Only through `CameraContext`, which `<CanvasStack>` and `<Stage>` set: the
15
+ camera as a weasel `ViewApi` over frame-local coordinates, the frame, the
16
+ camera's element, a stage's content rect. A canvas instrument's layers come
17
+ from `CanvasStackContext`, marks from `AnnotationsContext`. So it works
18
+ anywhere inside a camera — `stage.overlay`, or a canvas instrument's `render`.
19
+
20
+ Input is core's `minimap.center` / `minimap.pan` on a dispatcher of its own,
21
+ with the camera as `rootView`; the pointer store is the trial's, shared.
22
+
23
+ ## Traps
24
+
25
+ **It sits inside the stage's element.** Every event that bubbles out of the box
26
+ reaches the stage's dispatcher, the loupe's aim and the stage's pointer
27
+ publisher. The box stops them; drop that and a drag in the overview also pans
28
+ the stage, and the pointer flips between `'overview'` and `'stage'` each move.
29
+
30
+ **Marks are placed by measuring.** A target's rect on the overview is its
31
+ element's rect against the camera's element, through the camera — never the
32
+ target's content size, which is the mark's world and not where it is drawn.
33
+ jsdom measures zero; a test mocks `getBoundingClientRect`, and the picture is a
34
+ screenshot.
35
+
36
+ **Never render the instrument's own `render` in it.** Its effects — a pane
37
+ ref, an annotation target — would run twice. `render` is a separate, lighter
38
+ copy the instrument supplies.