@weasel-js/labkit 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -0,0 +1,96 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { currentPage, LabSwitcher } from './LabSwitcher';
4
+ import { LabShell } from './LabShell';
5
+
6
+ const PAGES = [
7
+ { href: '/corpus', label: 'Wall' },
8
+ { href: '/stats', label: 'Dashboard' },
9
+ { href: '/ingest', label: 'Ingestion' },
10
+ ];
11
+
12
+ const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
13
+
14
+ describe('currentPage', () => {
15
+ it('matches a bare path', () => {
16
+ expect(currentPage('/stats', PAGES)).toBe(1);
17
+ });
18
+
19
+ it('survives a query string, a trailing slash and a leftover extension', () => {
20
+ // A dev server maps `/stats` to `stats.html` and serves both spellings, so
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']) {
24
+ expect(currentPage(path, PAGES)).toBe(1);
25
+ }
26
+ });
27
+
28
+ it('reports -1 for a page that is not one of them', () => {
29
+ expect(currentPage('/bench', PAGES)).toBe(-1);
30
+ });
31
+ });
32
+
33
+ describe('<LabSwitcher>', () => {
34
+ it('shows the title and nothing else until it is opened', () => {
35
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
36
+ expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
37
+ 'aria-expanded', 'false');
38
+ expect(screen.queryByRole('menu')).toBeNull();
39
+ });
40
+
41
+ it('opens a menu of every lab, the open one included', () => {
42
+ // Filtering the current page out shifts every other entry as you move
43
+ // between pages, so it stays and is marked instead.
44
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
45
+ open();
46
+ const items = screen.getAllByRole('menuitem');
47
+ expect(items.map((i) => i.textContent)).toEqual(['Wall', 'Dashboard', 'Ingestion']);
48
+ expect(items[1]).toHaveAttribute('aria-current', 'page');
49
+ });
50
+
51
+ it('offers real links, so cmd-click and the back button keep working', () => {
52
+ // These are separate documents. A <select> would fire a change event and
53
+ // get none of the three for free.
54
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
55
+ open();
56
+ expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
57
+ expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
58
+ 'href', '/corpus');
59
+ });
60
+
61
+ it('closes on Escape and on a press outside it', () => {
62
+ render(
63
+ <div>
64
+ <LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
65
+ <button type="button">elsewhere</button>
66
+ </div>);
67
+ open();
68
+ fireEvent.keyDown(document, { key: 'Escape' });
69
+ expect(screen.queryByRole('menu')).toBeNull();
70
+
71
+ open();
72
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'elsewhere' }));
73
+ expect(screen.queryByRole('menu')).toBeNull();
74
+ });
75
+
76
+ it('stays a plain heading when there is nowhere to switch to', () => {
77
+ // A disclosure arrow promising a menu of the page you are already on is
78
+ // worse than no control.
79
+ render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
80
+ expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
81
+ expect(screen.queryByRole('button')).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('<LabShell> with pages', () => {
86
+ it('turns its title into the switcher', () => {
87
+ render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
88
+ expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
89
+ });
90
+
91
+ it('leaves the title alone when no pages are given', () => {
92
+ render(<LabShell title="corpus stats">body</LabShell>);
93
+ expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
94
+ expect(screen.queryByRole('button', { name: /corpus stats/ })).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+
3
+ /** One page in a project's set of labs. */
4
+ export interface LabPage {
5
+ /** The page's own URL, and how the switcher knows which page is open. */
6
+ href: string;
7
+ label: string;
8
+ }
9
+
10
+ /** Props for `<LabSwitcher>`. */
11
+ export interface LabSwitcherProps {
12
+ /** The open page's title, shown as the control's own label. */
13
+ title: string;
14
+ pages: readonly LabPage[];
15
+ /** Defaults to the current location. Pass it in tests, or to render a
16
+ * switcher for a page other than the one being viewed. */
17
+ path?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /** Which of `pages` a path is on, or -1.
22
+ *
23
+ * Matched on the end of the path so a query string, a trailing slash or a
24
+ * leftover `.html` cannot lose it: a project whose dev server maps `/stats`
25
+ * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
+ * lost their extension still resolves to the same page. */
27
+ export function currentPage(path: string, pages: readonly LabPage[]): number {
28
+ const here = path.split('?')[0]!.split('#')[0]!
29
+ .replace(/\/$/, '').replace(/\.html$/, '');
30
+ return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
31
+ }
32
+
33
+ /** A project's lab title, doubling as the way to reach its other labs.
34
+ *
35
+ * The menu holds real anchors, not a `<select>` and not buttons. These are
36
+ * separate documents, so an `href` is what gets middle-click, cmd-click and
37
+ * the back button for free; a select fires a change event and can do none of
38
+ * the three. The trigger is a button because it opens something rather than
39
+ * going anywhere.
40
+ *
41
+ * Pages accumulate -- a project grows a wall, a dashboard, an ingest page, a
42
+ * bench -- and a tab strip is the layout that stops working first. */
43
+ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps) {
44
+ const [open, setOpen] = useState(false);
45
+ const root = useRef<HTMLDivElement>(null);
46
+ const menuId = useId();
47
+ const here = currentPage(path ?? (typeof window === 'undefined'
48
+ ? '' : window.location.pathname), pages);
49
+
50
+ const close = useCallback(() => setOpen(false), []);
51
+
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const onKey = (e: KeyboardEvent) => {
55
+ if (e.key === 'Escape') {
56
+ close();
57
+ root.current?.querySelector('button')?.focus();
58
+ }
59
+ };
60
+ // Pointer, not click: a press that starts outside should dismiss before
61
+ // whatever it lands on reacts, the way every other menu on the page does.
62
+ const onDown = (e: PointerEvent) => {
63
+ if (!root.current?.contains(e.target as Node)) close();
64
+ };
65
+ document.addEventListener('keydown', onKey);
66
+ document.addEventListener('pointerdown', onDown);
67
+ return () => {
68
+ document.removeEventListener('keydown', onKey);
69
+ document.removeEventListener('pointerdown', onDown);
70
+ };
71
+ }, [open, close]);
72
+
73
+ // One lab is not a set to choose from, and a disclosure arrow promising a
74
+ // menu of itself is worse than no control.
75
+ if (pages.length < 2) {
76
+ return <h1 className={`lk-switcher-title${className ? ` ${className}` : ''}`}>{title}</h1>;
77
+ }
78
+
79
+ return (
80
+ <div className={`lk-switcher${className ? ` ${className}` : ''}`} ref={root}>
81
+ <h1 className="lk-switcher-title">
82
+ <button
83
+ type="button"
84
+ className="lk-switcher-trigger"
85
+ aria-expanded={open}
86
+ aria-haspopup="menu"
87
+ aria-controls={open ? menuId : undefined}
88
+ onClick={() => setOpen((was) => !was)}
89
+ >
90
+ {title}
91
+ <span className="lk-switcher-caret" aria-hidden="true" />
92
+ </button>
93
+ </h1>
94
+ {open && (
95
+ <div className="lk-switcher-menu" id={menuId} role="menu">
96
+ {pages.map((page, i) => (
97
+ <a
98
+ key={page.href}
99
+ href={page.href}
100
+ role="menuitem"
101
+ className="lk-switcher-item"
102
+ aria-current={i === here ? 'page' : undefined}
103
+ onClick={close}
104
+ >
105
+ {page.label}
106
+ </a>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
@@ -10,7 +10,8 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
- registerPainter: vi.fn(() => () => {}),
13
+ registerClear: vi.fn(() => () => {}),
14
+ registerPainter: vi.fn(() => () => {}),
14
15
  containerRef: vi.fn(),
15
16
  getContainer: vi.fn(() => null),
16
17
  };
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { describeElement, findOvershoot } from './fitCheck';
3
+
4
+ const bound = { top: 0, left: 0, bottom: 600, right: 800 };
5
+ const box = (bottom: number, right: number) => ({ top: 0, left: 0, bottom, right });
6
+
7
+ describe('findOvershoot', () => {
8
+ it('returns null when everything is inside the bound', () => {
9
+ expect(findOvershoot(bound, [{ item: 'a', edges: box(600, 800) }])).toBeNull();
10
+ });
11
+
12
+ it('ignores sub-pixel overshoot within the tolerance', () => {
13
+ expect(findOvershoot(bound, [{ item: 'a', edges: box(600.8, 800) }])).toBeNull();
14
+ });
15
+
16
+ it('names the item reaching furthest past the bound', () => {
17
+ const hit = findOvershoot(bound, [
18
+ { item: 'header', edges: box(640, 800) },
19
+ { item: 'tile', edges: box(900, 800) },
20
+ { item: 'footer', edges: box(700, 800) },
21
+ ]);
22
+ expect(hit).toEqual({ item: 'tile', by: 300, axis: 'y' });
23
+ });
24
+
25
+ it('compares both axes and reports the worse one', () => {
26
+ const hit = findOvershoot(bound, [
27
+ { item: 'tall', edges: box(650, 800) },
28
+ { item: 'wide', edges: box(600, 1000) },
29
+ ]);
30
+ expect(hit).toEqual({ item: 'wide', by: 200, axis: 'x' });
31
+ });
32
+
33
+ it('keeps the first item on a tie, which is the outermost in document order', () => {
34
+ const hit = findOvershoot(bound, [
35
+ { item: 'shell', edges: box(616, 800) },
36
+ { item: 'footer', edges: box(616, 800) },
37
+ ]);
38
+ expect(hit?.item).toBe('shell');
39
+ });
40
+ });
41
+
42
+ describe('describeElement', () => {
43
+ it('writes the tag and every class', () => {
44
+ const el = document.createElement('DIV');
45
+ el.className = 'lk-shell-body extra';
46
+ expect(describeElement(el)).toBe('div.lk-shell-body.extra');
47
+ });
48
+
49
+ it('places a classless element by its nearest classed ancestor', () => {
50
+ const outer = document.createElement('div');
51
+ outer.className = 'lk-trial';
52
+ const mid = document.createElement('div');
53
+ const leaf = document.createElement('canvas');
54
+ outer.appendChild(mid).appendChild(leaf);
55
+ expect(describeElement(leaf)).toBe('canvas in div.lk-trial');
56
+ });
57
+ });
@@ -0,0 +1,125 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /** An element's edges, in the same coordinate space as the bound it is tested against. */
4
+ export interface Edges {
5
+ top: number;
6
+ left: number;
7
+ bottom: number;
8
+ right: number;
9
+ }
10
+
11
+ export interface Overshoot<T> {
12
+ item: T;
13
+ /** How far past the bound the item reaches, in px, on whichever axis is worse. */
14
+ by: number;
15
+ axis: 'x' | 'y';
16
+ }
17
+
18
+ /** The item reaching furthest past the bottom or right of `bound`. On a tie the
19
+ * first item wins, so given document order it names the outermost box. */
20
+ export function findOvershoot<T>(
21
+ bound: Edges,
22
+ items: Iterable<{ item: T; edges: Edges }>,
23
+ tolerance = 1,
24
+ ): Overshoot<T> | null {
25
+ let worst: Overshoot<T> | null = null;
26
+ for (const { item, edges } of items) {
27
+ const y = edges.bottom - bound.bottom;
28
+ const x = edges.right - bound.right;
29
+ const by = Math.max(x, y);
30
+ if (by <= tolerance) continue;
31
+ if (!worst || by > worst.by) worst = { item, by, axis: y >= x ? 'y' : 'x' };
32
+ }
33
+ return worst;
34
+ }
35
+
36
+ function tagAndClasses(el: Element): string {
37
+ return el.tagName.toLowerCase() + [...el.classList].map((c) => `.${c}`).join('');
38
+ }
39
+
40
+ /** `div.lk-shell-body` — the tag and classes, which is what a reader greps for.
41
+ * A classless element is placed by its nearest classed ancestor. */
42
+ export function describeElement(el: Element): string {
43
+ if (el.classList.length > 0) return tagAndClasses(el);
44
+ let up = el.parentElement;
45
+ while (up && up.classList.length === 0) up = up.parentElement;
46
+ return up ? `${tagAndClasses(el)} in ${tagAndClasses(up)}` : tagAndClasses(el);
47
+ }
48
+
49
+ function edgesOf(el: Element): Edges {
50
+ const r = el.getBoundingClientRect();
51
+ return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
52
+ }
53
+
54
+ function* candidates(root: Element): Iterable<{ item: Element; edges: Edges }> {
55
+ yield { item: root, edges: edgesOf(root) };
56
+ for (const el of root.querySelectorAll('*')) yield { item: el, edges: edgesOf(el) };
57
+ }
58
+
59
+ function overflows(el: Element): boolean {
60
+ return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
61
+ }
62
+
63
+ /** Why a mounted lab scrolls, or null when it fits. The document only counts
64
+ * when the lab is what reaches past the viewport, so a lab embedded in a long
65
+ * page does not report the page's own scroll. */
66
+ export function explainLabScroll(lab: Element, shellBody: Element): string | null {
67
+ if (overflows(shellBody)) {
68
+ const r = shellBody.getBoundingClientRect();
69
+ const bound = {
70
+ top: r.top,
71
+ left: r.left,
72
+ bottom: r.top + shellBody.clientHeight,
73
+ right: r.left + shellBody.clientWidth,
74
+ };
75
+ const hit = findOvershoot(bound, candidates(shellBody));
76
+ return hit
77
+ ? `.lk-shell-body scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past it (${hit.axis})`
78
+ : '.lk-shell-body scrolls';
79
+ }
80
+
81
+ const doc = document.scrollingElement ?? document.documentElement;
82
+ if (!overflows(doc)) return null;
83
+ const r = lab.getBoundingClientRect();
84
+ const bound = {
85
+ top: r.top,
86
+ left: r.left,
87
+ bottom: r.top + Math.min(r.height, doc.clientHeight),
88
+ right: r.left + Math.min(r.width, doc.clientWidth),
89
+ };
90
+ const hit = findOvershoot(bound, candidates(lab));
91
+ return hit
92
+ ? `the page scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past the lab's box (${hit.axis})`
93
+ : null;
94
+ }
95
+
96
+ const SETTLE_MS = 300;
97
+
98
+ /** Dev only: warns once, naming the culprit, when a mounted lab scrolls. */
99
+ export function useLabFitWarning(labBody: Element | null): void {
100
+ useEffect(() => {
101
+ if (process.env.NODE_ENV === 'production') return;
102
+ if (!labBody || typeof ResizeObserver === 'undefined') return;
103
+ const lab = labBody.closest('.lk-lab');
104
+ const shellBody = labBody.closest('.lk-shell-body');
105
+ if (!lab || !shellBody) return;
106
+
107
+ // A shrinking window overflows for a frame or two before the workspace
108
+ // re-lays out, so only overflow that outlasts the resize is reported.
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ const observer = new ResizeObserver(() => {
111
+ clearTimeout(timer);
112
+ timer = setTimeout(() => {
113
+ const why = explainLabScroll(lab, shellBody);
114
+ if (!why) return;
115
+ observer.disconnect();
116
+ console.warn(`[labkit] <Lab> should fit its container without scrolling, but ${why}.`);
117
+ }, SETTLE_MS);
118
+ });
119
+ for (const el of [document.documentElement, lab, shellBody, labBody]) observer.observe(el);
120
+ return () => {
121
+ clearTimeout(timer);
122
+ observer.disconnect();
123
+ };
124
+ }, [labBody]);
125
+ }
package/src/lab/index.ts CHANGED
@@ -6,5 +6,7 @@ export type { LabPaletteProps } from './LabPalette';
6
6
  export { LabPalette } from './LabPalette';
7
7
  export type { LabShellProps } from './LabShell';
8
8
  export { LabShell } from './LabShell';
9
+ export type { LabPage, LabSwitcherProps } from './LabSwitcher';
10
+ export { currentPage, LabSwitcher } from './LabSwitcher';
9
11
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
10
12
  export { Workspace } from './Workspace';
@@ -4,7 +4,7 @@
4
4
  on, painted by whichever painter suits the instrument's content.
5
5
 
6
6
  The magnifier itself is not here. `@weasel-js/loupe` holds the model — aim,
7
- factor, mode, colour, picking — over a `LoupeSurface` it asks five questions.
7
+ factor, mode, color, picking — over a `LoupeSurface` it asks five questions.
8
8
  This directory binds that model to a labkit trial and draws it.
9
9
 
10
10
  ## Files
@@ -7,7 +7,7 @@
7
7
  // Nothing under the lens stops working while it is up: the pan, the wheel and
8
8
  // any overlay of the instrument's own keep receiving the pointer.
9
9
  pointer-events: none;
10
- border: var(--wzl-border-w) solid var(--wzl-border-raised);
10
+ border: var(--wzl-border-w) solid var(--wzl-border-strong);
11
11
  box-shadow: 0 2px 8px var(--wzl-shadow);
12
12
  // A painter's ground shows through wherever it draws nothing, and a vector
13
13
  // lens leaves the gaps between marks transparent. This is the trial's own
@@ -61,7 +61,7 @@ export function lensSourceRect(
61
61
  }
62
62
 
63
63
  /**
64
- * The colour a stack presents at a point: the topmost visible layer with
64
+ * The color a stack presents at a point: the topmost visible layer with
65
65
  * anything opaque there, or `null` when every layer is transparent — which is
66
66
  * the honest answer for a point showing nothing but the workspace behind.
67
67
  */
@@ -43,7 +43,7 @@ export interface LoupeCapability<TS = unknown, TC = unknown> {
43
43
  /** Held for a momentary peek while the loupe is off. Default `'Alt'`; `null`
44
44
  * turns hold-to-peek off. Matched against `KeyboardEvent.key`. */
45
45
  peekKey?: string | null;
46
- /** Called with the colour under the aim, wherever the surface can say. The
46
+ /** Called with the color under the aim, wherever the surface can say. The
47
47
  * canvas painter reads it back; a DOM loupe has no pixels to sample. */
48
48
  onColorChange?: (hex: string) => void;
49
49
  }
@@ -148,7 +148,7 @@ describe('useLoupe', () => {
148
148
  expect(wheel.defaultPrevented).toBe(true);
149
149
  });
150
150
 
151
- it('reports the colour under the aim', () => {
151
+ it('reports the color under the aim', () => {
152
152
  const onColorChange = vi.fn();
153
153
  render(
154
154
  <Harness sample={(p) => (p.x > 10 ? '#ff0000' : '#00ff00')} onColorChange={onColorChange} />,
@@ -14,7 +14,7 @@ export interface UseLoupeOptions {
14
14
  hostRef: RefObject<HTMLElement | null>;
15
15
  /** Whether the loupe is turned on. Hold-to-peek shows it regardless. */
16
16
  enabled: boolean;
17
- /** Hex colour at a host point. Omitted, the loupe reports no colour — which
17
+ /** Hex color at a host point. Omitted, the loupe reports no color — which
18
18
  * is the honest answer for a surface with no pixels to read. */
19
19
  sample?: (p: LoupePoint) => string | null;
20
20
  }
@@ -37,6 +37,8 @@ export {
37
37
  type ChromaCurve,
38
38
  type ChromaCurvePoint,
39
39
  ComboBox,
40
+ type ComboBoxCommit,
41
+ type ComboBoxFilter,
40
42
  ComboBoxItem,
41
43
  type ComboBoxItemProps,
42
44
  type ComboBoxOption,
@@ -100,6 +102,9 @@ export {
100
102
  type LayerStackProps,
101
103
  type LogicalMod,
102
104
  type LogicalModSpec,
105
+ MenuButton,
106
+ type MenuButtonItem,
107
+ type MenuButtonProps,
103
108
  MINUS_SIGN,
104
109
  NumberField,
105
110
  type NumberFieldProps,
@@ -188,6 +193,10 @@ export {
188
193
  type TrackCtx,
189
194
  type UseReorderDragListOptions,
190
195
  type UseRovingTabIndexOptions,
196
+ useAsyncOptions,
197
+ type AsyncOption,
198
+ type AsyncOptionsResult,
199
+ type UseAsyncOptionsProps,
191
200
  useReorderDragList,
192
201
  useRovingTabIndex,
193
202
  visiblePrefSubtree,
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { localStorageAdapter } from '../state/adapters';
3
+ import { Persistence } from '../state/Persistence';
2
4
  import { FloatingPanel } from './FloatingPanel';
3
5
  import { Legend } from './Legend';
4
6
 
@@ -43,4 +45,14 @@ export const HoldingALegend: Story = {
43
45
  },
44
46
  };
45
47
 
46
- export const Remembered: Story = { args: { storageKey: 'labkit.story.panel' } };
48
+ // Remembered only under a provider; this one keeps it across story reloads.
49
+ export const Remembered: Story = {
50
+ args: { persist: 'story-panel' },
51
+ decorators: [
52
+ (Story) => (
53
+ <Persistence storageKey="labkit-story" storage={localStorageAdapter}>
54
+ <Story />
55
+ </Persistence>
56
+ ),
57
+ ],
58
+ };
@@ -1,5 +1,8 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createMemoryAdapter } from '../state/adapters';
4
+ import { labPrefix } from '../state/labRecords';
5
+ import { Persistence } from '../state/Persistence';
3
6
  import { FloatingPanel } from './FloatingPanel';
4
7
 
5
8
  // The shared stub reports one size for every element, which makes the panel and
@@ -219,34 +222,62 @@ describe('FloatingPanel dragging', () => {
219
222
  });
220
223
 
221
224
  describe('FloatingPanel persistence', () => {
222
- it('writes its placement under the given key once dragged', () => {
223
- const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
225
+ const PLACE = `${labPrefix('fp')}value:lab:legend`;
226
+
227
+ function persisted(backing: Map<string, unknown>, ui: React.ReactElement) {
228
+ return render(
229
+ <Persistence storageKey="fp" storage={createMemoryAdapter(backing)}>
230
+ <div>{ui}</div>
231
+ </Persistence>,
232
+ );
233
+ }
234
+
235
+ it('writes its placement under its persist name once dragged', async () => {
236
+ const backing = new Map<string, unknown>();
237
+ const { container } = persisted(backing, <FloatingPanel persist="legend">x</FloatingPanel>);
238
+ await waitFor(() => expect(container.querySelector('.lk-floating-panel')).not.toBeNull());
239
+ const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
224
240
  fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
225
241
  fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
226
242
  fireEvent.pointerUp(panel);
227
- const stored = JSON.parse(localStorage.getItem('k') ?? 'null');
228
- expect(stored).toMatchObject({ x: expect.any(Number), y: expect.any(Number) });
229
- expect(stored).toHaveProperty('anchor');
243
+ await waitFor(() =>
244
+ expect(backing.get(PLACE)).toMatchObject({ x: expect.any(Number), y: expect.any(Number) }),
245
+ );
246
+ expect(backing.get(PLACE)).toHaveProperty('anchor');
230
247
  });
231
248
 
232
- it('restores what it stored on a later mount', () => {
233
- localStorage.setItem('k', JSON.stringify({ x: 140, y: 90, anchor: null }));
234
- const { panel } = renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>);
235
- expect(panel.style.left).toBe('140px');
249
+ it('restores what it stored on a later mount', async () => {
250
+ const backing = new Map<string, unknown>([[PLACE, { x: 140, y: 90, anchor: null }]]);
251
+ const { container } = persisted(backing, <FloatingPanel persist="legend">x</FloatingPanel>);
252
+ // Placed only once both boxes are measured, which can land a render later.
253
+ await waitFor(() =>
254
+ expect(
255
+ (container.querySelector('.lk-floating-panel') as HTMLElement | null)?.style.left,
256
+ ).toBe('140px'),
257
+ );
258
+ const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
236
259
  expect(panel.style.top).toBe('90px');
237
260
  });
238
261
 
239
- it('writes nothing when no key is given', () => {
240
- const { panel } = renderPanel(<FloatingPanel>x</FloatingPanel>);
262
+ it('writes nothing without a persist name', async () => {
263
+ const backing = new Map<string, unknown>();
264
+ const { container, unmount } = persisted(backing, <FloatingPanel>x</FloatingPanel>);
265
+ await waitFor(() => expect(container.querySelector('.lk-floating-panel')).not.toBeNull());
266
+ const panel = container.querySelector('.lk-floating-panel') as HTMLElement;
241
267
  fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
242
268
  fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
243
269
  fireEvent.pointerUp(panel);
244
- expect(localStorage.length).toBe(0);
270
+ unmount();
271
+ await new Promise((r) => setTimeout(r, 20));
272
+ expect(backing.size).toBe(0);
245
273
  });
246
274
 
247
- it('survives a corrupt stored value rather than throwing', () => {
248
- localStorage.setItem('k', '{{{');
249
- expect(() => renderPanel(<FloatingPanel storageKey="k">x</FloatingPanel>)).not.toThrow();
275
+ it('forgets its placement with no provider above it', () => {
276
+ const { panel } = renderPanel(<FloatingPanel persist="legend">x</FloatingPanel>);
277
+ fireEvent.pointerDown(panel, { clientX: 100, clientY: 100 });
278
+ fireEvent.pointerMove(panel, { clientX: 140, clientY: 160 });
279
+ fireEvent.pointerUp(panel);
280
+ expect(localStorage.length).toBe(0);
250
281
  });
251
282
  });
252
283