@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.
- package/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-D6M0o8cQ.d.ts} +1 -1
- package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-QS7XzvpG.d.ts} +2 -2
- package/dist/_dts/{index-BDVzvRzQ.d.ts → index-B2X21G1c.d.ts} +8 -2
- package/dist/_dts/{types-D6s4b7if.d.ts → types-C1Zw7s5P.d.ts} +1 -0
- package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
- package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
- package/dist/canvas/index.d.ts +6 -6
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +4 -4
- package/dist/chrome/index.js +5 -5
- package/dist/chunk-2YILQ7Y5.js +33 -0
- package/dist/chunk-2YILQ7Y5.js.map +1 -0
- package/dist/{chunk-QB5SM4BM.js → chunk-4AMN4MMB.js} +5 -5
- package/dist/{chunk-QB5SM4BM.js.map → chunk-4AMN4MMB.js.map} +1 -1
- package/dist/{chunk-L5QJOOLV.js → chunk-7VW3S44V.js} +512 -56
- package/dist/chunk-7VW3S44V.js.map +1 -0
- package/dist/{chunk-ESIQNQ6K.js → chunk-B5VWJBWM.js} +12 -6
- package/dist/chunk-B5VWJBWM.js.map +1 -0
- package/dist/{chunk-RBGKL7NF.js → chunk-L5WBGUVR.js} +1664 -1628
- package/dist/chunk-L5WBGUVR.js.map +1 -0
- package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
- package/dist/chunk-LJSIUFYD.js.map +1 -0
- package/dist/{chunk-HHGVISVZ.js → chunk-QR5V3AFG.js} +6 -4
- package/dist/chunk-QR5V3AFG.js.map +1 -0
- package/dist/{chunk-6OEYTYML.js → chunk-UD7TEHZT.js} +15 -15
- package/dist/chunk-UD7TEHZT.js.map +1 -0
- package/dist/{chunk-HMFODCOX.js → chunk-VHFQKAUL.js} +3 -3
- package/dist/{chunk-HMFODCOX.js.map → chunk-VHFQKAUL.js.map} +1 -1
- package/dist/{chunk-TO2FUOKF.js → chunk-W3KSUXXR.js} +16 -13
- package/dist/chunk-W3KSUXXR.js.map +1 -0
- package/dist/{chunk-AE5CNVRU.js → chunk-XCBCZEBO.js} +3 -3
- package/dist/{chunk-AE5CNVRU.js.map → chunk-XCBCZEBO.js.map} +1 -1
- package/dist/{chunk-E2UQVZ44.js → chunk-Y2HK44TG.js} +3 -3
- package/dist/{chunk-E2UQVZ44.js.map → chunk-Y2HK44TG.js.map} +1 -1
- package/dist/{chunk-M75ZU6ZZ.js → chunk-YQGQUBKA.js} +3 -3
- package/dist/{chunk-M75ZU6ZZ.js.map → chunk-YQGQUBKA.js.map} +1 -1
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +55 -12
- package/dist/index.js +306 -159
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +5 -5
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +336 -15
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +4 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +64 -5
- package/dist/surface/index.d.ts +17 -2
- package/dist/surface/index.js +3 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +3 -3
- package/package.json +8 -8
- package/src/annotations/AnnotationOverlay.tsx +10 -6
- package/src/annotations/Annotations.less +6 -0
- package/src/annotations/Annotations.overlay.test.tsx +113 -0
- package/src/annotations/MarkList.tsx +3 -2
- package/src/config/builder.test.ts +5 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/controls/ControlPanel.test.tsx +23 -0
- package/src/controls/ControlPanel.tsx +3 -0
- package/src/lab/Lab.surface.test.tsx +8 -3
- package/src/lab/LabHeader.test.tsx +12 -0
- package/src/lab/LabHeader.tsx +16 -6
- package/src/passthrough/weasel-ui.test.ts +9 -0
- package/src/passthrough/weasel-ui.ts +4 -0
- package/src/primitives/ZoomControl.tsx +2 -0
- package/src/state/store.ts +10 -0
- package/src/state/types.ts +3 -0
- package/src/surface/AGENTS.md +11 -1
- package/src/surface/index.ts +7 -1
- package/src/surface/useSurfaceTile.ts +20 -2
- package/src/surface/useTiledSurface.ts +4 -1
- package/src/theme/Interstellar.stories.tsx +4 -3
- package/src/theme/base.less +39 -1
- package/src/trial/Trial.less +27 -5
- package/src/trial/TrialBody.test.tsx +67 -0
- package/src/trial/TrialBody.tsx +155 -0
- package/src/trial/TrialChrome.tsx +18 -10
- package/src/trial/index.ts +2 -0
- package/dist/chunk-6OEYTYML.js.map +0 -1
- package/dist/chunk-ESIQNQ6K.js.map +0 -1
- package/dist/chunk-HHGVISVZ.js.map +0 -1
- package/dist/chunk-L5QJOOLV.js.map +0 -1
- package/dist/chunk-RBGKL7NF.js.map +0 -1
- package/dist/chunk-TO2FUOKF.js.map +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
121
|
+
surface?.registerTile(tileId, null);
|
|
118
122
|
},
|
|
119
|
-
[surface,
|
|
123
|
+
[surface, tileId],
|
|
120
124
|
);
|
|
121
125
|
|
|
122
126
|
useEffect(
|
|
123
127
|
() =>
|
|
124
|
-
surface?.registerPainter(
|
|
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,
|
|
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
|
-
<
|
|
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={(
|
|
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');
|
package/src/config/builder.ts
CHANGED
|
@@ -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' });
|
package/src/config/types.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
});
|
package/src/lab/LabHeader.tsx
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ value: '
|
|
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
|
-
|
|
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();
|
|
@@ -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;
|
package/src/state/store.ts
CHANGED
|
@@ -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) =>
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
package/src/surface/AGENTS.md
CHANGED
|
@@ -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
|
package/src/surface/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
52
|
+
surface?.registerTile(tileId, el);
|
|
35
53
|
},
|
|
36
|
-
[surface,
|
|
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
|
|
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
|
-
|
|
98
|
-
by passing <code>nebula={'{[colors]}'}</code> to the
|
|
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><Lab></code> component.
|
|
99
100
|
</p>
|
|
100
101
|
</div>
|
|
101
102
|
);
|