@weasel-js/labkit 1.5.0 → 1.5.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 (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +177 -53
  51. package/dist/index.js +1333 -1683
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +26 -202
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +153 -40
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. package/src/theme/interstellar.tokens.json +0 -61
@@ -39,12 +39,24 @@
39
39
  .lk-shell-header-actions {
40
40
  margin-left: auto;
41
41
  display: flex;
42
- // A flex container reports its first item's baseline, so this is what
43
- // carries the row's baseline up to the header.
44
- align-items: baseline;
42
+ // Every control here runs at the one control height and shares a middle.
43
+ // The row still hands the header its first item's baseline, which is what
44
+ // lines the controls' labels up with the title.
45
+ align-items: center;
45
46
  gap: var(--wzl-space-sm);
46
47
  }
47
48
 
49
+ // A toolbar dropped into the header is a run of header controls, not a strip of
50
+ // its own: it takes the header's control height rather than the compact one
51
+ // trial toolbars use, and none of the strip's box.
52
+ .lk-shell-header-actions .lk-toolbar {
53
+ --wzl-control-h: inherit;
54
+ min-height: 0;
55
+ padding: 0;
56
+ background: none;
57
+ border: 0;
58
+ }
59
+
48
60
  .lk-shell-body {
49
61
  display: flex;
50
62
  flex-direction: column;
@@ -81,6 +93,15 @@
81
93
  border-right: 1px solid var(--wzl-border);
82
94
  }
83
95
 
96
+ .lk-lab__aside {
97
+ display: flex;
98
+ flex-direction: column;
99
+ height: 100%;
100
+ overflow-y: auto;
101
+ background: var(--wzl-surface);
102
+ border-left: 1px solid var(--wzl-border);
103
+ }
104
+
84
105
  .lk-lab__main {
85
106
  display: flex;
86
107
  min-width: 0;
@@ -1,4 +1,7 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect, within } from '@weasel-js/forge/play';
3
+ import { Select, ToggleBar } from '../passthrough/weasel-ui';
4
+ import { Toolbar } from '../primitives/Toolbar';
2
5
  import { LabShell } from './LabShell';
3
6
 
4
7
  const meta: Meta<typeof LabShell> = {
@@ -37,3 +40,49 @@ export const WithFooter: Story = {
37
40
  footer: <span>Status: ready</span>,
38
41
  },
39
42
  };
43
+
44
+ // jsdom has no layout, so this runs in the browser story projects.
45
+ export const HeaderControlsLineUp: Story = {
46
+ args: {
47
+ title: 'My Lab',
48
+ header: (
49
+ <>
50
+ <button type="button">Add</button>
51
+ <ToggleBar
52
+ ariaLabel="Mode"
53
+ items={[
54
+ { value: 'a', label: 'A' },
55
+ { value: 'b', label: 'B' },
56
+ ]}
57
+ value="a"
58
+ onChange={() => {}}
59
+ />
60
+ <Select
61
+ aria-label="Font"
62
+ width="fit"
63
+ selectedKey="oswald"
64
+ options={[{ value: 'oswald', label: 'Oswald' }]}
65
+ />
66
+ <Toolbar aria-label="Actions">
67
+ <Toolbar.Group>
68
+ <Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
69
+ </Toolbar.Group>
70
+ </Toolbar>
71
+ </>
72
+ ),
73
+ children: <p>Body content.</p>,
74
+ },
75
+ play: async ({ canvasElement }) => {
76
+ const canvas = within(canvasElement);
77
+ const boxes = [
78
+ canvas.getByRole('button', { name: 'Add' }),
79
+ canvas.getByRole('radiogroup', { name: 'Mode' }),
80
+ canvas.getByRole('button', { name: /Font/ }),
81
+ canvas.getByRole('button', { name: 'Undo' }),
82
+ ].map((el) => el.getBoundingClientRect());
83
+ const heights = boxes.map((box) => Math.round(box.height * 2) / 2);
84
+ const middles = boxes.map((box) => Math.round((box.top + box.height / 2) * 2) / 2);
85
+ await expect(heights).toEqual(heights.map(() => heights[0]));
86
+ await expect(middles).toEqual(middles.map(() => middles[0]));
87
+ },
88
+ };
@@ -1,10 +1,8 @@
1
- import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
2
1
  import type { ReactNode } from 'react';
3
2
  import type { LabMode } from '../state/types';
4
- import { interstellarTheme } from '../theme/interstellar';
3
+ import { LabRoot } from './LabRoot';
5
4
  import type { LabPage } from './LabSwitcher';
6
5
  import { LabSwitcher } from './LabSwitcher';
7
- import { useResolvedMode } from './useSystemMode';
8
6
 
9
7
  /** Props for `<LabShell>`. */
10
8
  export interface LabShellProps {
@@ -27,34 +25,27 @@ export interface LabShellProps {
27
25
  /** Page frame for a lab: a titled header, a body, and an optional footer,
28
26
  * themed for the resolved color mode. Presentational only — use `<Lab>` when
29
27
  * the trial runtime is wanted too. */
30
- export function LabShell({ title, children, header, footer, mode = 'auto',
31
- pages, path }: LabShellProps) {
32
- const resolved = useResolvedMode(mode);
33
- const outer = useThemeOptional();
34
-
35
- const shell = (
36
- // Overlays portal here rather than to the themed wrapper above: `.lk-root`
37
- // carries the element defaults a lab's bare markup is styled by.
38
- <div className="lk-root lk-shell" data-wzl-portal-host="">
28
+ export function LabShell({
29
+ title,
30
+ children,
31
+ header,
32
+ footer,
33
+ mode = 'auto',
34
+ pages,
35
+ path,
36
+ }: LabShellProps) {
37
+ return (
38
+ <LabRoot mode={mode} className="lk-shell">
39
39
  <header className="lk-shell-header">
40
- {pages && pages.length > 0
41
- ? <LabSwitcher title={title} pages={pages} path={path}
42
- className="lk-shell-title" />
43
- : <h1 className="lk-shell-title">{title}</h1>}
40
+ {pages && pages.length > 0 ? (
41
+ <LabSwitcher title={title} pages={pages} path={path} className="lk-shell-title" />
42
+ ) : (
43
+ <h1 className="lk-shell-title">{title}</h1>
44
+ )}
44
45
  {header && <div className="lk-shell-header-actions">{header}</div>}
45
46
  </header>
46
47
  <main className="lk-shell-body">{children}</main>
47
48
  {footer && <footer className="lk-shell-footer">{footer}</footer>}
48
- </div>
49
- );
50
-
51
- // Inside <Lab> the theme is already applied on `.lk-lab`; wrapping again
52
- // would only add a div.
53
- return outer ? (
54
- shell
55
- ) : (
56
- <ThemeProvider theme={interstellarTheme} mode={resolved}>
57
- {shell}
58
- </ThemeProvider>
49
+ </LabRoot>
59
50
  );
60
51
  }
@@ -6,7 +6,7 @@
6
6
  .lk-switcher-title {
7
7
  margin: 0;
8
8
  font-size: var(--wzl-font-size-xl);
9
- line-height: var(--wzl-line-height-tight, 1.2);
9
+ line-height: var(--wzl-leading-snug);
10
10
  }
11
11
 
12
12
  .lk-switcher-trigger {
@@ -26,12 +26,12 @@
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-text-accent, var(--wzl-text)); }
29
+ &:hover { color: var(--wzl-accent); }
30
30
 
31
31
  &:focus-visible {
32
32
  outline: 2px solid var(--wzl-focus-ring, currentColor);
33
33
  outline-offset: 2px;
34
- border-radius: var(--wzl-radius-sm, 2px);
34
+ border-radius: var(--wzl-radius-sm, 3px);
35
35
  }
36
36
  }
37
37
 
@@ -54,7 +54,7 @@
54
54
  position: absolute;
55
55
  top: calc(100% + var(--wzl-space-xs));
56
56
  left: 0;
57
- z-index: var(--wzl-z-popover, 60);
57
+ z-index: var(--wzl-z-overlay);
58
58
  min-width: max(100%, 11rem);
59
59
  display: flex;
60
60
  flex-direction: column;
@@ -64,15 +64,15 @@
64
64
  // surface, the way `FloatingPanel` does.
65
65
  background: var(--wzl-surface);
66
66
  border: 1px solid var(--wzl-border);
67
- border-radius: var(--wzl-radius-md, 6px);
68
- box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
67
+ border-radius: var(--wzl-radius-md, 5px);
68
+ box-shadow: 0 6px 20px var(--wzl-shadow);
69
69
  }
70
70
 
71
71
  .lk-switcher-item {
72
72
  padding: var(--wzl-space-xs) var(--wzl-space-sm);
73
73
  border-radius: var(--wzl-radius-sm, 3px);
74
- color: var(--wzl-text);
75
- font-size: var(--wzl-font-size-md);
74
+ color: var(--wzl-fg);
75
+ font-size: var(--wzl-font-size);
76
76
  text-decoration: none;
77
77
  white-space: nowrap;
78
78
 
@@ -87,6 +87,6 @@
87
87
  // The open page stays in the list rather than being filtered out: a menu that
88
88
  // drops its current entry shifts every other one as you move between pages.
89
89
  .lk-switcher-item[aria-current='page'] {
90
- color: var(--wzl-text-strong, var(--wzl-text));
91
- font-weight: var(--wzl-font-weight-semibold, 600);
90
+ color: var(--wzl-fg);
91
+ font-weight: var(--wzl-font-weight-bold);
92
92
  }
@@ -1,7 +1,7 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { currentPage, LabSwitcher } from './LabSwitcher';
4
3
  import { LabShell } from './LabShell';
4
+ import { currentPage, LabSwitcher } from './LabSwitcher';
5
5
 
6
6
  const PAGES = [
7
7
  { href: '/corpus', label: 'Wall' },
@@ -19,8 +19,7 @@ describe('currentPage', () => {
19
19
  it('survives a query string, a trailing slash and a leftover extension', () => {
20
20
  // A dev server maps `/stats` to `stats.html` and serves both spellings, so
21
21
  // an old bookmark must still resolve to the same page.
22
- for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
23
- '/lab/stats.html#top']) {
22
+ for (const path of ['/stats?kind=base', '/stats/', '/stats.html', '/lab/stats.html#top']) {
24
23
  expect(currentPage(path, PAGES)).toBe(1);
25
24
  }
26
25
  });
@@ -34,7 +33,9 @@ describe('<LabSwitcher>', () => {
34
33
  it('shows the title and nothing else until it is opened', () => {
35
34
  render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
36
35
  expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
37
- 'aria-expanded', 'false');
36
+ 'aria-expanded',
37
+ 'false',
38
+ );
38
39
  expect(screen.queryByRole('menu')).toBeNull();
39
40
  });
40
41
 
@@ -54,8 +55,7 @@ describe('<LabSwitcher>', () => {
54
55
  render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
55
56
  open();
56
57
  expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
57
- expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
58
- 'href', '/corpus');
58
+ expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute('href', '/corpus');
59
59
  });
60
60
 
61
61
  it('closes on Escape and on a press outside it', () => {
@@ -63,7 +63,8 @@ describe('<LabSwitcher>', () => {
63
63
  <div>
64
64
  <LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
65
65
  <button type="button">elsewhere</button>
66
- </div>);
66
+ </div>,
67
+ );
67
68
  open();
68
69
  fireEvent.keyDown(document, { key: 'Escape' });
69
70
  expect(screen.queryByRole('menu')).toBeNull();
@@ -76,7 +77,7 @@ describe('<LabSwitcher>', () => {
76
77
  it('stays a plain heading when there is nowhere to switch to', () => {
77
78
  // A disclosure arrow promising a menu of the page you are already on is
78
79
  // worse than no control.
79
- render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
80
+ render(<LabSwitcher title="corpus stats" pages={PAGES.slice(1, 2)} path="/stats" />);
80
81
  expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
81
82
  expect(screen.queryByRole('button')).toBeNull();
82
83
  });
@@ -84,7 +85,11 @@ describe('<LabSwitcher>', () => {
84
85
 
85
86
  describe('<LabShell> with pages', () => {
86
87
  it('turns its title into the switcher', () => {
87
- render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
88
+ render(
89
+ <LabShell title="corpus stats" pages={PAGES} path="/stats">
90
+ body
91
+ </LabShell>,
92
+ );
88
93
  expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
89
94
  });
90
95
 
@@ -25,8 +25,10 @@ export interface LabSwitcherProps {
25
25
  * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
26
  * lost their extension still resolves to the same page. */
27
27
  export function currentPage(path: string, pages: readonly LabPage[]): number {
28
- const here = path.split('?')[0]!.split('#')[0]!
29
- .replace(/\/$/, '').replace(/\.html$/, '');
28
+ const here = path
29
+ .replace(/[?#].*$/, '')
30
+ .replace(/\/$/, '')
31
+ .replace(/\.html$/, '');
30
32
  return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
31
33
  }
32
34
 
@@ -44,8 +46,10 @@ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps)
44
46
  const [open, setOpen] = useState(false);
45
47
  const root = useRef<HTMLDivElement>(null);
46
48
  const menuId = useId();
47
- const here = currentPage(path ?? (typeof window === 'undefined'
48
- ? '' : window.location.pathname), pages);
49
+ const here = currentPage(
50
+ path ?? (typeof window === 'undefined' ? '' : window.location.pathname),
51
+ pages,
52
+ );
49
53
 
50
54
  const close = useCallback(() => setOpen(false), []);
51
55
 
@@ -80,5 +80,5 @@
80
80
  width: 260px;
81
81
  height: auto;
82
82
  max-height: 60%;
83
- box-shadow: var(--wzl-shadow-panel, 0 2px 8px rgb(0 0 0 / 0.3));
83
+ box-shadow: 0 2px 8px var(--wzl-shadow);
84
84
  }
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { Workspace } from './Workspace';
3
3
 
4
4
  const meta: Meta<typeof Workspace> = {
@@ -11,7 +11,7 @@ function fakeHandle(): SurfaceHandle {
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
13
  registerClear: vi.fn(() => () => {}),
14
- registerPainter: vi.fn(() => () => {}),
14
+ registerPainter: vi.fn(() => () => {}),
15
15
  containerRef: vi.fn(),
16
16
  getContainer: vi.fn(() => null),
17
17
  };
package/src/lab/index.ts CHANGED
@@ -4,6 +4,8 @@ export type { LabContextValue } from './LabContext';
4
4
  export { LabContext, useLabContext } from './LabContext';
5
5
  export type { LabPaletteProps } from './LabPalette';
6
6
  export { LabPalette } from './LabPalette';
7
+ export type { LabRootProps } from './LabRoot';
8
+ export { LabRoot } from './LabRoot';
7
9
  export type { LabShellProps } from './LabShell';
8
10
  export { LabShell } from './LabShell';
9
11
  export type { LabPage, LabSwitcherProps } from './LabSwitcher';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { useState } from 'react';
3
3
  import { LayerList, type LayerTreeNode } from './LayerList';
4
4
 
@@ -12,7 +12,9 @@ This directory binds that model to a labkit trial and draws it.
12
12
  | File | Role |
13
13
  |---|---|
14
14
  | `types.ts` | `LoupeCapability`, and `resolveLoupe` filling in every default |
15
- | `useLoupe.ts` | The model over a host element, plus all of the input |
15
+ | `useLoupe.ts` | The model over a host element, and pointer aiming |
16
+ | `loupeActions.ts` | `loupe.peek` and `loupe.magnify`, as `Action` descriptors |
17
+ | `LoupeGestures.tsx` | Registers those and mounts the dispatcher on the host |
16
18
  | `TrialLoupe.tsx` | Picks the painter and mounts the lens |
17
19
  | `LoupeBubble.tsx` | The circular clip, positioned on the aim |
18
20
  | `CanvasLoupe.tsx` | Painter for a `<CanvasStack>` |
@@ -44,6 +46,16 @@ StrictMode mounts / unmounts / mounts every effect — so disposing in the
44
46
  cleanup leaves a magnifier that draws but silently ignores every aim. It owns
45
47
  no resources; unmounting only reports the lens gone.
46
48
 
47
- **The wheel has to be taken from pan/zoom by hand.** `usePanZoom` is a React
48
- handler on the same element, so the lens listens in the capture phase and stops
49
- propagation. See the loupe entry in `docs/TODO.md` for what replaces this.
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.
54
+
55
+ **Aiming is a plain listener because a hover is not a gesture.**
56
+ `GESTURE_DESCRIPTORS` names no continuous-motion gesture, so `pointermove` /
57
+ `pointerleave` stay hand-attached in `useLoupe`. Everything else routes.
58
+
59
+ **`<LoupeGestures>` mounts outside the visibility gate.** Hold-to-peek is what
60
+ raises a lens that is down; gate its registration on `loupe.visible` and the
61
+ peek key stops working entirely.
@@ -0,0 +1,67 @@
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.
5
+ *
6
+ * Three of the dispatcher's four element channels are off. Only `wheel` is
7
+ * wanted here; `contextMenu` suppresses the native menu unconditionally and
8
+ * `ingest` makes the host a file-drop target, neither of which a lab asked
9
+ * for by turning a magnifier on.
10
+ */
11
+ import {
12
+ type ActionsRegistry,
13
+ type Tool,
14
+ useActionsRegistry,
15
+ useGestureDispatcher,
16
+ } from '@weasel-js/core';
17
+ import { type RefObject, useEffect, useMemo } from 'react';
18
+ import { createLoupeActions, type LoupeInputApi } from './loupeActions';
19
+
20
+ /** Props for `<LoupeGestures>`. */
21
+ export interface LoupeGesturesProps {
22
+ /** The element the dispatcher listens on — the same one the lens tracks. */
23
+ hostRef: RefObject<HTMLElement | null>;
24
+ input: LoupeInputApi;
25
+ /** `KeyboardEvent.key` for hold-to-peek, or `null` for no peek binding. */
26
+ peekKey: string | null;
27
+ }
28
+
29
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
30
+
31
+ const CHANNELS = { pointer: false, contextMenu: false, ingest: false } as const;
32
+
33
+ function LoupeDispatch({
34
+ hostRef,
35
+ input,
36
+ peekKey,
37
+ registry,
38
+ }: LoupeGesturesProps & { registry: ActionsRegistry }) {
39
+ const actions = useMemo(() => createLoupeActions(input, peekKey), [input, peekKey]);
40
+
41
+ useEffect(() => {
42
+ const offs = actions.map((a) => registry.register(a));
43
+ return () => {
44
+ for (const off of offs) off();
45
+ };
46
+ }, [registry, actions]);
47
+
48
+ useGestureDispatcher({
49
+ canvasRef: hostRef,
50
+ actions: registry,
51
+ toolsById: NO_TOOLS,
52
+ channels: CHANNELS,
53
+ });
54
+
55
+ return null;
56
+ }
57
+
58
+ /**
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
+ */
63
+ export function LoupeGestures(props: LoupeGesturesProps) {
64
+ const registry = useActionsRegistry();
65
+ if (!registry) return null;
66
+ return <LoupeDispatch {...props} registry={registry} />;
67
+ }
@@ -1,3 +1,4 @@
1
+ import { WeaselProvider } from '@weasel-js/core';
1
2
  import { type RefObject, useContext, useMemo } from 'react';
2
3
  import { CanvasStackContext } from '../canvas/CanvasStackContext';
3
4
  import type { WorldSpec } from '../canvas/worldSpec';
@@ -6,6 +7,7 @@ import { CanvasLoupe } from './CanvasLoupe';
6
7
  import { sampleStack } from './canvasLens';
7
8
  import { DomLoupe } from './DomLoupe';
8
9
  import { LoupeBubble } from './LoupeBubble';
10
+ import { LoupeGestures } from './LoupeGestures';
9
11
  import type { ResolvedLoupe } from './types';
10
12
  import { useHostSize } from './useHostSize';
11
13
  import { useLoupe } from './useLoupe';
@@ -59,35 +61,48 @@ export function TrialLoupe({
59
61
  const measured = useHostSize(host);
60
62
  const size = surface?.size ?? measured;
61
63
 
62
- if (!loupe.visible) return null;
64
+ // The gestures mount outside the visibility gate: hold-to-peek is what
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.
68
+ const gestures = (
69
+ <WeaselProvider isolate>
70
+ <LoupeGestures hostRef={host} input={loupe.input} peekKey={capability.peekKey ?? null} />
71
+ </WeaselProvider>
72
+ );
73
+
74
+ if (!loupe.visible) return gestures;
63
75
 
64
76
  return (
65
- <LoupeBubble aim={loupe.aim} diameter={capability.diameter}>
66
- {capability.render ? (
67
- <DomLoupe
68
- aim={loupe.aim}
69
- factor={loupe.factor}
70
- mode={loupe.mode}
71
- diameter={capability.diameter}
72
- size={size}
73
- view={view}
74
- frame={stack?.frame}
75
- state={state}
76
- config={config}
77
- render={capability.render}
78
- />
79
- ) : surface && stack ? (
80
- <CanvasLoupe
81
- aim={loupe.aim}
82
- factor={loupe.factor}
83
- mode={loupe.mode}
84
- diameter={capability.diameter}
85
- surface={surface}
86
- view={stack.view}
87
- frame={stack.frame}
88
- worldSpec={worldSpec}
89
- />
90
- ) : null}
91
- </LoupeBubble>
77
+ <>
78
+ {gestures}
79
+ <LoupeBubble aim={loupe.aim} diameter={capability.diameter}>
80
+ {capability.render ? (
81
+ <DomLoupe
82
+ aim={loupe.aim}
83
+ factor={loupe.factor}
84
+ mode={loupe.mode}
85
+ diameter={capability.diameter}
86
+ size={size}
87
+ view={view}
88
+ frame={stack?.frame}
89
+ state={state}
90
+ config={config}
91
+ render={capability.render}
92
+ />
93
+ ) : surface && stack ? (
94
+ <CanvasLoupe
95
+ aim={loupe.aim}
96
+ factor={loupe.factor}
97
+ mode={loupe.mode}
98
+ diameter={capability.diameter}
99
+ surface={surface}
100
+ view={stack.view}
101
+ frame={stack.frame}
102
+ worldSpec={worldSpec}
103
+ />
104
+ ) : null}
105
+ </LoupeBubble>
106
+ </>
92
107
  );
93
108
  }
@@ -6,6 +6,10 @@ export type { DomLoupeProps } from './DomLoupe';
6
6
  export { DomLoupe } from './DomLoupe';
7
7
  export type { LoupeBubbleProps } from './LoupeBubble';
8
8
  export { LoupeBubble } from './LoupeBubble';
9
+ export type { LoupeGesturesProps } from './LoupeGestures';
10
+ export { LoupeGestures } from './LoupeGestures';
11
+ export type { LoupeInputApi } from './loupeActions';
12
+ export { createLoupeActions, LOUPE_MAGNIFY_ID, LOUPE_PEEK_ID } from './loupeActions';
9
13
  export type { TrialLoupeProps } from './TrialLoupe';
10
14
  export { TrialLoupe } from './TrialLoupe';
11
15
  export type {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The loupe's two bindable interactions, as `Action` descriptors the gesture
3
+ * dispatcher routes: hold a key to peek, and walk the magnification with the
4
+ * wheel.
5
+ *
6
+ * Free of React so the routing can be tested without a DOM.
7
+ */
8
+ import type { Action } from '@weasel-js/core';
9
+
10
+ /** Canonical id of the hold-to-peek action. */
11
+ export const LOUPE_PEEK_ID = 'loupe.peek';
12
+ /** Canonical id of the wheel-magnify action. */
13
+ export const LOUPE_MAGNIFY_ID = 'loupe.magnify';
14
+
15
+ /** How fast the wheel walks the magnification. Gentler than the pan-zoom
16
+ * wheel: the lens' whole range is one order of magnitude. */
17
+ export const WHEEL_RATE = 0.002;
18
+
19
+ /** What the actions do to a live loupe. Every member is read through the
20
+ * object on each invocation, so one stable instance serves the life of the
21
+ * registration. */
22
+ export interface LoupeInputApi {
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. */
26
+ shown: () => boolean;
27
+ /** Raise or drop the hold-to-peek flag. */
28
+ setPeeking: (on: boolean) => void;
29
+ /** Multiply the magnification by one wheel notch of `deltaY`. */
30
+ magnifyBy: (deltaY: number) => void;
31
+ }
32
+
33
+ /**
34
+ * Build the loupe's actions. `peekKey` is a `KeyboardEvent.key`; passing
35
+ * `null` omits `loupe.peek` entirely rather than registering a binding no key
36
+ * can match.
37
+ */
38
+ export function createLoupeActions(
39
+ input: LoupeInputApi,
40
+ peekKey: string | null,
41
+ ): readonly Action[] {
42
+ const magnify: Action = {
43
+ id: LOUPE_MAGNIFY_ID,
44
+ label: 'Magnify (loupe)',
45
+ group: 'loupe',
46
+ // Every modifier optional: the lens claims the wheel whenever it is up,
47
+ // which is what the hand-rolled capture-phase listener did. A bare spec
48
+ // would forbid modifiers and hand Cmd+wheel back to the lab mid-peek.
49
+ defaultBinding: [
50
+ {
51
+ spec: {
52
+ kind: 'wheel',
53
+ mods: { alt: 'optional', ctrl: 'optional', meta: 'optional', shift: 'optional' },
54
+ },
55
+ opts: {},
56
+ },
57
+ ],
58
+ enabled: () => (input.shown() ? true : 'not-applicable'),
59
+ invoker: {
60
+ timing: 'immediate',
61
+ run(_deps, params) {
62
+ input.magnifyBy((params?.deltaY as number | undefined) ?? 0);
63
+ },
64
+ },
65
+ };
66
+
67
+ if (peekKey === null) return [magnify];
68
+
69
+ const peek: Action = {
70
+ id: LOUPE_PEEK_ID,
71
+ label: 'Peek (loupe)',
72
+ group: 'loupe',
73
+ // Hotkey scope: peeking must beat whatever the lab's active tool binds to
74
+ // the same key, the way every other held-key trigger does.
75
+ scope: 'hotkey',
76
+ // Every modifier optional because the peek key may BE one: the keydown
77
+ // that carries `Alt` — the default — also reports `altKey: true`, and the
78
+ // matcher forbids any modifier a spec does not name.
79
+ defaultBinding: [
80
+ {
81
+ spec: {
82
+ kind: 'key-held',
83
+ key: peekKey,
84
+ mods: { alt: 'optional', ctrl: 'optional', meta: 'optional', shift: 'optional' },
85
+ },
86
+ opts: {},
87
+ },
88
+ ],
89
+ invoker: {
90
+ timing: 'ongoing',
91
+ start() {
92
+ input.setPeeking(true);
93
+ return { onEnd: () => input.setPeeking(false) };
94
+ },
95
+ },
96
+ };
97
+ return [peek, magnify];
98
+ }