@weasel-js/labkit 1.4.0-pre.1 → 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 (95) hide show
  1. package/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-D6M0o8cQ.d.ts} +1 -1
  2. package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-QS7XzvpG.d.ts} +2 -2
  3. package/dist/_dts/{index-BDVzvRzQ.d.ts → index-B2X21G1c.d.ts} +8 -2
  4. package/dist/_dts/{types-D6s4b7if.d.ts → types-C1Zw7s5P.d.ts} +1 -0
  5. package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
  6. package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
  7. package/dist/canvas/index.d.ts +6 -6
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +4 -4
  10. package/dist/chrome/index.js +5 -5
  11. package/dist/chunk-2YILQ7Y5.js +33 -0
  12. package/dist/chunk-2YILQ7Y5.js.map +1 -0
  13. package/dist/{chunk-QB5SM4BM.js → chunk-4AMN4MMB.js} +5 -5
  14. package/dist/{chunk-QB5SM4BM.js.map → chunk-4AMN4MMB.js.map} +1 -1
  15. package/dist/{chunk-L5QJOOLV.js → chunk-7VW3S44V.js} +512 -56
  16. package/dist/chunk-7VW3S44V.js.map +1 -0
  17. package/dist/{chunk-ESIQNQ6K.js → chunk-B5VWJBWM.js} +12 -6
  18. package/dist/chunk-B5VWJBWM.js.map +1 -0
  19. package/dist/{chunk-RBGKL7NF.js → chunk-L5WBGUVR.js} +1664 -1628
  20. package/dist/chunk-L5WBGUVR.js.map +1 -0
  21. package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
  22. package/dist/chunk-LJSIUFYD.js.map +1 -0
  23. package/dist/{chunk-HHGVISVZ.js → chunk-QR5V3AFG.js} +6 -4
  24. package/dist/chunk-QR5V3AFG.js.map +1 -0
  25. package/dist/{chunk-6OEYTYML.js → chunk-UD7TEHZT.js} +15 -15
  26. package/dist/chunk-UD7TEHZT.js.map +1 -0
  27. package/dist/{chunk-HMFODCOX.js → chunk-VHFQKAUL.js} +3 -3
  28. package/dist/{chunk-HMFODCOX.js.map → chunk-VHFQKAUL.js.map} +1 -1
  29. package/dist/{chunk-TO2FUOKF.js → chunk-W3KSUXXR.js} +16 -13
  30. package/dist/chunk-W3KSUXXR.js.map +1 -0
  31. package/dist/{chunk-AE5CNVRU.js → chunk-XCBCZEBO.js} +3 -3
  32. package/dist/{chunk-AE5CNVRU.js.map → chunk-XCBCZEBO.js.map} +1 -1
  33. package/dist/{chunk-E2UQVZ44.js → chunk-Y2HK44TG.js} +3 -3
  34. package/dist/{chunk-E2UQVZ44.js.map → chunk-Y2HK44TG.js.map} +1 -1
  35. package/dist/{chunk-M75ZU6ZZ.js → chunk-YQGQUBKA.js} +3 -3
  36. package/dist/{chunk-M75ZU6ZZ.js.map → chunk-YQGQUBKA.js.map} +1 -1
  37. package/dist/controls/index.d.ts +2 -2
  38. package/dist/controls/index.js +3 -3
  39. package/dist/dragdrop/index.d.ts +3 -3
  40. package/dist/dragdrop/index.js +2 -2
  41. package/dist/index.d.ts +55 -12
  42. package/dist/index.js +306 -159
  43. package/dist/index.js.map +1 -1
  44. package/dist/layers/index.d.ts +4 -4
  45. package/dist/layers/index.js +3 -3
  46. package/dist/loupe/index.d.ts +5 -5
  47. package/dist/loupe/index.js +2 -2
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +336 -15
  50. package/dist/passthrough/weasel-ui.js +2 -2
  51. package/dist/primitives/index.js +4 -4
  52. package/dist/state/index.d.ts +3 -3
  53. package/dist/state/index.js +4 -2
  54. package/dist/state/index.js.map +1 -1
  55. package/dist/styles.css +64 -5
  56. package/dist/surface/index.d.ts +17 -2
  57. package/dist/surface/index.js +3 -2
  58. package/dist/ui/layers/index.js +2 -2
  59. package/dist/undo/index.d.ts +3 -3
  60. package/package.json +8 -8
  61. package/src/annotations/AnnotationOverlay.tsx +10 -6
  62. package/src/annotations/Annotations.less +6 -0
  63. package/src/annotations/Annotations.overlay.test.tsx +113 -0
  64. package/src/annotations/MarkList.tsx +3 -2
  65. package/src/config/builder.test.ts +5 -0
  66. package/src/config/builder.ts +6 -0
  67. package/src/config/types.ts +1 -0
  68. package/src/controls/ControlPanel.test.tsx +23 -0
  69. package/src/controls/ControlPanel.tsx +3 -0
  70. package/src/lab/Lab.surface.test.tsx +8 -3
  71. package/src/lab/LabHeader.test.tsx +12 -0
  72. package/src/lab/LabHeader.tsx +16 -6
  73. package/src/passthrough/weasel-ui.test.ts +9 -0
  74. package/src/passthrough/weasel-ui.ts +4 -0
  75. package/src/primitives/ZoomControl.tsx +2 -0
  76. package/src/state/store.ts +10 -0
  77. package/src/state/types.ts +3 -0
  78. package/src/surface/AGENTS.md +11 -1
  79. package/src/surface/index.ts +7 -1
  80. package/src/surface/useSurfaceTile.ts +20 -2
  81. package/src/surface/useTiledSurface.ts +4 -1
  82. package/src/theme/Interstellar.stories.tsx +4 -3
  83. package/src/theme/base.less +39 -1
  84. package/src/trial/Trial.less +27 -5
  85. package/src/trial/TrialBody.test.tsx +67 -0
  86. package/src/trial/TrialBody.tsx +155 -0
  87. package/src/trial/TrialChrome.tsx +18 -10
  88. package/src/trial/index.ts +2 -0
  89. package/dist/chunk-6OEYTYML.js.map +0 -1
  90. package/dist/chunk-ESIQNQ6K.js.map +0 -1
  91. package/dist/chunk-HHGVISVZ.js.map +0 -1
  92. package/dist/chunk-L5QJOOLV.js.map +0 -1
  93. package/dist/chunk-RBGKL7NF.js.map +0 -1
  94. package/dist/chunk-TO2FUOKF.js.map +0 -1
  95. package/dist/chunk-XJ6N32QP.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.4.0-pre.1",
3
+ "version": "1.4.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -93,7 +93,7 @@
93
93
  "dev:drag": "LABKIT_EXAMPLE=drag-lab vite",
94
94
  "dev:weasel": "LABKIT_EXAMPLE=weasel-lab vite",
95
95
  "build": "tsup && npm run build:dts && npm run build:css && npm run build:fonts",
96
- "build:dts": "tsx scripts/build-dts.mts",
96
+ "build:dts": "NODE_OPTIONS=--max-old-space-size=8192 tsx scripts/build-dts.mts",
97
97
  "prepublishOnly": "npm run build",
98
98
  "build:css": "node scripts/build-css.mjs",
99
99
  "build:fonts": "mkdir -p dist/fonts && cp ../theme/fonts/*.woff2 dist/fonts/",
@@ -114,11 +114,11 @@
114
114
  "react-dom": "^19.0.0"
115
115
  },
116
116
  "dependencies": {
117
- "@weasel-js/core": "1.4.0-pre.1",
118
- "@weasel-js/loupe": "1.4.0-pre.1",
119
- "@weasel-js/svg": "1.4.0-pre.1",
120
- "@weasel-js/theme": "1.4.0-pre.1",
121
- "@weasel-js/ui": "1.4.0-pre.1",
117
+ "@weasel-js/core": "1.4.0",
118
+ "@weasel-js/loupe": "1.4.0",
119
+ "@weasel-js/svg": "1.4.0",
120
+ "@weasel-js/theme": "1.4.0",
121
+ "@weasel-js/ui": "1.4.0",
122
122
  "earcut": "2.2.4",
123
123
  "polygon-clipping": "^0.15.7",
124
124
  "react-aria-components": "^1.5.0",
@@ -129,7 +129,7 @@
129
129
  "@biomejs/biome": "^2.4.12",
130
130
  "@rollup/plugin-alias": "^6.0.0",
131
131
  "@testing-library/jest-dom": "^6.6.0",
132
- "@weasel-js/modes": "1.4.0-pre.1",
132
+ "@weasel-js/modes": "1.4.0",
133
133
  "less": "^4.2.0",
134
134
  "rollup-plugin-dts": "^6.4.1",
135
135
  "tsx": "^4.19.0",
@@ -9,7 +9,7 @@ import {
9
9
  import { type CSSProperties, useEffect, useRef, useState } from 'react';
10
10
  import { createPortal } from 'react-dom';
11
11
  import type { Rect } from '../surface/rect';
12
- import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
12
+ import { useSurfaceCanvas, useSurfaceOptional, useTileId } from '../surface/useSurfaceTile';
13
13
  import { createMarkDrawOne } from './drawOne';
14
14
  import type { WorldRect } from './frac';
15
15
  import { seenFrom } from './staleness';
@@ -101,12 +101,16 @@ export function AnnotationOverlay({
101
101
  configRef.current = config;
102
102
 
103
103
  const id = target.id;
104
+ // The surface's tile namespace is the whole lab's, and every trial of an
105
+ // instrument declares the same target ids — so the tile is registered under
106
+ // the trial-scoped key, not under `id`.
107
+ const tileId = useTileId(id);
104
108
  const lastTile = useRef<HTMLElement | null>(null);
105
109
  useEffect(() => {
106
110
  const el = target.ref.current ?? null;
107
111
  if (el === lastTile.current) return;
108
112
  lastTile.current = el;
109
- surface?.registerTile(id, el);
113
+ surface?.registerTile(tileId, el);
110
114
  });
111
115
  useEffect(
112
116
  () => () => {
@@ -114,20 +118,20 @@ export function AnnotationOverlay({
114
118
  // commit. StrictMode's mount / unmount / mount would otherwise take the
115
119
  // tile out and never put it back, and the surface would measure nothing.
116
120
  lastTile.current = null;
117
- surface?.registerTile(id, null);
121
+ surface?.registerTile(tileId, null);
118
122
  },
119
- [surface, id],
123
+ [surface, tileId],
120
124
  );
121
125
 
122
126
  useEffect(
123
127
  () =>
124
- surface?.registerPainter(id, (next) => {
128
+ surface?.registerPainter(tileId, (next) => {
125
129
  setRect((prev) => (sameRect(prev, next) ? prev : next));
126
130
  // The shared buffer was cleared if it resized this frame; this pane's
127
131
  // own loop has no way to know that.
128
132
  sceneCanvas.current?.requestRedraw();
129
133
  }),
130
- [surface, id],
134
+ [surface, tileId],
131
135
  );
132
136
 
133
137
  const container = surface?.getContainer() ?? null;
@@ -61,6 +61,12 @@
61
61
  cursor: pointer;
62
62
  }
63
63
 
64
+ // Box and typography come from <Input>. This only says how the field shares
65
+ // the row.
66
+ .lk-mark-list__title {
67
+ width: 100%;
68
+ }
69
+
64
70
  // The export panel: a target to export, how, and at what resolution. Held in
65
71
  // a popover because a mark belongs to a target — with more than one pane,
66
72
  // "export" on its own does not say what.
@@ -10,7 +10,9 @@
10
10
  import { act, render } from '@testing-library/react';
11
11
  import { StrictMode, useRef } from 'react';
12
12
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { TrialIdProvider } from '../state/context';
13
14
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
15
+ import type { SurfaceHandle } from '../surface/useTiledSurface';
14
16
  import { useTiledSurface } from '../surface/useTiledSurface';
15
17
  import { AnnotationTargets } from './AnnotationTargets';
16
18
  import { createAnnotationStore } from './store';
@@ -140,3 +142,114 @@ describe('<AnnotationTargets>', () => {
140
142
  expect(document.querySelectorAll('.lk-annotate__input')).toHaveLength(0);
141
143
  });
142
144
  });
145
+
146
+ /** One trial's worth of the harness above: its own pane element, its own
147
+ * overlay, and the target id every trial of an instrument shares. */
148
+ function OneTrial({
149
+ trialId,
150
+ box,
151
+ }: {
152
+ trialId: string;
153
+ box: { left: number; top: number; width: number; height: number };
154
+ }) {
155
+ const pane = useRef<HTMLDivElement | null>(null);
156
+ const annotations = useRef(createAnnotationStore({ targets: () => [] })).current;
157
+ const capability: AnnotationsCapability = {
158
+ targets: (): AnnotationTarget[] => [{ id: 'pane:a', ref: pane, content: { w: 200, h: 100 } }],
159
+ };
160
+ return (
161
+ <TrialIdProvider trialId={trialId}>
162
+ <div
163
+ data-testid={trialId}
164
+ ref={(el) => {
165
+ pane.current = el;
166
+ if (el) stubBox(el, box.left, box.top, box.width, box.height);
167
+ }}
168
+ />
169
+ <AnnotationTargets
170
+ capability={capability}
171
+ state={{}}
172
+ config={{}}
173
+ annotations={annotations}
174
+ activeToolId="rect"
175
+ />
176
+ </TrialIdProvider>
177
+ );
178
+ }
179
+
180
+ describe('two trials of one instrument', () => {
181
+ beforeEach(() => {
182
+ vi.useFakeTimers();
183
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
184
+ setTimeout(() => cb(0), 16),
185
+ );
186
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
187
+ });
188
+ afterEach(() => {
189
+ vi.useRealTimers();
190
+ vi.unstubAllGlobals();
191
+ vi.restoreAllMocks();
192
+ });
193
+
194
+ // An instrument names its regions once, so every trial of it declares the
195
+ // same target id. Sharing the surface's tile namespace, the trial that opens
196
+ // second takes the first one's rect, its observer registration and its
197
+ // painter — and the first is never told it moved again, so it keeps the box
198
+ // it was measured at while it was the only trial open.
199
+ it('re-measures a trial that was alone when a second one opens beside it', () => {
200
+ let handle: SurfaceHandle | null = null;
201
+
202
+ function Trials({ second }: { second: boolean }) {
203
+ const surface = useTiledSurface({ onFrame: () => {} });
204
+ handle = surface;
205
+ return (
206
+ <SurfaceContext.Provider value={surface}>
207
+ <SurfaceCanvasContext.Provider value={null}>
208
+ <div
209
+ ref={(el) => {
210
+ if (!el) return;
211
+ stubBox(el, 0, 0, 1200, 700);
212
+ surface.containerRef(el);
213
+ }}
214
+ >
215
+ <OneTrial
216
+ trialId="t1"
217
+ box={{ left: 0, top: 0, width: second ? 476 : 666, height: 639 }}
218
+ />
219
+ {second ? (
220
+ <OneTrial trialId="t2" box={{ left: 500, top: 0, width: 476, height: 639 }} />
221
+ ) : null}
222
+ </div>
223
+ </SurfaceCanvasContext.Provider>
224
+ </SurfaceContext.Provider>
225
+ );
226
+ }
227
+
228
+ const boxes = (root: HTMLElement) => [
229
+ ...root.querySelectorAll<HTMLElement>('.lk-annotate__input'),
230
+ ];
231
+ const widths = (root: HTMLElement) =>
232
+ boxes(root).map((el) => el.style.getPropertyValue('--lk-anno-w'));
233
+
234
+ const { container, rerender } = render(<Trials second={false} />, { wrapper: StrictMode });
235
+ act(() => {
236
+ vi.advanceTimersByTime(64);
237
+ });
238
+ expect(widths(container)).toEqual(['666px']);
239
+
240
+ // The grid halves the open trial to make room. jsdom's ResizeObserver never
241
+ // fires, so this takes `Workspace`'s path — the one it takes anyway for a
242
+ // tile that moves without resizing, which no observer would have seen.
243
+ rerender(<Trials second={true} />);
244
+ act(() => {
245
+ handle?.invalidateRects();
246
+ vi.advanceTimersByTime(64);
247
+ });
248
+
249
+ expect(widths(container)).toEqual(['476px', '476px']);
250
+ expect(boxes(container).map((el) => el.style.getPropertyValue('--lk-anno-x'))).toEqual([
251
+ '0px',
252
+ '500px',
253
+ ]);
254
+ });
255
+ });
@@ -1,3 +1,4 @@
1
+ import { Input } from '../passthrough/weasel-ui';
1
2
  import { useAnnotations } from './AnnotationsContext';
2
3
  import type { AnnotationMeaning } from './types';
3
4
 
@@ -43,12 +44,12 @@ export function MarkList({ meaning, config }: MarkListProps) {
43
44
  ×
44
45
  </button>
45
46
  </div>
46
- <input
47
+ <Input
47
48
  className="lk-mark-list__title"
48
49
  aria-label={`Title of ${a.kind} on ${a.target}`}
49
50
  value={a.title ?? ''}
50
51
  placeholder="Untitled"
51
- onChange={(e) => marks.update(a.id, { title: e.target.value })}
52
+ onChange={(next) => marks.update(a.id, { title: next })}
52
53
  />
53
54
  {statuses.length > 0 ? (
54
55
  // A native select: it carries role, name and keyboard operability
@@ -29,6 +29,11 @@ describe('builder', () => {
29
29
  });
30
30
  });
31
31
 
32
+ it('annotates a number with a display suffix', () => {
33
+ const n = f.number(20).suffix('px');
34
+ expect(n.annotations.suffix).toBe('px');
35
+ });
36
+
32
37
  it('keeps a custom node kind across a chain', () => {
33
38
  const n = f.custom('vector2', { x: 0 }).label('Offset');
34
39
  expect(n.kind).toBe('vector2');
@@ -90,6 +90,12 @@ class NumberNode extends BaseNode<number> {
90
90
  return this.ann({ step });
91
91
  }
92
92
 
93
+ /** A display suffix for the value — `'px'`, `'ms'`, `'°'`. Presentation only:
94
+ * it is never parsed, and the stored value stays a plain number. */
95
+ suffix(suffix: string): this {
96
+ return this.ann({ suffix });
97
+ }
98
+
93
99
  /** Force a slider even without both bounds. */
94
100
  slider(): this {
95
101
  return this.ann({ control: 'slider' });
@@ -24,6 +24,7 @@ export interface Annotations {
24
24
  min?: number;
25
25
  max?: number;
26
26
  step?: number;
27
+ suffix?: string;
27
28
  control?: string;
28
29
  options?: readonly ConfigOption[];
29
30
  placeholder?: string;
@@ -420,3 +420,26 @@ describe('<ControlPanel> packing', () => {
420
420
  expect(rowOf('Width')?.className).toMatch(/rowInline/);
421
421
  });
422
422
  });
423
+
424
+ describe('<ControlPanel> suffix', () => {
425
+ const panel = (node: ReturnType<typeof f.number>) =>
426
+ render(
427
+ <ControlPanel
428
+ schema={resolveConfigSchema(f.schema({ width: node.label('Width') }), [])}
429
+ config={{ width: 20 }}
430
+ setConfig={vi.fn()}
431
+ />,
432
+ );
433
+
434
+ it('suffixes a slider readout with the suffix the leaf declares', () => {
435
+ const { container } = panel(f.number(20).range(0, 100).slider().suffix('px'));
436
+ expect(container.querySelector('input[type="range"]')).not.toBeNull();
437
+ expect(container.textContent).toContain('px');
438
+ });
439
+
440
+ it('suffixes a typed number with the suffix the leaf declares', () => {
441
+ const { container } = panel(f.number(20).input().suffix('px'));
442
+ expect(container.querySelector('input[type="number"]')).not.toBeNull();
443
+ expect(container.textContent).toContain('px');
444
+ });
445
+ });
@@ -159,6 +159,7 @@ function ControlRow<TC extends Record<string, unknown>>({
159
159
  const min = extra<number>(leaf, 'min');
160
160
  const max = extra<number>(leaf, 'max');
161
161
  const step = extra<number>(leaf, 'step');
162
+ const suffix = extra<string>(leaf, 'suffix');
162
163
  if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
163
164
  return (
164
165
  <SliderRow
@@ -167,6 +168,7 @@ function ControlRow<TC extends Record<string, unknown>>({
167
168
  min={min}
168
169
  max={max}
169
170
  step={step}
171
+ unit={suffix}
170
172
  onChange={write}
171
173
  layout={layout}
172
174
  span={wide}
@@ -186,6 +188,7 @@ function ControlRow<TC extends Record<string, unknown>>({
186
188
  min={min}
187
189
  max={max}
188
190
  step={step}
191
+ unit={suffix}
189
192
  onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
190
193
  layout={layout}
191
194
  description={description}
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
12
12
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { defineInstrument } from '../instrument/defineInstrument';
14
14
  import { SurfaceContext } from '../surface/SurfaceContext';
15
- import { useSurfaceOptional, useSurfaceTile } from '../surface/useSurfaceTile';
15
+ import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
16
16
  import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
17
17
  import { useTiledSurface } from '../surface/useTiledSurface';
18
18
  import { Lab } from './Lab';
@@ -39,9 +39,13 @@ beforeAll(() => {
39
39
  });
40
40
 
41
41
  let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
42
+ /** The key the probe's tile is actually registered under: a tile inside a
43
+ * trial is scoped by it, and the trial's id is minted by the store. */
44
+ let paneTileId = 'pane';
42
45
 
43
46
  function Probe() {
44
47
  seen = useSurfaceOptional();
48
+ paneTileId = useTileId('pane');
45
49
  const tile = useSurfaceTile('pane');
46
50
  return <div data-testid="pane" ref={tile} />;
47
51
  }
@@ -146,7 +150,7 @@ describe('<Lab> tile round trip', () => {
146
150
 
147
151
  act(() => {
148
152
  handle?.invalidateRects();
149
- handle?.invalidate('pane');
153
+ handle?.invalidate(paneTileId);
150
154
  });
151
155
  act(() => {
152
156
  vi.advanceTimersByTime(64);
@@ -156,7 +160,8 @@ describe('<Lab> tile round trip', () => {
156
160
  // that passes because nothing ran is the failure mode this arc keeps
157
161
  // hitting.
158
162
  expect(frames.length).toBeGreaterThan(0);
159
- const rect = frames[frames.length - 1]?.rects.get('pane');
163
+ expect(paneTileId).not.toBe('pane');
164
+ const rect = frames[frames.length - 1]?.rects.get(paneTileId);
160
165
  expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
161
166
  });
162
167
  });
@@ -39,4 +39,16 @@ describe('<LabHeader>', () => {
39
39
  expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
40
40
  }
41
41
  });
42
+
43
+ it('draws each color mode as a glyph, with the word only as its name', () => {
44
+ // The assertion above passes whether the segment holds text or an icon,
45
+ // because both routes produce the same accessible name. This is the one
46
+ // that can tell them apart.
47
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
48
+ for (const label of ['Auto', 'Light', 'Dark']) {
49
+ const segment = screen.getByRole('radio', { name: label });
50
+ expect(segment.querySelector('svg')).not.toBeNull();
51
+ expect(segment).toHaveTextContent('');
52
+ }
53
+ });
42
54
  });
@@ -1,14 +1,22 @@
1
- import { AddIcon } from '@weasel-js/ui';
1
+ import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
2
2
  import { Select, ToggleBar } from '../passthrough/weasel-ui';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { useLabContext } from './LabContext';
5
5
 
6
- const MODES: { value: LabMode; label: string }[] = [
7
- { value: 'auto', label: 'Auto' },
8
- { value: 'light', label: 'Light' },
9
- { value: 'dark', label: 'Dark' },
6
+ // The glyph is the segment's content and the word is its accessible name, so
7
+ // the bar stays a three-way radiogroup announcing Auto / Light / Dark.
8
+ const MODES = [
9
+ { value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
10
+ { value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
11
+ { value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
10
12
  ];
11
13
 
14
+ const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
15
+ value,
16
+ label: glyph,
17
+ ariaLabel: label,
18
+ }));
19
+
12
20
  /** The controls `<Lab>` puts in its header: add a trial, and choose the color
13
21
  * mode. Both drive `LabContext`, which carried them with no UI at all — so
14
22
  * every consumer rebuilt these two. Rendered before a consumer's own header
@@ -46,7 +54,9 @@ export function LabHeader() {
46
54
  <ToggleBar
47
55
  className="lk-lab-header__mode"
48
56
  ariaLabel="Color mode"
49
- items={MODES}
57
+ size="sm"
58
+ variant="flat"
59
+ items={MODE_ITEMS}
50
60
  value={lab.mode}
51
61
  onChange={(next) => {
52
62
  if (next) lab.setMode(next);
@@ -8,6 +8,8 @@ import {
8
8
  Dialog,
9
9
  Field,
10
10
  formatNumber,
11
+ ICON_PATHS,
12
+ Icon,
11
13
  Input,
12
14
  KeyCap,
13
15
  MINUS_SIGN,
@@ -69,6 +71,13 @@ describe('weasel-ui passthrough', () => {
69
71
  }
70
72
  });
71
73
 
74
+ it('re-exports the icon set, so a lab need not depend on weasel-ui itself', () => {
75
+ expect(['function', 'object']).toContain(typeof Icon);
76
+ expect(ICON_PATHS.layoutRows).toContain('<rect');
77
+ expect(ICON_PATHS.layoutColumns).toContain('<rect');
78
+ expect(ICON_PATHS.layoutGrid).toContain('<rect');
79
+ });
80
+
72
81
  it('re-exports app-chrome primitives as React components', () => {
73
82
  for (const C of [Sidebar, ToolPalette, KeyCap]) {
74
83
  expect(C).toBeDefined();
@@ -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
  );