@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.4.0-pre.0",
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.0",
118
- "@weasel-js/loupe": "1.4.0-pre.0",
119
- "@weasel-js/svg": "1.4.0-pre.0",
120
- "@weasel-js/theme": "1.4.0-pre.0",
121
- "@weasel-js/ui": "1.4.0-pre.0",
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.0",
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
@@ -155,6 +155,78 @@ describe('the annotation store', () => {
155
155
  expect(fn.mock.calls.length).toBe(before);
156
156
  });
157
157
 
158
+ // What the overlay's canvas does to a selection is `scene.setSelection` —
159
+ // weasel binds `useSelection` to the scene it is handed, so the selection is
160
+ // scene state, not React state, and these exercise the real write. What they
161
+ // do NOT reach is the pointer half: no click, marquee or handle runs here,
162
+ // because jsdom has no WebGL2 and the overlay's canvas never paints or
163
+ // hit-tests. That the canvas is bound to this scene is core's claim.
164
+ it('merges the selection across targets, and maps it back to annotation ids', () => {
165
+ const store = makeStore();
166
+ const onNaive = store.add(RING);
167
+ const onOcct = store.add({ ...RING, target: 'occt' });
168
+
169
+ store.setSelection([onOcct, onNaive]);
170
+ // Declaration order, not call order: one merged answer over several scenes.
171
+ expect(store.selection()).toEqual([onNaive, onOcct]);
172
+ expect(store.selection().map((id) => store.get(id)?.target)).toEqual(['naive', 'occt']);
173
+ });
174
+
175
+ it('reads back what the canvas would have written into the scene', () => {
176
+ const store = makeStore();
177
+ const id = store.add(RING);
178
+ const [node] = [...store.sceneFor('naive').renderOrder()];
179
+ if (node === undefined) throw new Error('unreachable');
180
+
181
+ store.sceneFor('naive').setSelection([node]);
182
+ expect(store.selection()).toEqual([id]);
183
+ });
184
+
185
+ it('replaces rather than adds, clearing a target the new selection omits', () => {
186
+ const store = makeStore();
187
+ const onNaive = store.add(RING);
188
+ const onOcct = store.add({ ...RING, target: 'occt' });
189
+
190
+ store.setSelection([onNaive]);
191
+ store.setSelection([onOcct]);
192
+ expect(store.selection()).toEqual([onOcct]);
193
+ expect(store.sceneFor('naive').getSelection()).toEqual([]);
194
+ });
195
+
196
+ it('drops an id it cannot resolve instead of throwing', () => {
197
+ const store = makeStore();
198
+ const id = store.add(RING);
199
+
200
+ expect(() => store.setSelection(['naive/nope', 'ghost/1', 'noslash', ''])).not.toThrow();
201
+ expect(store.selection()).toEqual([]);
202
+
203
+ store.setSelection([id, id]);
204
+ expect(store.selection()).toEqual([id]);
205
+ });
206
+
207
+ it('stops reporting a selected mark once it is removed', () => {
208
+ const store = makeStore();
209
+ const id = store.add(RING);
210
+ store.setSelection([id]);
211
+ store.remove(id);
212
+ expect(store.selection()).toEqual([]);
213
+ });
214
+
215
+ it('notifies subscribers when the selection changes', () => {
216
+ const store = makeStore();
217
+ const id = store.add(RING);
218
+ const fn = vi.fn();
219
+ store.subscribe(fn);
220
+
221
+ store.setSelection([id]);
222
+ expect(fn).toHaveBeenCalled();
223
+
224
+ // A write that changes nothing is not an event.
225
+ const before = fn.mock.calls.length;
226
+ store.setSelection([id]);
227
+ expect(fn.mock.calls.length).toBe(before);
228
+ });
229
+
158
230
  it('round-trips through JSON, and the snapshot is JSON-clean', () => {
159
231
  const store = makeStore();
160
232
  const id = store.add(RING, { angle: 'iso', shading: 'outline' });
@@ -221,6 +221,36 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
221
221
  return history.redo(historySceneAt);
222
222
  },
223
223
 
224
+ selection() {
225
+ const out: string[] = [];
226
+ for (const target of liveTargets()) {
227
+ const scene = scenes.get(target);
228
+ if (!scene) continue;
229
+ for (const node of scene.getSelection()) {
230
+ if (scene.get(node)) out.push(`${target}/${String(node)}`);
231
+ }
232
+ }
233
+ return out;
234
+ },
235
+
236
+ setSelection(ids) {
237
+ const wanted = new Map<string, string[]>();
238
+ for (const id of ids) {
239
+ const found = nodeOf(id);
240
+ if (!found) continue;
241
+ const node = String(found.node.id);
242
+ const on = wanted.get(found.target);
243
+ if (!on) wanted.set(found.target, [node]);
244
+ else if (!on.includes(node)) on.push(node);
245
+ }
246
+ for (const [target, scene] of scenes) {
247
+ const next = wanted.get(target) ?? [];
248
+ const now = scene.getSelection();
249
+ if (now.length === next.length && next.every((n, i) => String(now[i]) === n)) continue;
250
+ scene.setSelection(next.map(asNodeId));
251
+ }
252
+ },
253
+
224
254
  subscribe(fn) {
225
255
  subs.add(fn);
226
256
  return () => {
@@ -196,8 +196,15 @@ export interface AnnotationsApi {
196
196
  within(target: string, box: FracRect): Annotation[];
197
197
  /** Whether `a`'s position still describes the picture `config` produces. */
198
198
  isStale(a: Annotation, config: unknown): boolean;
199
- /** Fires after every mutation. No delta: the scene does not diff, so
200
- * re-query rather than expecting a change payload. */
199
+ /** The marks the user currently has selected, across every target, as
200
+ * annotation ids. Every one resolves through `get`. */
201
+ selection(): readonly string[];
202
+ /** Replace the selection. An id naming a target or a mark that is not
203
+ * there is dropped, the way `update` and `remove` ignore one. */
204
+ setSelection(ids: readonly string[]): void;
205
+ /** Fires after every mutation *and* after a selection change — weasel keeps
206
+ * a canvas's selection on the scene, so both already arrive on this one
207
+ * channel. No delta: re-query, and re-read `selection()`. */
201
208
  subscribe(fn: () => void): () => void;
202
209
 
203
210
  /** Whether the last mark change on any target can be taken back. Weasel
@@ -6,5 +6,16 @@ export type { ZoomAtOptions } from './camera';
6
6
  export { centerOn, zoomAt } from './camera';
7
7
  export { screenToWorld, worldToScreen } from './canvasCoords';
8
8
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
9
+ export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
10
+ export {
11
+ clampPitch,
12
+ orbitAfterDrag,
13
+ orbitAfterWheel,
14
+ PITCH_LIMIT,
15
+ useOrbit,
16
+ wrapYaw,
17
+ } from './useOrbit';
18
+ export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
19
+ export { usePanZoom } from './usePanZoom';
9
20
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
10
21
  export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
@@ -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}
package/src/index.test.ts CHANGED
@@ -67,6 +67,7 @@ describe('surface, job and orbit entry points', () => {
67
67
  expect(typeof kit.fracToWorld).toBe('function');
68
68
  expect(typeof kit.useOrbit).toBe('function');
69
69
  expect(typeof kit.orbitAfterDrag).toBe('function');
70
+ expect(typeof kit.usePanZoom).toBe('function');
70
71
  expect(typeof kit.useJob).toBe('function');
71
72
  expect(typeof kit.as2DView).toBe('function');
72
73
  expect(kit.SurfaceContext).toBeDefined();
package/src/index.ts CHANGED
@@ -115,6 +115,12 @@ export type {
115
115
  CanvasStackContextValue,
116
116
  CanvasStackProps,
117
117
  CanvasStackSurface,
118
+ OrbitHandlers,
119
+ OrbitView,
120
+ PanZoomHandlers,
121
+ UseOrbitOptions,
122
+ UsePanZoomOptions,
123
+ Vec3,
118
124
  ViewportSize,
119
125
  WorldFrame,
120
126
  WorldSpec,
@@ -125,21 +131,19 @@ export {
125
131
  CanvasStack,
126
132
  CanvasStackContext,
127
133
  centerOn,
128
- DEFAULT_FRAME,
129
- resolveFrame,
130
- screenToWorld,
131
- worldToScreen,
132
- zoomAt,
133
- } from './canvas';
134
- export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
135
- export {
136
134
  clampPitch,
135
+ DEFAULT_FRAME,
137
136
  orbitAfterDrag,
138
137
  orbitAfterWheel,
139
138
  PITCH_LIMIT,
139
+ resolveFrame,
140
+ screenToWorld,
140
141
  useOrbit,
142
+ usePanZoom,
143
+ worldToScreen,
141
144
  wrapYaw,
142
- } from './canvas/useOrbit';
145
+ zoomAt,
146
+ } from './canvas';
143
147
  export * from './chrome';
144
148
  export { f } from './config/builder';
145
149
  export { fromConfigFields } from './config/fromConfigField';
@@ -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();