@weasel-js/labkit 1.4.4 → 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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  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-MOM3GOVY.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-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.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-64ZCN3DA.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-U3IYHIAE.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 +110 -381
  53. package/dist/index.js +691 -817
  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 +164 -122
  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 +84 -50
  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/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -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
+ }
@@ -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
 
@@ -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
  () => ({
@@ -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,108 @@
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('names its seam after the sidebar label', () => {
87
+ render(
88
+ <Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
89
+ <p>main</p>
90
+ </Split>,
91
+ );
92
+ expect(screen.getByRole('separator')).toHaveAccessibleName(/stories/i);
93
+ });
94
+
95
+ it('renames its seam when the label changes', () => {
96
+ const { rerender } = render(
97
+ <Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
98
+ <p>main</p>
99
+ </Split>,
100
+ );
101
+ rerender(
102
+ <Split viewport={VIEWPORT} label="Outline" sidebar={<p>side</p>}>
103
+ <p>main</p>
104
+ </Split>,
105
+ );
106
+ expect(screen.getByRole('separator')).toHaveAccessibleName(/outline/i);
107
+ });
108
+ });