@weasel-js/labkit 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +177 -53
  51. package/dist/index.js +1333 -1683
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +26 -202
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +153 -40
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,6 +1,8 @@
1
1
  import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { WeaselProvider } from '@weasel-js/core';
2
3
  import { type RefObject, StrictMode, useRef } from 'react';
3
4
  import { describe, expect, it, vi } from 'vitest';
5
+ import { LoupeGestures } from './LoupeGestures';
4
6
  import { resolveLoupe } from './types';
5
7
  import { type LoupeState, useLoupe } from './useLoupe';
6
8
 
@@ -15,18 +17,29 @@ interface HarnessProps {
15
17
  }
16
18
 
17
19
  /** Reports the loupe's state as text, which is every assertion jsdom can make
18
- * about a magnifier — that the lens shows the right region is a screenshot. */
20
+ * about a magnifier — that the lens shows the right region is a screenshot.
21
+ *
22
+ * Mounts `<LoupeGestures>` the way `<TrialLoupe>` does, so the key and wheel
23
+ * cases below exercise the real dispatcher route rather than a stand-in. */
19
24
  function Harness({ enabled = true, seen, sample, ...rest }: HarnessProps) {
20
25
  const hostRef = useRef<HTMLDivElement | null>(null);
26
+ const capability = resolveLoupe(rest);
21
27
  const loupe = useLoupe({
22
28
  hostRef: hostRef as RefObject<HTMLElement | null>,
23
29
  enabled,
24
30
  sample,
25
- capability: resolveLoupe(rest),
31
+ capability,
26
32
  });
27
33
  seen?.(loupe);
28
34
  return (
29
35
  <div ref={hostRef} data-testid="host">
36
+ <WeaselProvider isolate>
37
+ <LoupeGestures
38
+ hostRef={hostRef as RefObject<HTMLElement | null>}
39
+ input={loupe.input}
40
+ peekKey={capability.peekKey ?? null}
41
+ />
42
+ </WeaselProvider>
30
43
  <span data-testid="visible">{String(loupe.visible)}</span>
31
44
  <span data-testid="aim">{`${loupe.aim.x},${loupe.aim.y}`}</span>
32
45
  <span data-testid="factor">{loupe.factor}</span>
@@ -94,6 +107,7 @@ describe('useLoupe', () => {
94
107
  act(() => {
95
108
  fireEvent.keyDown(window, { key: 'Alt' });
96
109
  });
110
+ expect(read('visible')).toBe('true');
97
111
  act(() => {
98
112
  fireEvent.blur(window);
99
113
  });
@@ -148,6 +162,49 @@ describe('useLoupe', () => {
148
162
  expect(wheel.defaultPrevented).toBe(true);
149
163
  });
150
164
 
165
+ it('stops a claimed wheel reaching the pan-zoom handler above it', () => {
166
+ // The real handler is React's, delegated to the root container, so what
167
+ // decides whether pan-zoom runs is whether the event bubbles past the
168
+ // host — `defaultPrevented` says nothing about that. A listener on an
169
+ // ancestor is the proxy for React's root one.
170
+ const above = vi.fn();
171
+ const { container } = render(<Harness />);
172
+ container.addEventListener('wheel', above);
173
+
174
+ const host = screen.getByTestId('host');
175
+ move(40, 25);
176
+ act(() => {
177
+ host.dispatchEvent(
178
+ new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }),
179
+ );
180
+ });
181
+ expect(above).not.toHaveBeenCalled();
182
+
183
+ fireEvent.pointerLeave(host);
184
+ act(() => {
185
+ host.dispatchEvent(
186
+ new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }),
187
+ );
188
+ });
189
+ expect(above).toHaveBeenCalledTimes(1);
190
+ });
191
+
192
+ it('leaves the native context menu and file drops to the lab', () => {
193
+ // The dispatcher suppresses `contextmenu` unconditionally and makes its
194
+ // element a drop target; the loupe mounts with both channels off, so a
195
+ // lab that turns a magnifier on keeps its right-click and its drops.
196
+ render(<Harness />);
197
+ const host = screen.getByTestId('host');
198
+ move(40, 25);
199
+
200
+ const menu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
201
+ act(() => {
202
+ host.dispatchEvent(menu);
203
+ });
204
+ expect(menu.defaultPrevented).toBe(false);
205
+ expect(host.classList.contains('weasel-dropover')).toBe(false);
206
+ });
207
+
151
208
  it('reports the color under the aim', () => {
152
209
  const onColorChange = vi.fn();
153
210
  render(
@@ -5,6 +5,8 @@ import {
5
5
  type LoupePoint,
6
6
  } from '@weasel-js/loupe';
7
7
  import { type RefObject, useCallback, useEffect, useReducer, useRef } from 'react';
8
+ import type { LoupeInputApi } from './loupeActions';
9
+ import { WHEEL_RATE } from './loupeActions';
8
10
  import type { ResolvedLoupe } from './types';
9
11
 
10
12
  /** Options for {@link useLoupe}. */
@@ -32,21 +34,21 @@ export interface LoupeState {
32
34
  setFactor: (factor: number) => void;
33
35
  /** Sample what the lens shows at a point inside it. */
34
36
  pick: (p?: LoupePoint) => string | null;
37
+ /** What `<LoupeGestures>` drives the lens through. Stable for the life of
38
+ * the hook, so registering the actions on it does not churn. */
39
+ input: LoupeInputApi;
35
40
  }
36
41
 
37
- /** How fast the wheel walks the magnification. Gentler than the pan-zoom
38
- * wheel: the lens' whole range is one order of magnitude. */
39
- const WHEEL_RATE = 0.002;
40
-
41
42
  /**
42
- * Binds `@weasel-js/loupe`'s model to a host element and drives it from the
43
- * pointer, the peek key and the wheel.
43
+ * Binds `@weasel-js/loupe`'s model to a host element and tracks the pointer
44
+ * across it.
44
45
  *
45
- * These are plain listeners rather than gesture bindings because labkit trials
46
- * do not route input through the dispatcher — see the loupe entry under
47
- * "Selection, actions & UI panels" in `docs/TODO.md`. They are all in this one
48
- * hook so that when trial input does go through it, this is the only file to
49
- * rewrite.
46
+ * The peek key and the wheel are not here: they are `loupe.peek` and
47
+ * `loupe.magnify`, routed through the gesture dispatcher that
48
+ * `<LoupeGestures>` mounts on the same host. Aiming stays a plain listener
49
+ * because a hover is not a gesture the grammar names — `GESTURE_DESCRIPTORS`
50
+ * has no continuous-motion entry, and inventing one to carry the lens' aim
51
+ * would be an input taxonomy change, not a loupe change.
50
52
  */
51
53
  export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
52
54
  const [, bump] = useReducer((n: number) => n + 1, 0);
@@ -128,53 +130,30 @@ export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptio
128
130
  overRef.current = false;
129
131
  bump();
130
132
  };
131
- const onWheel = (e: WheelEvent): void => {
132
- if (!shown()) return;
133
- // Ahead of the stack's own pan-zoom, which is a React handler on this same
134
- // element: capture runs first, and stopping propagation keeps the event
135
- // from reaching React's root listener at all.
136
- e.preventDefault();
137
- e.stopPropagation();
138
- model.setFactor(model.factor * Math.exp(-e.deltaY * WHEEL_RATE));
139
- };
140
133
 
141
134
  host.addEventListener('pointermove', onPointerMove);
142
135
  host.addEventListener('pointerleave', onPointerLeave);
143
- host.addEventListener('wheel', onWheel, { capture: true, passive: false });
144
136
  return () => {
145
137
  host.removeEventListener('pointermove', onPointerMove);
146
138
  host.removeEventListener('pointerleave', onPointerLeave);
147
- host.removeEventListener('wheel', onWheel, { capture: true });
148
139
  };
149
140
  }, [hostRef, model, shown]);
150
141
 
151
- const peekKey = capability.peekKey;
152
- useEffect(() => {
153
- if (peekKey == null) return;
154
- const setPeeking = (next: boolean): void => {
155
- if (peekingRef.current === next) return;
156
- peekingRef.current = next;
157
- bump();
158
- };
159
- const onKeyDown = (e: KeyboardEvent): void => {
160
- if (e.key === peekKey) setPeeking(true);
161
- };
162
- const onKeyUp = (e: KeyboardEvent): void => {
163
- if (e.key === peekKey) setPeeking(false);
164
- };
165
- // A key held while the window loses focus never sends its keyup, so the
166
- // peek would stay on until the key was pressed and released again.
167
- const onBlur = (): void => setPeeking(false);
168
-
169
- window.addEventListener('keydown', onKeyDown);
170
- window.addEventListener('keyup', onKeyUp);
171
- window.addEventListener('blur', onBlur);
172
- return () => {
173
- window.removeEventListener('keydown', onKeyDown);
174
- window.removeEventListener('keyup', onKeyUp);
175
- window.removeEventListener('blur', onBlur);
142
+ const inputRef = useRef<LoupeInputApi | null>(null);
143
+ if (inputRef.current === null) {
144
+ inputRef.current = {
145
+ shown,
146
+ setPeeking: (on) => {
147
+ if (peekingRef.current === on) return;
148
+ peekingRef.current = on;
149
+ bump();
150
+ },
151
+ magnifyBy: (deltaY) => {
152
+ const m = modelRef.current;
153
+ if (m) m.setFactor(m.factor * Math.exp(-deltaY * WHEEL_RATE));
154
+ },
176
155
  };
177
- }, [peekKey]);
156
+ }
178
157
 
179
158
  return {
180
159
  visible: shown(),
@@ -185,5 +164,6 @@ export function useLoupe({ capability, hostRef, enabled, sample }: UseLoupeOptio
185
164
  setMode: model.setMode,
186
165
  setFactor: model.setFactor,
187
166
  pick: model.pick,
167
+ input: inputRef.current,
188
168
  };
189
169
  }
@@ -15,6 +15,8 @@ export {
15
15
  type ActionsBarVariant,
16
16
  type AddPointMode,
17
17
  type AnchorRenderProps,
18
+ type AsyncOption,
19
+ type AsyncOptionsResult,
18
20
  type AxesSettings,
19
21
  Badge,
20
22
  type BadgeProps,
@@ -191,12 +193,10 @@ export {
191
193
  ToolPalette,
192
194
  type ToolPaletteProps,
193
195
  type TrackCtx,
196
+ type UseAsyncOptionsProps,
194
197
  type UseReorderDragListOptions,
195
198
  type UseRovingTabIndexOptions,
196
199
  useAsyncOptions,
197
- type AsyncOption,
198
- type AsyncOptionsResult,
199
- type UseAsyncOptionsProps,
200
200
  useReorderDragList,
201
201
  useRovingTabIndex,
202
202
  visiblePrefSubtree,
@@ -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 { localStorageAdapter } from '../state/adapters';
3
3
  import { Persistence } from '../state/Persistence';
4
4
  import { FloatingPanel } from './FloatingPanel';
@@ -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
 
@@ -83,6 +83,31 @@ describe('Split', () => {
83
83
  expect(container.querySelector('.lk-x__main')?.textContent).toBe('main');
84
84
  });
85
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
+
86
111
  it('names its seam after the sidebar label', () => {
87
112
  render(
88
113
  <Split viewport={VIEWPORT} label="Stories" sidebar={<p>side</p>}>
@@ -11,6 +11,8 @@ const STRATEGIES = { strip: stripStrategy as never };
11
11
  export interface SplitProps {
12
12
  /** The sidebar pane's contents. */
13
13
  sidebar: ReactNode;
14
+ /** The edge the sidebar sits on. Read once, when the strip mounts. Default `'start'`. */
15
+ side?: 'start' | 'end';
14
16
  /** The content pane's contents. */
15
17
  children: ReactNode;
16
18
  /** Names the sidebar pane, and through it the seam. Default `'Sidebar'`. */
@@ -51,6 +53,7 @@ export interface SplitProps {
51
53
  export function Split({
52
54
  sidebar,
53
55
  children,
56
+ side = 'start',
54
57
  label = 'Sidebar',
55
58
  className,
56
59
  sidebarClassName,
@@ -72,28 +75,37 @@ export function Split({
72
75
  createNode({
73
76
  kind: 'zone',
74
77
  id: ZONE_ID,
75
- container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
76
- }),
77
- );
78
- store.registerNode(
79
- createNode({
80
- kind: 'sidebar',
81
- id: SIDEBAR_ID,
82
- parentId: ZONE_ID,
83
- meta: { title: label },
84
- placement: { size: { w: width ?? defaultWidth } },
85
- hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
86
- }),
87
- );
88
- store.registerNode(
89
- createNode({
90
- kind: 'content',
91
- id: CONTENT_ID,
92
- parentId: ZONE_ID,
93
- meta: { title: 'Content' },
94
- hints: { minSize: { w: contentMinWidth, h: 0 } },
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
+ },
95
88
  }),
96
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
+ }
97
109
  store.showNode(SIDEBAR_ID);
98
110
  store.showNode(CONTENT_ID);
99
111
  storeRef.current = store;
@@ -127,6 +139,10 @@ export function Split({
127
139
  useEffect(
128
140
  () =>
129
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
+ }
130
146
  const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
131
147
  if (typeof next !== 'number' || next === widthRef.current) return;
132
148
  widthRef.current = next;
@@ -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> = {
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { Specimen } from './Specimen';
3
+
4
+ const meta: Meta<typeof Specimen> = {
5
+ title: 'labkit/Specimen',
6
+ component: Specimen,
7
+ parameters: { layout: 'fullscreen' },
8
+ };
9
+ export default meta;
10
+
11
+ type Story = StoryObj<typeof Specimen>;
12
+
13
+ export const Everything: Story = {};
@@ -0,0 +1,13 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { SPECIMEN_SECTIONS, Specimen } from './Specimen';
4
+
5
+ describe('<Specimen>', () => {
6
+ it('renders every section as a named region', () => {
7
+ render(<Specimen />);
8
+ expect(SPECIMEN_SECTIONS.length).toBeGreaterThan(0);
9
+ for (const title of SPECIMEN_SECTIONS) {
10
+ expect(screen.getByRole('region', { name: title })).toBeInTheDocument();
11
+ }
12
+ });
13
+ });