@weasel-js/labkit 1.4.0-pre.0 → 1.4.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 (103) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/{CanvasStackContext-DtAn_3O-.d.ts → CanvasStackContext-D6M0o8cQ.d.ts} +1 -1
  3. package/dist/_dts/{frac-Cp3NivlC.d.ts → frac-QS7XzvpG.d.ts} +11 -4
  4. package/dist/_dts/{index-BDVzvRzQ.d.ts → index-B2X21G1c.d.ts} +8 -2
  5. package/dist/_dts/{types-D6s4b7if.d.ts → types-C1Zw7s5P.d.ts} +1 -0
  6. package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
  7. package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
  8. package/dist/canvas/index.d.ts +75 -9
  9. package/dist/canvas/index.js +2 -2
  10. package/dist/chrome/index.d.ts +4 -4
  11. package/dist/chrome/index.js +5 -5
  12. package/dist/chunk-2YILQ7Y5.js +33 -0
  13. package/dist/chunk-2YILQ7Y5.js.map +1 -0
  14. package/dist/{chunk-3P5TTTA7.js → chunk-4AMN4MMB.js} +5 -5
  15. package/dist/{chunk-3P5TTTA7.js.map → chunk-4AMN4MMB.js.map} +1 -1
  16. package/dist/{chunk-ROQDRXKW.js → chunk-7VW3S44V.js} +621 -51
  17. package/dist/chunk-7VW3S44V.js.map +1 -0
  18. package/dist/{chunk-4EY67BKJ.js → chunk-B5VWJBWM.js} +12 -6
  19. package/dist/chunk-B5VWJBWM.js.map +1 -0
  20. package/dist/{chunk-HEORBLVT.js → chunk-L5WBGUVR.js} +1664 -1628
  21. package/dist/chunk-L5WBGUVR.js.map +1 -0
  22. package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
  23. package/dist/chunk-LJSIUFYD.js.map +1 -0
  24. package/dist/{chunk-6JMU56CU.js → chunk-QR5V3AFG.js} +6 -4
  25. package/dist/chunk-QR5V3AFG.js.map +1 -0
  26. package/dist/{chunk-LITE5YFB.js → chunk-UD7TEHZT.js} +15 -15
  27. package/dist/chunk-UD7TEHZT.js.map +1 -0
  28. package/dist/{chunk-3ZELRRBV.js → chunk-VHFQKAUL.js} +86 -6
  29. package/dist/chunk-VHFQKAUL.js.map +1 -0
  30. package/dist/{chunk-YQJNT4XQ.js → chunk-W3KSUXXR.js} +16 -13
  31. package/dist/chunk-W3KSUXXR.js.map +1 -0
  32. package/dist/{chunk-JDDIZSLL.js → chunk-XCBCZEBO.js} +3 -3
  33. package/dist/{chunk-JDDIZSLL.js.map → chunk-XCBCZEBO.js.map} +1 -1
  34. package/dist/{chunk-EQIB2PGC.js → chunk-Y2HK44TG.js} +3 -3
  35. package/dist/{chunk-EQIB2PGC.js.map → chunk-Y2HK44TG.js.map} +1 -1
  36. package/dist/{chunk-5TCEVMPI.js → chunk-YQGQUBKA.js} +3 -3
  37. package/dist/{chunk-5TCEVMPI.js.map → chunk-YQGQUBKA.js.map} +1 -1
  38. package/dist/controls/index.d.ts +2 -2
  39. package/dist/controls/index.js +3 -3
  40. package/dist/dragdrop/index.d.ts +3 -3
  41. package/dist/dragdrop/index.js +2 -2
  42. package/dist/index.d.ts +57 -59
  43. package/dist/index.js +335 -240
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +4 -4
  46. package/dist/layers/index.js +3 -3
  47. package/dist/loupe/index.d.ts +5 -5
  48. package/dist/loupe/index.js +2 -2
  49. package/dist/passthrough/weasel-canvas.js +1 -1
  50. package/dist/passthrough/weasel-ui.d.ts +336 -15
  51. package/dist/passthrough/weasel-ui.js +2 -2
  52. package/dist/primitives/index.js +4 -4
  53. package/dist/state/index.d.ts +3 -3
  54. package/dist/state/index.js +4 -2
  55. package/dist/state/index.js.map +1 -1
  56. package/dist/styles.css +64 -5
  57. package/dist/surface/index.d.ts +17 -2
  58. package/dist/surface/index.js +3 -2
  59. package/dist/ui/layers/index.js +2 -2
  60. package/dist/undo/index.d.ts +3 -3
  61. package/package.json +8 -8
  62. package/src/annotations/AnnotationOverlay.tsx +10 -6
  63. package/src/annotations/Annotations.less +6 -0
  64. package/src/annotations/Annotations.overlay.test.tsx +113 -0
  65. package/src/annotations/MarkList.tsx +3 -2
  66. package/src/annotations/store.test.ts +72 -0
  67. package/src/annotations/store.ts +30 -0
  68. package/src/annotations/types.ts +9 -2
  69. package/src/canvas/index.ts +11 -0
  70. package/src/config/builder.test.ts +5 -0
  71. package/src/config/builder.ts +6 -0
  72. package/src/config/types.ts +1 -0
  73. package/src/controls/ControlPanel.test.tsx +23 -0
  74. package/src/controls/ControlPanel.tsx +3 -0
  75. package/src/index.test.ts +1 -0
  76. package/src/index.ts +13 -9
  77. package/src/lab/Lab.surface.test.tsx +8 -3
  78. package/src/lab/LabHeader.test.tsx +12 -0
  79. package/src/lab/LabHeader.tsx +16 -6
  80. package/src/passthrough/weasel-ui.test.ts +9 -0
  81. package/src/passthrough/weasel-ui.ts +4 -0
  82. package/src/primitives/ZoomControl.tsx +2 -0
  83. package/src/state/store.ts +10 -0
  84. package/src/state/types.ts +3 -0
  85. package/src/surface/AGENTS.md +11 -1
  86. package/src/surface/index.ts +7 -1
  87. package/src/surface/useSurfaceTile.ts +20 -2
  88. package/src/surface/useTiledSurface.ts +4 -1
  89. package/src/theme/Interstellar.stories.tsx +4 -3
  90. package/src/theme/base.less +39 -1
  91. package/src/trial/Trial.less +27 -5
  92. package/src/trial/TrialBody.test.tsx +67 -0
  93. package/src/trial/TrialBody.tsx +155 -0
  94. package/src/trial/TrialChrome.tsx +18 -10
  95. package/src/trial/index.ts +2 -0
  96. package/dist/chunk-3ZELRRBV.js.map +0 -1
  97. package/dist/chunk-4EY67BKJ.js.map +0 -1
  98. package/dist/chunk-6JMU56CU.js.map +0 -1
  99. package/dist/chunk-HEORBLVT.js.map +0 -1
  100. package/dist/chunk-LITE5YFB.js.map +0 -1
  101. package/dist/chunk-ROQDRXKW.js.map +0 -1
  102. package/dist/chunk-XJ6N32QP.js.map +0 -1
  103. package/dist/chunk-YQJNT4XQ.js.map +0 -1
@@ -69,6 +69,10 @@ export {
69
69
  formatShortcutParts,
70
70
  type GradientTrackOpts,
71
71
  type GridSettings,
72
+ ICON_PATHS,
73
+ Icon,
74
+ type IconName,
75
+ type IconProps,
72
76
  Input,
73
77
  type InputProps,
74
78
  type InterpolationMode,
@@ -45,6 +45,7 @@ export function ZoomControl({
45
45
  <Slider
46
46
  className="lk-zoom__slider"
47
47
  ariaLabel="Zoom"
48
+ density="slim"
48
49
  min={toTrack(min)}
49
50
  max={toTrack(max)}
50
51
  // Detents at the octaves plus actual size, so 25/50/100/200/400% are
@@ -65,6 +66,7 @@ export function ZoomControl({
65
66
  maxValue={max}
66
67
  step={0.01}
67
68
  hideSteppers
69
+ ghost
68
70
  formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
69
71
  onChange={(next) => {
70
72
  if (!Number.isFinite(next)) return;
@@ -34,6 +34,7 @@ export interface LabStoreActions {
34
34
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
35
35
  updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
36
36
  updateTrialView: (id: string, view: unknown) => void;
37
+ updateTrialSidebarWidth: (id: string, width: number) => void;
37
38
  updateTrialAnnotations: (id: string, doc: unknown) => void;
38
39
  updateTrialUndoStack: (
39
40
  id: string,
@@ -136,6 +137,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
136
137
  scheduleFlush();
137
138
  },
138
139
 
140
+ updateTrialSidebarWidth: (id, width) => {
141
+ set((s) => ({
142
+ trials: s.trials.map((w) =>
143
+ w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
144
+ ),
145
+ }));
146
+ scheduleFlush();
147
+ },
148
+
139
149
  updateTrialAnnotations: (id, doc) => {
140
150
  set((s) => ({
141
151
  trials: s.trials.map((w) =>
@@ -18,6 +18,9 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
18
18
  view: TV;
19
19
  /** This trial's own tool slot. Undefined means it reads the lab's. */
20
20
  activeToolId?: string | null;
21
+ /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
22
+ * until someone moves the seam. */
23
+ sidebarWidth?: number;
21
24
  /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
22
25
  * wrote them. Opaque here, and absent for a trial whose instrument declares
23
26
  * no `annotations` — or declares its own `storage`. Not in `state`, which
@@ -17,7 +17,7 @@ than a few 3D tiles have to share one surface.
17
17
  | `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
18
18
  | `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
19
19
  | `SurfaceContext.ts` | Carries the handle down |
20
- | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
20
+ | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
21
21
 
22
22
  ## Shape of a consumer
23
23
 
@@ -40,6 +40,16 @@ const surface = useTiledSurface({
40
40
  draw has to know where it is drawing relative to a surface that may have resized
41
41
  under it.
42
42
 
43
+ ## A tile id is scoped to its trial
44
+
45
+ The tile namespace is one lab-wide map, but an instrument names its regions once
46
+ and every trial of it declares those same names. `useSurfaceTile` registers under
47
+ `useTileId(id)` — `<trial>/<id>` inside a trial — and a frame's `rects` are keyed
48
+ the same way, so a host looking a tile up calls `useTileId` for the key. Unscoped,
49
+ the second trial to mount takes the first one's rect *and* its painter, and the
50
+ first is never told it moved again: it keeps the box it was measured at while it
51
+ was the only trial open.
52
+
43
53
  ## The unit is a rect, not a trial
44
54
 
45
55
  `useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
@@ -2,7 +2,13 @@ export { composeRects, rectsEqual } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
4
  export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
5
- export { useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
5
+ export {
6
+ useSurface,
7
+ useSurfaceCanvas,
8
+ useSurfaceOptional,
9
+ useSurfaceTile,
10
+ useTileId,
11
+ } from './useSurfaceTile';
6
12
  export type {
7
13
  SurfaceFrame,
8
14
  SurfaceHandle,
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useContext } from 'react';
2
+ import { TrialIdContext } from '../state/context';
2
3
  import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
3
4
  import type { SurfaceHandle } from './useTiledSurface';
4
5
 
@@ -20,19 +21,36 @@ export function useSurface(): SurfaceHandle {
20
21
  return surface;
21
22
  }
22
23
 
24
+ /**
25
+ * The key `id` is registered under, and so the key it comes back on in a
26
+ * frame's `rects`: `<trial>/<id>` inside a trial, and `id` alone outside one.
27
+ *
28
+ * A surface's tile namespace is flat and shared by the whole lab, but an
29
+ * instrument names its regions once and every trial of it declares those same
30
+ * names. Unscoped, the second trial to mount takes the first one's rect and its
31
+ * painter, and the first is never told it moved again.
32
+ */
33
+ export function useTileId(id: string): string {
34
+ const trial = useContext(TrialIdContext);
35
+ return trial ? `${trial}/${id}` : id;
36
+ }
37
+
23
38
  /**
24
39
  * A ref callback that publishes this element's rect to the surface under `id`.
25
40
  *
26
41
  * Attach it to whichever element the surface should draw into — that is not
27
42
  * necessarily the trial's own element, since a trial may hold a drawn pane beside
28
43
  * an undrawn one, or none at all.
44
+ *
45
+ * The frame reports it under `useTileId(id)`, not under `id`.
29
46
  */
30
47
  export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
31
48
  const surface = useSurfaceOptional();
49
+ const tileId = useTileId(id);
32
50
  return useCallback(
33
51
  (el: HTMLElement | null) => {
34
- surface?.registerTile(id, el);
52
+ surface?.registerTile(tileId, el);
35
53
  },
36
- [surface, id],
54
+ [surface, tileId],
37
55
  );
38
56
  }
@@ -5,7 +5,10 @@ import type { Box, Rect } from './rect';
5
5
 
6
6
  /** What a surface owner is handed once per animation frame. `rects` carries every
7
7
  * tile, not only the dirty ones: a scissored draw has to know where it is drawing
8
- * relative to a surface that may have resized under it. */
8
+ * relative to a surface that may have resized under it.
9
+ *
10
+ * Its keys are what `useTileId` returns — `<trial>/<id>` for a tile registered
11
+ * inside a trial — not the bare id the tile was named with. */
9
12
  export interface SurfaceFrame {
10
13
  dirty: ReadonlySet<string>;
11
14
  rects: ReadonlyMap<string, Rect>;
@@ -91,11 +91,12 @@ function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: strin
91
91
  function NebulaPreview() {
92
92
  return (
93
93
  <div>
94
- <h3 className="lk-theme-doc__heading">Cosmic backdrop (`--wzl-backdrop`)</h3>
94
+ <h3 className="lk-theme-doc__heading">Cosmic backdrop</h3>
95
95
  <div className="lk-theme-doc__nebula" style={{ background: 'var(--wzl-backdrop)' }} />
96
96
  <p className="lk-theme-doc__note">
97
- Default: four radial gradients (purple, pink, blue, then a dark void base). Override per-Lab
98
- by passing <code>nebula={'{[colors]}'}</code> to the <code>&lt;Lab&gt;</code> component.
97
+ <code>--wzl-backdrop</code> — four radial gradients (purple, pink, blue) over a dark void
98
+ base. Override per-Lab by passing <code>nebula={'{[colors]}'}</code> to the{' '}
99
+ <code>&lt;Lab&gt;</code> component.
99
100
  </p>
100
101
  </div>
101
102
  );
@@ -66,8 +66,46 @@
66
66
  }
67
67
  }
68
68
 
69
+ // Mirrors packages/ui/src/components/range.module.css so a consumer's bare
70
+ // range matches the kit's. A weasel-ui component's own module class outranks
71
+ // this, which is what keeps InlineRange's gradient track intact.
69
72
  :where(input[type="range"]) {
70
- accent-color: var(--wzl-accent);
73
+ appearance: none;
74
+ -webkit-appearance: none;
75
+ height: 12px;
76
+ background: transparent;
77
+ cursor: pointer;
78
+
79
+ &::-webkit-slider-runnable-track {
80
+ height: var(--wzl-slider-track-h);
81
+ border-radius: var(--wzl-radius-sm);
82
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
83
+ }
84
+
85
+ &::-moz-range-track {
86
+ height: var(--wzl-slider-track-h);
87
+ border-radius: var(--wzl-radius-sm);
88
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
89
+ }
90
+
91
+ &::-webkit-slider-thumb {
92
+ appearance: none;
93
+ -webkit-appearance: none;
94
+ width: var(--wzl-slider-thumb-size);
95
+ height: var(--wzl-slider-thumb-size);
96
+ border: none;
97
+ border-radius: 50%;
98
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
99
+ margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
100
+ }
101
+
102
+ &::-moz-range-thumb {
103
+ width: var(--wzl-slider-thumb-size);
104
+ height: var(--wzl-slider-thumb-size);
105
+ border: none;
106
+ border-radius: 50%;
107
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
108
+ }
71
109
  }
72
110
  }
73
111
 
@@ -20,13 +20,19 @@
20
20
  overflow: hidden;
21
21
  }
22
22
 
23
+ // The sidebar and the content well are a windease strip, so their extents
24
+ // come from the strategy and this pane only has to fill the rect it was
25
+ // given. `.windease-zone` supplies the containing block.
26
+ &__panes {
27
+ flex: 1;
28
+ min-width: 0;
29
+ }
30
+
23
31
  &__sidebar {
24
- flex-shrink: 0;
25
- // Stated, not content-derived: an auto-width sidebar is as wide as its
26
- // widest label, so one verbose config key sets the width of the lab.
27
- width: var(--lk-trial-sidebar-w, 20rem);
28
32
  display: flex;
29
33
  flex-direction: column;
34
+ width: 100%;
35
+ height: 100%;
30
36
  min-height: 0;
31
37
  // Sections floor at their content height, so without this the last ones
32
38
  // are cut rather than reachable.
@@ -40,7 +46,8 @@
40
46
  &__content {
41
47
  // The viewport region anchors against this well.
42
48
  position: relative;
43
- flex: 1;
49
+ width: 100%;
50
+ height: 100%;
44
51
  min-width: 0;
45
52
  min-height: 0;
46
53
  display: flex;
@@ -89,6 +96,21 @@
89
96
  flex-shrink: 0;
90
97
  }
91
98
 
99
+ // The strip's own seam. windease positions and sizes it; this is only what
100
+ // it looks like.
101
+ &__panes [data-affordance-kind='resize-x'] {
102
+ background: var(--wzl-border);
103
+ }
104
+
105
+ &__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
106
+ &__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
107
+ background: var(--wzl-accent);
108
+ }
109
+
110
+ &__panes .windease-affordance-hit:focus-visible {
111
+ outline: none;
112
+ }
113
+
92
114
  &--unknown {
93
115
  display: flex;
94
116
  align-items: center;
@@ -0,0 +1,67 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { TrialBody } from './TrialBody';
4
+
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
7
+ function renderBody(props: Partial<Parameters<typeof TrialBody>[0]> = {}) {
8
+ return render(
9
+ <TrialBody viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
10
+ <p>instrument output</p>
11
+ </TrialBody>,
12
+ );
13
+ }
14
+
15
+ describe('TrialBody', () => {
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
+ });
@@ -0,0 +1,155 @@
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-trial-body');
6
+ const SIDEBAR_ID = asNodeId('lk-trial-sidebar');
7
+ const CONTENT_ID = asNodeId('lk-trial-content');
8
+ const STRATEGIES = { strip: stripStrategy as never };
9
+
10
+ /** Props for `<TrialBody>`. */
11
+ export interface TrialBodyProps {
12
+ /** The sidebar region's sections. */
13
+ sidebar: ReactNode;
14
+ /** The instrument's own output. */
15
+ children: ReactNode;
16
+ /** Extra class on the content pane — `Trial` marks a canvas instrument's
17
+ * well flush. */
18
+ contentClassName?: string;
19
+ /** The sidebar's current extent in pixels. Omit for an unpersisted lab: the
20
+ * seam still works, its result just does not outlive the mount. */
21
+ width?: number;
22
+ /** Where the sidebar starts before anyone drags it. */
23
+ defaultWidth?: number;
24
+ minWidth?: number;
25
+ maxWidth?: number;
26
+ /** How little the instrument may be squeezed to. Reported as the seam's
27
+ * reachable maximum, so the drag stops there rather than clamping silently. */
28
+ contentMinWidth?: number;
29
+ /** Fires through the drag, not on release: the pane is already there. */
30
+ onWidthChange?: (width: number) => void;
31
+ /**
32
+ * Fixed extent. Omit in an app — the strip measures its own box. Supply it
33
+ * where nothing measures, notably jsdom, or both panes lay out at zero.
34
+ */
35
+ viewport?: { w: number; h: number };
36
+ }
37
+
38
+ /**
39
+ * A trial's sidebar and content as a two-pane strip with a draggable seam.
40
+ *
41
+ * The seam is `stripStrategy`'s own resize affordance, so it arrives as a
42
+ * `role="separator"` carrying the reachable range, operable by pointer and by
43
+ * arrows / Home / End, and clamped against the content pane's floor rather
44
+ * than against the sidebar's alone.
45
+ */
46
+ export function TrialBody({
47
+ sidebar,
48
+ children,
49
+ contentClassName,
50
+ width,
51
+ defaultWidth = 320,
52
+ minWidth = 140,
53
+ maxWidth = 720,
54
+ contentMinWidth = 160,
55
+ onWidthChange,
56
+ viewport,
57
+ }: TrialBodyProps) {
58
+ // The cross-axis half of every size hint below is inert: a horizontal strip
59
+ // stretches its panes to the container's height and never reads `h`.
60
+ const storeRef = useRef<Store | null>(null);
61
+ if (storeRef.current === null) {
62
+ const store = new Store();
63
+ store.registerNode(
64
+ createNode({
65
+ kind: 'zone',
66
+ id: ZONE_ID,
67
+ container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
68
+ }),
69
+ );
70
+ store.registerNode(
71
+ createNode({
72
+ kind: 'sidebar',
73
+ id: SIDEBAR_ID,
74
+ parentId: ZONE_ID,
75
+ meta: { title: 'Sidebar' },
76
+ placement: { size: { w: width ?? defaultWidth } },
77
+ hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
78
+ }),
79
+ );
80
+ store.registerNode(
81
+ createNode({
82
+ kind: 'content',
83
+ id: CONTENT_ID,
84
+ parentId: ZONE_ID,
85
+ meta: { title: 'Content' },
86
+ hints: { minSize: { w: contentMinWidth, h: 0 } },
87
+ }),
88
+ );
89
+ store.showNode(SIDEBAR_ID);
90
+ store.showNode(CONTENT_ID);
91
+ storeRef.current = store;
92
+ }
93
+ const store = storeRef.current;
94
+
95
+ // What the store last held, so the placement event can tell a drag from the
96
+ // echo of our own write and a controlled `width` does not fight the seam.
97
+ const widthRef = useRef(width ?? defaultWidth);
98
+
99
+ useEffect(() => {
100
+ store.setHints(SIDEBAR_ID, {
101
+ minSize: { w: minWidth, h: 0 },
102
+ maxSize: { w: maxWidth, h: 0 },
103
+ });
104
+ store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
105
+ }, [store, minWidth, maxWidth, contentMinWidth]);
106
+
107
+ useEffect(() => {
108
+ if (width === undefined || width === widthRef.current) return;
109
+ widthRef.current = width;
110
+ store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
111
+ }, [store, width]);
112
+
113
+ const onWidthChangeRef = useRef(onWidthChange);
114
+ onWidthChangeRef.current = onWidthChange;
115
+ useEffect(
116
+ () =>
117
+ store.events.on('node.placementChanged', () => {
118
+ const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
119
+ if (typeof next !== 'number' || next === widthRef.current) return;
120
+ widthRef.current = next;
121
+ onWidthChangeRef.current?.(next);
122
+ }),
123
+ [store],
124
+ );
125
+
126
+ const chrome = useMemo<ChromeMap>(
127
+ () => ({
128
+ sidebar: () => <div className="lk-trial__sidebar">{sidebar}</div>,
129
+ content: () => (
130
+ <div
131
+ className={
132
+ contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
133
+ }
134
+ >
135
+ {children}
136
+ </div>
137
+ ),
138
+ }),
139
+ [sidebar, children, contentClassName],
140
+ );
141
+
142
+ return (
143
+ <Provider store={store}>
144
+ <StrategyRegistryProvider strategies={STRATEGIES}>
145
+ <Container
146
+ parentId={ZONE_ID}
147
+ chrome={chrome}
148
+ className="lk-trial__panes windease-zone"
149
+ affordances
150
+ viewport={viewport}
151
+ />
152
+ </StrategyRegistryProvider>
153
+ </Provider>
154
+ );
155
+ }
@@ -17,6 +17,7 @@ import { JobProgress } from '../primitives/JobProgress';
17
17
  import { LabStoreContext } from '../state/context';
18
18
  import type { TrialRecord } from '../state/types';
19
19
  import { as2DView } from '../state/view';
20
+ import { TrialBody } from './TrialBody';
20
21
  import { TrialTitleBar } from './TrialTitleBar';
21
22
  import { UndockedSections } from './UndockedSections';
22
23
 
@@ -83,6 +84,7 @@ export function TrialChrome({
83
84
  const storeCtx = useContext(LabStoreContext);
84
85
  if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
85
86
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
87
+ const updateTrialSidebarWidth = useStore(storeCtx.store, (s) => s.updateTrialSidebarWidth);
86
88
  const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
87
89
  const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
88
90
  const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
@@ -213,18 +215,24 @@ export function TrialChrome({
213
215
  </div>
214
216
  <div className="lk-trial__body">
215
217
  <PaletteRegion contributions={inRegion('palette')} ctx={ctx} />
216
- <div className="lk-trial__sidebar">
217
- <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
218
- <UndockedSections
219
- trialId={trialId}
220
- sections={inRegion('sidebar').filter((c) => undockedIds.includes(c.id))}
221
- onDock={ctx.dockPanel}
222
- />
223
- </div>
224
- <div className={`lk-trial__content${instrument.canvas ? ' lk-trial__content--flush' : ''}`}>
218
+ <TrialBody
219
+ width={record.sidebarWidth}
220
+ onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
221
+ contentClassName={instrument.canvas ? 'lk-trial__content--flush' : undefined}
222
+ sidebar={
223
+ <>
224
+ <SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
225
+ <UndockedSections
226
+ trialId={trialId}
227
+ sections={inRegion('sidebar').filter((c) => undockedIds.includes(c.id))}
228
+ onDock={ctx.dockPanel}
229
+ />
230
+ </>
231
+ }
232
+ >
225
233
  {children}
226
234
  <ViewportRegion contributions={inRegion('viewport')} ctx={ctx} />
227
- </div>
235
+ </TrialBody>
228
236
  </div>
229
237
  <div className="lk-trial__status">
230
238
  {job ? <JobProgress job={job} /> : null}
@@ -1,5 +1,7 @@
1
1
  export type { TrialProps } from './Trial';
2
2
  export { Trial } from './Trial';
3
+ export type { TrialBodyProps } from './TrialBody';
4
+ export { TrialBody } from './TrialBody';
3
5
  export type { TrialChromeProps, UndoBindings } from './TrialChrome';
4
6
  export { TrialChrome } from './TrialChrome';
5
7
  export {
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/canvas/useLayerScheduler.ts","../src/canvas/usePanZoom.ts","../src/canvas/CanvasStack.tsx"],"names":["useRef","useEffect"],"mappings":"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","file":"chunk-3ZELRRBV.js","sourcesContent":["import { useVisibleRaf } from '@weasel-js/core';\nimport { type RefObject, useEffect, useRef } from 'react';\nimport type { ViewTransform } from '../instrument/types';\nimport { DEFAULT_FRAME, type WorldFrame } from './worldSpec';\n\n/** One layer of a canvas stack: its id, whether it is currently shown, and how\n * it paints itself. */\nexport interface CanvasLayerDescriptor {\n id: string;\n visible: boolean;\n render: (ctx: CanvasRenderingContext2D, view: ViewTransform, frame: WorldFrame) => void;\n}\n\ninterface SchedulerOptions {\n layers: CanvasLayerDescriptor[];\n view: ViewTransform;\n /** The instrument's coordinate system, resolved against `size`. */\n frame?: WorldFrame;\n canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;\n size: { width: number; height: number; dpr: number };\n /** The element the stack occupies. Given one, the scheduler also stops while\n * the stack sits outside the viewport. */\n host?: RefObject<Element | null>;\n}\n\nexport function useLayerScheduler({\n layers,\n view,\n frame = DEFAULT_FRAME,\n canvasRefs,\n size,\n host,\n}: SchedulerOptions): void {\n const dirty = useRef<Set<string>>(new Set());\n const lastRenderRef = useRef<Map<string, CanvasLayerDescriptor['render']>>(new Map());\n\n useEffect(() => {\n for (const layer of layers) {\n const prev = lastRenderRef.current.get(layer.id);\n if (prev !== layer.render) dirty.current.add(layer.id);\n lastRenderRef.current.set(layer.id, layer.render);\n }\n for (const id of [...lastRenderRef.current.keys()]) {\n if (!layers.find((l) => l.id === id)) lastRenderRef.current.delete(id);\n }\n }, [layers]);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: re-mark dirty when view or size changes\n useEffect(() => {\n for (const layer of layers) dirty.current.add(layer.id);\n }, [view, frame, size, layers]);\n\n // Painting only what is dirty is not the same as doing nothing: a hidden tab\n // still commits React updates, and the effect above marks every layer dirty\n // on any view or size change. The gate is what actually stops the work.\n const frameLoop = useVisibleRaf(\n () => {\n if (dirty.current.size === 0) return;\n const map = canvasRefs.current;\n if (!map) {\n dirty.current.clear();\n return;\n }\n for (const layer of layers) {\n if (!dirty.current.has(layer.id)) continue;\n if (!layer.visible) continue;\n const canvas = map.get(layer.id);\n if (!canvas) continue;\n const ctx = canvas.getContext('2d');\n if (!ctx) continue;\n ctx.save();\n ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);\n ctx.clearRect(0, 0, size.width, size.height);\n layer.render(ctx, view, frame);\n ctx.restore();\n }\n dirty.current.clear();\n },\n { target: host },\n );\n\n useEffect(() => {\n if (dirty.current.size > 0) frameLoop.request();\n });\n}\n","import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';\nimport type { ViewTransform } from '../instrument/types';\nimport { zoomAt } from './camera';\nimport { DEFAULT_FRAME, type WorldFrame } from './worldSpec';\n\nexport interface UsePanZoomOptions {\n view: ViewTransform;\n onViewChange: (v: ViewTransform) => void;\n /** The opening `view.zoom` always stays reachable, widening these past\n * whatever is passed here if it would otherwise exclude it. */\n minZoom?: number;\n maxZoom?: number;\n /** The instrument's coordinate system, resolved against the viewport. The\n * wheel anchors in this frame; omitting it anchors at the element's\n * top-left, which is what labkit did before a frame could be declared. */\n frame?: WorldFrame;\n}\n\nexport interface PanZoomHandlers {\n onWheel: (e: WheelEvent<HTMLElement>) => void;\n onPointerDown: (e: PointerEvent<HTMLElement>) => void;\n onPointerMove: (e: PointerEvent<HTMLElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLElement>) => void;\n isDragging: () => boolean;\n}\n\ninterface DragState {\n pointerId: number;\n startScreenX: number;\n startScreenY: number;\n startPan: { x: number; y: number };\n moved: boolean;\n}\n\nconst DRAG_THRESHOLD = 3;\n\nfunction isPositiveFinite(n: number): boolean {\n return Number.isFinite(n) && n > 0;\n}\n\nexport function usePanZoom({\n view,\n onViewChange,\n minZoom = 0.1,\n maxZoom = 32,\n frame = DEFAULT_FRAME,\n}: UsePanZoomOptions): PanZoomHandlers {\n const dragRef = useRef<DragState | null>(null);\n const viewRef = useRef(view);\n viewRef.current = view;\n // Captured once, from the view the canvas opened at — not the current view,\n // which would let a prior zoom-out shrink the range and trap the opening\n // view unreachable behind it.\n const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);\n\n const onWheel = useCallback(\n (e: WheelEvent<HTMLElement>) => {\n e.preventDefault();\n const rect = e.currentTarget.getBoundingClientRect();\n const initialZoom = initialZoomRef.current;\n onViewChange(\n zoomAt(\n viewRef.current,\n Math.exp(-e.deltaY * 0.001),\n { x: e.clientX - rect.left, y: e.clientY - rect.top },\n {\n frame,\n min: initialZoom == null ? minZoom : Math.min(minZoom, initialZoom),\n max: initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom),\n },\n ),\n );\n },\n [onViewChange, minZoom, maxZoom, frame],\n );\n\n const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {\n if (e.button !== 0) return;\n e.currentTarget.setPointerCapture(e.pointerId);\n dragRef.current = {\n pointerId: e.pointerId,\n startScreenX: e.clientX,\n startScreenY: e.clientY,\n startPan: { ...viewRef.current.pan },\n moved: false,\n };\n }, []);\n\n const onPointerMove = useCallback(\n (e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n const dx = e.clientX - drag.startScreenX;\n const dy = e.clientY - drag.startScreenY;\n if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;\n drag.moved = true;\n onViewChange({\n zoom: viewRef.current.zoom,\n pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },\n });\n },\n [onViewChange],\n );\n\n const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n e.currentTarget.releasePointerCapture(e.pointerId);\n }\n dragRef.current = null;\n }, []);\n\n const isDragging = useCallback(() => dragRef.current?.moved === true, []);\n\n return { onWheel, onPointerDown, onPointerMove, onPointerUp, isDragging };\n}\n","import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';\nimport type { Point, ViewTransform } from '../instrument/types';\nimport { CanvasStackContext } from './CanvasStackContext';\nimport { screenToWorld } from './canvasCoords';\nimport { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';\nimport { usePanZoom } from './usePanZoom';\nimport { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';\n\n/** Props for `<CanvasStack>`. */\nexport interface CanvasStackProps {\n layers: CanvasLayerDescriptor[];\n view: ViewTransform;\n onViewChange: (v: ViewTransform) => void;\n /** The instrument's coordinate system. Omitted, world (0,0) sits at the\n * element's top-left with y running down. */\n worldSpec?: WorldSpec;\n minZoom?: number;\n maxZoom?: number;\n width?: number | string;\n height?: number | string;\n className?: string;\n /** Fired whenever the stack is measured, so a consumer can place a view that\n * only makes sense in terms of the viewport. */\n onResize?: (size: ViewportSize) => void;\n onHitTest?: (worldPos: Point) => void;\n children?: ReactNode;\n}\n\n/** Stacks one `<canvas>` per layer and drives them from a shared view, so a\n * layer that changes rarely is not redrawn with one that changes every frame.\n * Handles sizing, device pixel ratio, and pan/zoom. */\nexport function CanvasStack({\n layers,\n view,\n onViewChange,\n worldSpec,\n minZoom,\n maxZoom,\n width = '100%',\n height = '100%',\n className,\n onResize,\n onHitTest,\n children,\n}: CanvasStackProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());\n const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });\n\n const setCanvasRef = (id: string, el: HTMLCanvasElement | null): void => {\n if (el) canvasMap.current.set(id, el);\n else canvasMap.current.delete(id);\n };\n\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n const update = () => {\n const rect = el.getBoundingClientRect();\n const dpr = window.devicePixelRatio ?? 1;\n setSize((prev) => {\n if (prev.width === rect.width && prev.height === rect.height && prev.dpr === dpr) {\n return prev;\n }\n return { width: rect.width, height: rect.height, dpr };\n });\n };\n update();\n if (typeof ResizeObserver === 'undefined') return;\n const ro = new ResizeObserver(update);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const onResizeRef = useRef(onResize);\n onResizeRef.current = onResize;\n useEffect(() => {\n if (size.width === 0 && size.height === 0) return;\n onResizeRef.current?.({ width: size.width, height: size.height });\n }, [size.width, size.height]);\n\n const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);\n\n const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });\n useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });\n\n const ctxValue = useMemo(\n () => ({\n view,\n frame,\n surface: { element: containerRef, size, canvases: canvasMap, layers },\n }),\n [view, frame, size, layers],\n );\n\n const containerStyle: CSSProperties = { width, height };\n const canvasPx = {\n width: Math.max(0, Math.round(size.width * size.dpr)),\n height: Math.max(0, Math.round(size.height * size.dpr)),\n };\n const canvasCss: CSSProperties = {\n width: size.width,\n height: size.height,\n };\n\n const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n const wasDragging = handlers.isDragging();\n handlers.onPointerUp(e);\n if (!wasDragging && onHitTest && containerRef.current) {\n const rect = containerRef.current.getBoundingClientRect();\n const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };\n onHitTest(screenToWorld(screen, view, frame));\n }\n };\n\n return (\n <CanvasStackContext.Provider value={ctxValue}>\n <div\n ref={containerRef}\n className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}\n style={containerStyle}\n onWheel={handlers.onWheel}\n onPointerDown={handlers.onPointerDown}\n onPointerMove={handlers.onPointerMove}\n onPointerUp={handlePointerUp}\n >\n {layers.map((layer) => (\n <canvas\n key={layer.id}\n ref={(el) => setCanvasRef(layer.id, el)}\n className=\"lk-canvas-stack__canvas\"\n width={canvasPx.width}\n height={canvasPx.height}\n style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}\n />\n ))}\n <div className=\"lk-canvas-stack__overlay\">{children}</div>\n </div>\n </CanvasStackContext.Provider>\n );\n}\n"]}