@weasel-js/labkit 1.4.4 → 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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  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-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -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
 
@@ -9,6 +9,7 @@ import {
9
9
  type FloatingPlacement,
10
10
  floatingStrategy,
11
11
  } from 'windease';
12
+ import { usePersistedState } from '../state/usePersistedState';
12
13
 
13
14
  /** The strategy holds a map keyed by item id; a panel is always a lone item. */
14
15
  const ITEM_ID = 'panel';
@@ -22,8 +23,10 @@ export interface FloatingPanelProps {
22
23
  snapCorners?: readonly Corner[];
23
24
  /** Pixels in from a corner when snapped. Default 12. */
24
25
  inset?: number;
25
- /** localStorage key to remember its position under. Omit to forget on reload. */
26
- storageKey?: string;
26
+ /** Name to remember its position under, through `usePersistedState` — so it
27
+ * is remembered only inside a lab or a `<Persistence>`. Omit to forget on
28
+ * reload. */
29
+ persist?: string;
27
30
  className?: string;
28
31
  }
29
32
 
@@ -47,16 +50,6 @@ function isInteractive(target: EventTarget | null): boolean {
47
50
  return target.closest('input, button, a, select, textarea, [data-no-drag]') !== null;
48
51
  }
49
52
 
50
- function readStored(key: string | undefined): FloatingPlacement | null {
51
- if (!key) return null;
52
- try {
53
- const raw = localStorage.getItem(key);
54
- return raw ? (JSON.parse(raw) as FloatingPlacement) : null;
55
- } catch {
56
- return null;
57
- }
58
- }
59
-
60
53
  /**
61
54
  * A draggable box that floats over its offset parent and snaps to its corners.
62
55
  *
@@ -70,7 +63,7 @@ export function FloatingPanel({
70
63
  anchor = DEFAULT_ANCHOR,
71
64
  snapCorners,
72
65
  inset = DEFAULT_INSET,
73
- storageKey,
66
+ persist,
74
67
  className,
75
68
  }: FloatingPanelProps) {
76
69
  const ref = useRef<HTMLDivElement>(null);
@@ -79,18 +72,11 @@ export function FloatingPanel({
79
72
 
80
73
  const [container, setContainer] = useState({ w: 0, h: 0 });
81
74
  const [size, setSize] = useState({ w: 0, h: 0 });
82
- const [place, setPlace] = useState<FloatingPlacement>(
83
- () => readStored(storageKey) ?? { x: 0, y: 0, anchor },
84
- );
85
-
86
- useEffect(() => {
87
- if (!storageKey) return;
88
- try {
89
- localStorage.setItem(storageKey, JSON.stringify(place));
90
- } catch {
91
- // A full or disabled store is not worth failing a lab over.
92
- }
93
- }, [place, storageKey]);
75
+ const [place, setPlace] = usePersistedState<FloatingPlacement>(persist, () => ({
76
+ x: 0,
77
+ y: 0,
78
+ anchor,
79
+ }));
94
80
 
95
81
  const item = useMemo(
96
82
  () => ({
@@ -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 { FpsMeter } from './FpsMeter';
3
3
 
4
4
  const meta: Meta<typeof FpsMeter> = {
@@ -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 type { JobHandle } from '../job/types';
3
3
  import { JobProgress } from './JobProgress';
4
4
 
@@ -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 { Legend } from './Legend';
3
3
 
4
4
  const meta: Meta<typeof Legend> = {
@@ -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 { ScaleIndicator } from './ScaleIndicator';
3
3
 
4
4
  const meta: Meta<typeof ScaleIndicator> = {
@@ -60,6 +60,13 @@
60
60
 
61
61
  .lk-sidebar-section {
62
62
  border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
63
+
64
+ // The last section runs to the bottom of its pane. Stopped at its content,
65
+ // its rule and the bare pane under it read as a second, empty zone.
66
+ &:last-child {
67
+ flex-grow: 1;
68
+ border-bottom: 0;
69
+ }
63
70
  }
64
71
 
65
72
  .lk-sidebar-section__bar {
@@ -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 { Sidebar } from './Sidebar';
4
4
 
@@ -0,0 +1,14 @@
1
+ // The strip's own seam. windease positions and sizes it; this is only what it
2
+ // looks like.
3
+ .lk-split [data-affordance-kind='resize-x'] {
4
+ background: var(--wzl-border);
5
+ }
6
+
7
+ .lk-split .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
8
+ .lk-split .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
9
+ background: var(--wzl-accent);
10
+ }
11
+
12
+ .lk-split .windease-affordance-hit:focus-visible {
13
+ outline: none;
14
+ }
@@ -0,0 +1,133 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { Split } from './Split';
4
+
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
7
+ function renderBody(props: Partial<Parameters<typeof Split>[0]> = {}) {
8
+ return render(
9
+ <Split viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
10
+ <p>instrument output</p>
11
+ </Split>,
12
+ );
13
+ }
14
+
15
+ describe('Split', () => {
16
+ it('renders both panes', () => {
17
+ renderBody();
18
+ expect(screen.getByText('sidebar body')).toBeInTheDocument();
19
+ expect(screen.getByText('instrument output')).toBeInTheDocument();
20
+ });
21
+
22
+ it('puts a labeled separator between the sidebar and the content', () => {
23
+ renderBody({ width: 320 });
24
+ const seam = screen.getByRole('separator');
25
+ expect(seam).toHaveAttribute('aria-orientation', 'horizontal');
26
+ expect(seam).toHaveAttribute('aria-valuenow', '320');
27
+ expect(seam).toHaveAccessibleName(/sidebar/i);
28
+ });
29
+
30
+ it('reports the width a drag lands on', () => {
31
+ const onWidthChange = vi.fn();
32
+ renderBody({ width: 320, onWidthChange });
33
+ const seam = screen.getByRole('separator');
34
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
35
+ fireEvent.pointerMove(seam, { clientX: 380, clientY: 100 });
36
+ fireEvent.pointerUp(seam, { clientX: 380, clientY: 100 });
37
+ expect(onWidthChange).toHaveBeenLastCalledWith(380);
38
+ });
39
+
40
+ it('resizes from the keyboard', () => {
41
+ const onWidthChange = vi.fn();
42
+ renderBody({ width: 320, onWidthChange });
43
+ const seam = screen.getByRole('separator');
44
+ fireEvent.keyDown(seam, { key: 'ArrowRight' });
45
+ expect(onWidthChange).toHaveBeenLastCalledWith(328);
46
+ });
47
+
48
+ it('refuses to drag the sidebar past its stated bounds', () => {
49
+ const onWidthChange = vi.fn();
50
+ renderBody({ width: 320, minWidth: 200, maxWidth: 400, onWidthChange });
51
+ const seam = screen.getByRole('separator');
52
+ expect(seam).toHaveAttribute('aria-valuemin', '200');
53
+ expect(seam).toHaveAttribute('aria-valuemax', '400');
54
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
55
+ fireEvent.pointerMove(seam, { clientX: 900, clientY: 100 });
56
+ expect(onWidthChange).toHaveBeenLastCalledWith(400);
57
+ });
58
+
59
+ it('leaves the content pane a floor the seam cannot cross', () => {
60
+ const onWidthChange = vi.fn();
61
+ renderBody({ width: 320, maxWidth: 10_000, contentMinWidth: 300, onWidthChange });
62
+ const seam = screen.getByRole('separator');
63
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
64
+ fireEvent.pointerMove(seam, { clientX: 5_000, clientY: 100 });
65
+ expect(onWidthChange).toHaveBeenLastCalledWith(VIEWPORT.w - 300);
66
+ });
67
+
68
+ it('puts the caller’s class names on its boxes', () => {
69
+ const { container } = render(
70
+ <Split
71
+ viewport={VIEWPORT}
72
+ className="lk-x__panes"
73
+ sidebarClassName="lk-x__side"
74
+ contentClassName="lk-x__main"
75
+ label="Stories"
76
+ sidebar={<p>side</p>}
77
+ >
78
+ <p>main</p>
79
+ </Split>,
80
+ );
81
+ expect(container.querySelector('.lk-x__panes.lk-split')).not.toBeNull();
82
+ expect(container.querySelector('.lk-x__side')?.textContent).toBe('side');
83
+ expect(container.querySelector('.lk-x__main')?.textContent).toBe('main');
84
+ });
85
+
86
+ it('puts the sidebar after the content on the end side, where dragging the seam left widens it', () => {
87
+ const onWidthChange = vi.fn();
88
+ const { container } = render(
89
+ <Split
90
+ viewport={VIEWPORT}
91
+ side="end"
92
+ width={320}
93
+ sidebarClassName="lk-x__side"
94
+ contentClassName="lk-x__main"
95
+ sidebar={<p>side</p>}
96
+ onWidthChange={onWidthChange}
97
+ >
98
+ <p>main</p>
99
+ </Split>,
100
+ );
101
+ const side = container.querySelector('.lk-x__side') as HTMLElement;
102
+ const main = container.querySelector('.lk-x__main') as HTMLElement;
103
+ expect(main.compareDocumentPosition(side) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
104
+ const seam = screen.getByRole('separator');
105
+ fireEvent.pointerDown(seam, { clientX: 480, clientY: 100 });
106
+ fireEvent.pointerMove(seam, { clientX: 420, clientY: 100 });
107
+ fireEvent.pointerUp(seam, { clientX: 420, clientY: 100 });
108
+ expect(onWidthChange).toHaveBeenLastCalledWith(380);
109
+ });
110
+
111
+ it('names its seam after the sidebar label', () => {
112
+ render(
113
+ <Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
114
+ <p>main</p>
115
+ </Split>,
116
+ );
117
+ expect(screen.getByRole('separator')).toHaveAccessibleName(/stories/i);
118
+ });
119
+
120
+ it('renames its seam when the label changes', () => {
121
+ const { rerender } = render(
122
+ <Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
123
+ <p>main</p>
124
+ </Split>,
125
+ );
126
+ rerender(
127
+ <Split viewport={VIEWPORT} label="Outline" sidebar={<p>side</p>}>
128
+ <p>main</p>
129
+ </Split>,
130
+ );
131
+ expect(screen.getByRole('separator')).toHaveAccessibleName(/outline/i);
132
+ });
133
+ });
@@ -0,0 +1,175 @@
1
+ import { type ReactNode, useEffect, useMemo, useRef } from 'react';
2
+ import { asNodeId, createNode, Store, stripStrategy } from 'windease';
3
+ import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
4
+
5
+ const ZONE_ID = asNodeId('lk-split');
6
+ const SIDEBAR_ID = asNodeId('lk-split-sidebar');
7
+ const CONTENT_ID = asNodeId('lk-split-content');
8
+ const STRATEGIES = { strip: stripStrategy as never };
9
+
10
+ /** Props for `<Split>`. */
11
+ export interface SplitProps {
12
+ /** The sidebar pane's contents. */
13
+ sidebar: ReactNode;
14
+ /** The edge the sidebar sits on. Read once, when the strip mounts. Default `'start'`. */
15
+ side?: 'start' | 'end';
16
+ /** The content pane's contents. */
17
+ children: ReactNode;
18
+ /** Names the sidebar pane, and through it the seam. Default `'Sidebar'`. */
19
+ label?: string;
20
+ /** Extra classes on the strip, which always has `lk-split`. */
21
+ className?: string;
22
+ /** The class on the sidebar pane. */
23
+ sidebarClassName?: string;
24
+ /** The class on the content pane. */
25
+ contentClassName?: string;
26
+ /** The sidebar's current extent in pixels. Omit to leave it unpersisted: the
27
+ * seam still works, its result just does not outlive the mount. */
28
+ width?: number;
29
+ /** Where the sidebar starts before anyone drags it. */
30
+ defaultWidth?: number;
31
+ minWidth?: number;
32
+ maxWidth?: number;
33
+ /** How little the content pane may be squeezed to. Reported as the seam's
34
+ * reachable maximum, so the drag stops there rather than clamping silently. */
35
+ contentMinWidth?: number;
36
+ /** Fires through the drag, not on release: the pane is already there. */
37
+ onWidthChange?: (width: number) => void;
38
+ /**
39
+ * Fixed extent. Omit in an app — the strip measures its own box. Supply it
40
+ * where nothing measures, notably jsdom, or both panes lay out at zero.
41
+ */
42
+ viewport?: { w: number; h: number };
43
+ }
44
+
45
+ /**
46
+ * A sidebar and a content pane as a two-pane strip with a draggable seam.
47
+ *
48
+ * The seam is `stripStrategy`'s own resize affordance, so it arrives as a
49
+ * `role="separator"` carrying the reachable range, operable by pointer and by
50
+ * arrows / Home / End, and clamped against the content pane's floor rather
51
+ * than against the sidebar's alone.
52
+ */
53
+ export function Split({
54
+ sidebar,
55
+ children,
56
+ side = 'start',
57
+ label = 'Sidebar',
58
+ className,
59
+ sidebarClassName,
60
+ contentClassName,
61
+ width,
62
+ defaultWidth = 320,
63
+ minWidth = 140,
64
+ maxWidth = 720,
65
+ contentMinWidth = 160,
66
+ onWidthChange,
67
+ viewport,
68
+ }: SplitProps) {
69
+ // The cross-axis half of every size hint below is inert: a horizontal strip
70
+ // stretches its panes to the container's height and never reads `h`.
71
+ const storeRef = useRef<Store | null>(null);
72
+ if (storeRef.current === null) {
73
+ const store = new Store();
74
+ store.registerNode(
75
+ createNode({
76
+ kind: 'zone',
77
+ id: ZONE_ID,
78
+ // A strip's seam resizes the pane before it, which on the end side is the
79
+ // content; 'neighbor' has the seam size the sidebar after it too.
80
+ container: {
81
+ strategyId: 'strip',
82
+ config: {
83
+ axis: 'x',
84
+ resizable: true,
85
+ ...(side === 'end' ? { resizeMode: 'neighbor' } : {}),
86
+ },
87
+ },
88
+ }),
89
+ );
90
+ const sidebarNode = createNode({
91
+ kind: 'sidebar',
92
+ id: SIDEBAR_ID,
93
+ parentId: ZONE_ID,
94
+ meta: { title: label },
95
+ placement: { size: { w: width ?? defaultWidth } },
96
+ hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
97
+ });
98
+ const contentNode = createNode({
99
+ kind: 'content',
100
+ id: CONTENT_ID,
101
+ parentId: ZONE_ID,
102
+ meta: { title: 'Content' },
103
+ hints: { minSize: { w: contentMinWidth, h: 0 } },
104
+ });
105
+ // A strip lays its children out in registration order.
106
+ for (const node of side === 'end' ? [contentNode, sidebarNode] : [sidebarNode, contentNode]) {
107
+ store.registerNode(node);
108
+ }
109
+ store.showNode(SIDEBAR_ID);
110
+ store.showNode(CONTENT_ID);
111
+ storeRef.current = store;
112
+ }
113
+ const store = storeRef.current;
114
+
115
+ // What the store last held, so the placement event can tell a drag from the
116
+ // echo of our own write and a controlled `width` does not fight the seam.
117
+ const widthRef = useRef(width ?? defaultWidth);
118
+
119
+ useEffect(() => {
120
+ store.setHints(SIDEBAR_ID, {
121
+ minSize: { w: minWidth, h: 0 },
122
+ maxSize: { w: maxWidth, h: 0 },
123
+ });
124
+ store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
125
+ }, [store, minWidth, maxWidth, contentMinWidth]);
126
+
127
+ useEffect(() => {
128
+ store.setMeta(SIDEBAR_ID, { title: label });
129
+ }, [store, label]);
130
+
131
+ useEffect(() => {
132
+ if (width === undefined || width === widthRef.current) return;
133
+ widthRef.current = width;
134
+ store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
135
+ }, [store, width]);
136
+
137
+ const onWidthChangeRef = useRef(onWidthChange);
138
+ onWidthChangeRef.current = onWidthChange;
139
+ useEffect(
140
+ () =>
141
+ store.events.on('node.placementChanged', () => {
142
+ // A neighbor drag pins the content's width too; unpinned, it goes on filling the strip.
143
+ if ((store.getPlacement(CONTENT_ID)?.size as { w?: number } | undefined)?.w !== undefined) {
144
+ store.patchPlacement(CONTENT_ID, { size: {} });
145
+ }
146
+ const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
147
+ if (typeof next !== 'number' || next === widthRef.current) return;
148
+ widthRef.current = next;
149
+ onWidthChangeRef.current?.(next);
150
+ }),
151
+ [store],
152
+ );
153
+
154
+ const chrome = useMemo<ChromeMap>(
155
+ () => ({
156
+ sidebar: () => <div className={sidebarClassName}>{sidebar}</div>,
157
+ content: () => <div className={contentClassName}>{children}</div>,
158
+ }),
159
+ [sidebar, children, sidebarClassName, contentClassName],
160
+ );
161
+
162
+ return (
163
+ <Provider store={store}>
164
+ <StrategyRegistryProvider strategies={STRATEGIES}>
165
+ <Container
166
+ parentId={ZONE_ID}
167
+ chrome={chrome}
168
+ className={className ? `lk-split windease-zone ${className}` : 'lk-split windease-zone'}
169
+ affordances
170
+ viewport={viewport}
171
+ />
172
+ </StrategyRegistryProvider>
173
+ </Provider>
174
+ );
175
+ }
@@ -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 { StatusBar } from './StatusBar';
3
3
 
4
4
  const meta: Meta<typeof StatusBar> = {
@@ -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 { Toolbar } from './Toolbar';
3
3
 
4
4
  const meta: Meta<typeof Toolbar> = {
@@ -1,3 +1,4 @@
1
+ import { normalizeZoom } from '@weasel-js/core';
1
2
  import { NumberField, Slider } from '../passthrough/weasel-ui';
2
3
 
3
4
  /** Props for `<ZoomControl>`. */
@@ -30,10 +31,12 @@ export function ZoomControl({
30
31
  zoom,
31
32
  onZoomChange,
32
33
  onZoomCommit,
33
- min = 0.1,
34
- max = 8,
34
+ min: minProp = 0.1,
35
+ max: maxProp = 8,
35
36
  className,
36
37
  }: ZoomControlProps) {
38
+ const min = normalizeZoom(minProp);
39
+ const max = normalizeZoom(maxProp);
37
40
  const commit = onZoomCommit ?? onZoomChange;
38
41
  const emit = (track: number, done: boolean): void => {
39
42
  const next = clamp(fromTrack(track), min, max);
@@ -9,6 +9,8 @@ export type { ScaleIndicatorProps } from './ScaleIndicator';
9
9
  export { ScaleIndicator } from './ScaleIndicator';
10
10
  export type { SidebarProps } from './Sidebar';
11
11
  export { Sidebar } from './Sidebar';
12
+ export type { SplitProps } from './Split';
13
+ export { Split } from './Split';
12
14
  export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
13
15
  export { StatusBar } from './StatusBar';
14
16
  export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
@@ -0,0 +1,95 @@
1
+ .lk-specimen {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--wzl-space-lg);
5
+ padding: var(--wzl-space-lg);
6
+ background: var(--wzl-surface);
7
+ color: var(--wzl-fg);
8
+ font-family: var(--wzl-font-ui);
9
+ font-size: var(--wzl-font-size);
10
+ }
11
+
12
+ .lk-specimen__section {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: var(--wzl-space-sm);
16
+ }
17
+
18
+ .lk-specimen__heading {
19
+ margin: 0;
20
+ padding-block-end: var(--wzl-space-xs);
21
+ border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
22
+ color: var(--wzl-fg-muted);
23
+ font-family: var(--wzl-font-display);
24
+ font-size: var(--wzl-font-size-sm);
25
+ font-weight: var(--wzl-font-weight-normal);
26
+ letter-spacing: 0.08em;
27
+ text-transform: uppercase;
28
+ }
29
+
30
+ .lk-specimen__grid {
31
+ display: grid;
32
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
33
+ gap: var(--wzl-space-md);
34
+ align-items: start;
35
+ }
36
+
37
+ .lk-specimen__cell {
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: var(--wzl-space-xs);
41
+ min-width: 0;
42
+ }
43
+
44
+ .lk-specimen__label {
45
+ color: var(--wzl-fg-subtle);
46
+ font-family: var(--wzl-font-mono);
47
+ font-size: var(--wzl-font-size-xs);
48
+ }
49
+
50
+ .lk-specimen__row {
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ align-items: center;
54
+ gap: var(--wzl-space-sm);
55
+ }
56
+
57
+ .lk-specimen__cell--wide {
58
+ grid-column: 1 / -1;
59
+ }
60
+
61
+ .lk-specimen__box {
62
+ position: relative;
63
+ display: flex;
64
+ height: 160px;
65
+ overflow: hidden;
66
+ border: var(--wzl-border-w) solid var(--wzl-line-subtle);
67
+ border-radius: var(--wzl-radius-sm);
68
+ }
69
+
70
+ .lk-specimen__box-main {
71
+ flex: 1;
72
+ min-width: 0;
73
+ padding: var(--wzl-space-sm);
74
+ }
75
+
76
+ .lk-specimen__pad {
77
+ padding: var(--wzl-space-xs) var(--wzl-space-sm);
78
+ }
79
+
80
+ .lk-specimen__pane {
81
+ flex: none;
82
+ width: var(--lk-specimen-pane-w);
83
+ padding: var(--wzl-space-sm);
84
+ }
85
+
86
+ .lk-specimen__timeline {
87
+ height: 180px;
88
+ }
89
+
90
+ .lk-specimen__floating-host {
91
+ position: relative;
92
+ height: 160px;
93
+ border: var(--wzl-border-w) solid var(--wzl-line-subtle);
94
+ border-radius: var(--wzl-radius-sm);
95
+ }