@weasel-js/labkit 1.2.0 → 1.3.0-pre.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/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
- package/dist/canvas/index.d.ts +7 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +4 -2
- package/dist/index.d.ts +246 -106
- package/dist/index.js +673 -559
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +5 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3799 -3672
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +542 -153
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +4 -2
- package/package.json +16 -7
- package/src/canvas/AGENTS.md +4 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.ts +25 -3
- package/src/instrument/SineWave.smoke.test.tsx +8 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +27 -4
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +5 -8
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +11 -3
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +4 -0
- package/src/styles.less +6 -0
- package/src/surface/useTiledSurface.ts +14 -10
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +13 -15
- package/src/trial/Trial.less +115 -10
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +80 -39
- package/src/trial/TrialChrome.tsx +99 -80
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-73KA7WBO.js +0 -5992
- package/dist/chunk-73KA7WBO.js.map +0 -1
- package/dist/chunk-BOHF3PQO.js.map +0 -1
- package/dist/chunk-G5TJVQQT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/dist/chunk-THBG7FQZ.js.map +0 -1
- package/dist/chunk-VUU5UXHE.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -54
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createLabStore } from './store';
|
|
3
|
+
|
|
4
|
+
function store() {
|
|
5
|
+
return createLabStore({ storageKey: 'test', storage: { read: () => null, write: () => {} } });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
|
|
9
|
+
|
|
10
|
+
describe('the tool slot', () => {
|
|
11
|
+
it('starts empty at both levels', () => {
|
|
12
|
+
const s = store();
|
|
13
|
+
expect(s.getState().activeToolId).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets the lab slot', () => {
|
|
17
|
+
const s = store();
|
|
18
|
+
s.getState().setLabTool('brush');
|
|
19
|
+
expect(s.getState().activeToolId).toBe('brush');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sets a trial slot without touching the lab one', () => {
|
|
23
|
+
const s = store();
|
|
24
|
+
s.getState().addTrial(record);
|
|
25
|
+
s.getState().setLabTool('brush');
|
|
26
|
+
s.getState().setTrialTool('t1', 'eraser');
|
|
27
|
+
expect(s.getState().activeToolId).toBe('brush');
|
|
28
|
+
expect(s.getState().trials[0].activeToolId).toBe('eraser');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves a trial slot undefined until it is set', () => {
|
|
32
|
+
const s = store();
|
|
33
|
+
s.getState().addTrial(record);
|
|
34
|
+
expect(s.getState().trials[0].activeToolId).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/state/types.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
15
15
|
/** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
|
|
16
16
|
* but never read into. A 3D lab puts an orbit here and keeps all three. */
|
|
17
17
|
view: TV;
|
|
18
|
+
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
19
|
+
activeToolId?: string | null;
|
|
18
20
|
undoStack: UndoStack;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -39,6 +41,8 @@ export interface LabStoreState {
|
|
|
39
41
|
trials: TrialRecord[];
|
|
40
42
|
savedSnapshots: SavedSnapshot[];
|
|
41
43
|
mode: LabMode;
|
|
44
|
+
/** The lab's tool slot — what a trial with no slot of its own resolves to. */
|
|
45
|
+
activeToolId: string | null;
|
|
42
46
|
/** Per-trial tile extents, keyed by trial id. Opaque here — the
|
|
43
47
|
* shape belongs to whatever lays the trials out. */
|
|
44
48
|
layout: Record<string, unknown>;
|
package/src/styles.less
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
@import './lab/LabShell.less';
|
|
5
5
|
@import './primitives/Toolbar.less';
|
|
6
6
|
@import './primitives/Sidebar.less';
|
|
7
|
+
@import './primitives/ZoomControl.less';
|
|
7
8
|
@import './primitives/StatusBar.less';
|
|
9
|
+
@import './primitives/JobProgress.less';
|
|
8
10
|
@import './primitives/FpsMeter.less';
|
|
9
11
|
@import './primitives/ScaleIndicator.less';
|
|
12
|
+
@import './primitives/Legend.less';
|
|
13
|
+
@import './primitives/FloatingPanel.less';
|
|
10
14
|
@import './lab/Lab.less';
|
|
11
15
|
@import './trial/Trial.less';
|
|
16
|
+
@import './chrome/regions/ViewportRegion.less';
|
|
17
|
+
@import './chrome/regions/PaletteRegion.less';
|
|
12
18
|
@import './controls/ControlPanel.less';
|
|
13
19
|
@import './canvas/CanvasStack.less';
|
|
14
20
|
@import './layers/LayerList.less';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useVisibleRaf } from '@weasel-js/core';
|
|
1
2
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
3
|
import { composeRects, rectsEqual } from './composeRects';
|
|
3
4
|
import type { Box, Rect } from './rect';
|
|
@@ -35,7 +36,6 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
35
36
|
const rects = useRef(new Map<string, Rect>());
|
|
36
37
|
const dirty = useRef(new Set<string>());
|
|
37
38
|
const needsMeasure = useRef(true);
|
|
38
|
-
const raf = useRef(0);
|
|
39
39
|
const observer = useRef<ResizeObserver | null>(null);
|
|
40
40
|
const lastDpr = useRef(0);
|
|
41
41
|
|
|
@@ -58,10 +58,8 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
58
58
|
return changed;
|
|
59
59
|
}, []);
|
|
60
60
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
raf.current = requestAnimationFrame(() => {
|
|
64
|
-
raf.current = 0;
|
|
61
|
+
const frameLoop = useVisibleRaf(
|
|
62
|
+
() => {
|
|
65
63
|
const el = container.current;
|
|
66
64
|
if (!el) {
|
|
67
65
|
dirty.current.clear();
|
|
@@ -85,8 +83,15 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
85
83
|
size: { width: box.width, height: box.height },
|
|
86
84
|
});
|
|
87
85
|
dirty.current.clear();
|
|
88
|
-
}
|
|
89
|
-
|
|
86
|
+
},
|
|
87
|
+
// The host attaches its container through `containerRef`, which may land
|
|
88
|
+
// well after this hook's first effect; the gate re-resolves it per request.
|
|
89
|
+
{ target: () => container.current },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const schedule = useCallback(() => {
|
|
93
|
+
frameLoop.request();
|
|
94
|
+
}, [frameLoop]);
|
|
90
95
|
|
|
91
96
|
const invalidate = useCallback(
|
|
92
97
|
(id: string) => {
|
|
@@ -148,10 +153,9 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
148
153
|
return () => {
|
|
149
154
|
ro.disconnect();
|
|
150
155
|
observer.current = null;
|
|
151
|
-
|
|
152
|
-
raf.current = 0;
|
|
156
|
+
frameLoop.cancel();
|
|
153
157
|
};
|
|
154
|
-
}, [schedule]);
|
|
158
|
+
}, [schedule, frameLoop]);
|
|
155
159
|
|
|
156
160
|
return { invalidate, invalidateAll, invalidateRects, registerTile, containerRef };
|
|
157
161
|
}
|
package/src/test-setup.ts
CHANGED
|
@@ -31,6 +31,32 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
|
31
31
|
} as unknown as typeof ResizeObserver;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// jsdom ships no PointerEvent, so `fireEvent.pointerDown` builds a bare Event
|
|
35
|
+
// and drops clientX/clientY — a drag handler then reads `undefined` and computes
|
|
36
|
+
// NaN deltas, which the DOM rejects as invalid CSS rather than reporting.
|
|
37
|
+
// MouseEvent already carries the coordinate fields.
|
|
38
|
+
if (typeof globalThis.PointerEvent === 'undefined') {
|
|
39
|
+
class PointerEventPolyfill extends MouseEvent {
|
|
40
|
+
readonly pointerId: number;
|
|
41
|
+
readonly pointerType: string;
|
|
42
|
+
readonly isPrimary: boolean;
|
|
43
|
+
constructor(type: string, init: PointerEventInit = {}) {
|
|
44
|
+
super(type, init);
|
|
45
|
+
this.pointerId = init.pointerId ?? 1;
|
|
46
|
+
this.pointerType = init.pointerType ?? 'mouse';
|
|
47
|
+
this.isPrimary = init.isPrimary ?? true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// jsdom implements neither side of pointer capture.
|
|
54
|
+
if (typeof Element.prototype.setPointerCapture === 'undefined') {
|
|
55
|
+
Element.prototype.setPointerCapture = () => {};
|
|
56
|
+
Element.prototype.releasePointerCapture = () => {};
|
|
57
|
+
Element.prototype.hasPointerCapture = () => false;
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
afterEach(() => {
|
|
35
61
|
cleanup();
|
|
36
62
|
});
|
package/src/theme/base.less
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
// Resets and global element defaults.
|
|
18
18
|
.lk-root {
|
|
19
|
+
// labkit's own vocabulary over the theme's raw scale — named for the thing
|
|
20
|
+
// rather than the step, so "the workspace is too tight" has one place to go.
|
|
21
|
+
--lk-workspace-pad: var(--wzl-space-sm);
|
|
22
|
+
|
|
19
23
|
font-family: var(--wzl-font-ui);
|
|
20
24
|
font-size: var(--wzl-font-size);
|
|
21
25
|
font-weight: var(--wzl-font-weight-light);
|
|
@@ -26,8 +30,15 @@
|
|
|
26
30
|
*, *::before, *::after {
|
|
27
31
|
box-sizing: border-box;
|
|
28
32
|
}
|
|
33
|
+
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
// Defaults for labkit's own bare markup. `.lk-root` is inside :where() too, so
|
|
36
|
+
// the whole selector scores (0,0,0): a weasel-ui module class beats it on
|
|
37
|
+
// specificity instead of on which stylesheet happened to be injected last.
|
|
38
|
+
// Deliberate overrides of a weasel-ui component go the other way — see the
|
|
39
|
+
// `.lk-root` prefix convention below.
|
|
40
|
+
:where(.lk-root) {
|
|
41
|
+
:where(button) {
|
|
31
42
|
font: inherit;
|
|
32
43
|
color: inherit;
|
|
33
44
|
background: var(--wzl-surface-raised);
|
|
@@ -55,11 +66,17 @@
|
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
input[type="range"] {
|
|
69
|
+
:where(input[type="range"]) {
|
|
59
70
|
accent-color: var(--wzl-accent);
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// The `.lk-root` prefix convention: a labkit class handed to a weasel-ui
|
|
75
|
+
// component through `className` lands beside that component's own CSS-module
|
|
76
|
+
// class at equal specificity, so injection order — not intent — decides the
|
|
77
|
+
// winner. Prefixing with `.lk-root` scores (0,2,0) and settles it. Use it only
|
|
78
|
+
// where labkit means to override the component; plain `.lk-*` elsewhere.
|
|
79
|
+
|
|
63
80
|
// Cosmic background applied to the Lab root. The starscape sits as a fixed
|
|
64
81
|
// pseudo-element so panning the trial doesn't shift the field of stars.
|
|
65
82
|
.lk-lab {
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"fg-subtle": { "$value": "#5a5a78" },
|
|
29
29
|
"accent": { "$value": "#b08adb" },
|
|
30
30
|
"accent-hover": { "$value": "#c6a3ee" },
|
|
31
|
-
"focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" }
|
|
31
|
+
"focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" },
|
|
32
|
+
"shadow": { "$value": "rgba(0, 0, 0, 0.7)" },
|
|
33
|
+
"border-raised": { "$value": "rgba(255, 255, 255, 0.36)" }
|
|
32
34
|
},
|
|
33
35
|
"gradient": {
|
|
34
36
|
"$type": "gradient",
|
|
@@ -50,7 +52,9 @@
|
|
|
50
52
|
"fg-subtle": { "$value": "#a89b85" },
|
|
51
53
|
"accent": { "$value": "#a86f3c" },
|
|
52
54
|
"accent-hover": { "$value": "#c2854f" },
|
|
53
|
-
"focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" }
|
|
55
|
+
"focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" },
|
|
56
|
+
"shadow": { "$value": "rgba(80, 60, 40, 0.18)" },
|
|
57
|
+
"border-raised": { "$value": "#9e8b71" }
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { IconComponent } from '../chrome/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tool a trial can be in. labkit's own — core's `ToolsApi` carries hotkey
|
|
5
|
+
* slots, ambient tools, eligibility tiers and canvas overlay layers, all bound
|
|
6
|
+
* to the gesture dispatcher, and a labkit instrument is an arbitrary canvas or
|
|
7
|
+
* DOM tree rather than a weasel scene.
|
|
8
|
+
*/
|
|
9
|
+
export interface TrialTool {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon: IconComponent;
|
|
13
|
+
/** Shown in the tooltip. Not bound here — the instrument owns its keymap. */
|
|
14
|
+
shortcut?: string;
|
|
15
|
+
/** Presentation grouping in the palette. Ungrouped tools sort after grouped. */
|
|
16
|
+
group?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What an instrument declares to get a palette region. */
|
|
20
|
+
export interface ToolCapability {
|
|
21
|
+
tools: TrialTool[];
|
|
22
|
+
/** Which tool a fresh trial starts in. Defaults to the first. */
|
|
23
|
+
initial?: string;
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { f } from '../config/builder';
|
|
4
|
+
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
5
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
6
|
+
import { Lab } from '../lab/Lab';
|
|
7
|
+
|
|
8
|
+
const schemaInstrument = defineInstrument({
|
|
9
|
+
name: 'Schema',
|
|
10
|
+
config: f.schema({
|
|
11
|
+
showGrid: f.boolean(true),
|
|
12
|
+
cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
|
|
13
|
+
}),
|
|
14
|
+
initialState: () => ({}),
|
|
15
|
+
render: () => null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const legacyInstrument = defineInstrument({
|
|
19
|
+
name: 'Legacy',
|
|
20
|
+
defaultConfig: () => ({ showGrid: true }),
|
|
21
|
+
configSchema: () => [
|
|
22
|
+
{ type: 'checkbox' as const, key: 'showGrid', label: 'Show grid', default: true },
|
|
23
|
+
],
|
|
24
|
+
initialState: () => ({}),
|
|
25
|
+
render: () => null,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('a trial renders its instrument config', () => {
|
|
29
|
+
it('renders a builder schema into the settings sidebar', () => {
|
|
30
|
+
render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
|
|
31
|
+
expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText('Grid spacing')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders a legacy ConfigField list through the same path', () => {
|
|
36
|
+
render(<Lab instruments={[legacyInstrument]} defaultInstrument="Legacy" />);
|
|
37
|
+
expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('writes a config change back to the trial', () => {
|
|
41
|
+
render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
|
|
42
|
+
const checkbox = screen.getByLabelText('Show grid') as HTMLInputElement;
|
|
43
|
+
expect(checkbox.checked).toBe(true);
|
|
44
|
+
fireEvent.click(checkbox);
|
|
45
|
+
expect((screen.getByLabelText('Show grid') as HTMLInputElement).checked).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('lab-wide config seams', () => {
|
|
50
|
+
it('applies a lab-wide rule before labkit inference', () => {
|
|
51
|
+
const inst = defineInstrument({
|
|
52
|
+
name: 'Ruled',
|
|
53
|
+
config: f.schema({ tintColor: f.value('#ffffff') }),
|
|
54
|
+
initialState: () => ({}),
|
|
55
|
+
render: () => null,
|
|
56
|
+
});
|
|
57
|
+
const colorByName: ConfigRule = (ctx) =>
|
|
58
|
+
ctx.key.endsWith('Color') ? { kind: 'color' } : null;
|
|
59
|
+
render(
|
|
60
|
+
<Lab instruments={[inst]} defaultInstrument="Ruled" configRules={[colorByName]} />,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'color');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('without the rule the same leaf falls back to a text input', () => {
|
|
66
|
+
const inst = defineInstrument({
|
|
67
|
+
name: 'Unruled',
|
|
68
|
+
config: f.schema({ tintColor: f.value('#ffffff') }),
|
|
69
|
+
initialState: () => ({}),
|
|
70
|
+
render: () => null,
|
|
71
|
+
});
|
|
72
|
+
render(<Lab instruments={[inst]} defaultInstrument="Unruled" />);
|
|
73
|
+
expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'text');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('supplies a lab-wide control for a kind labkit does not ship', () => {
|
|
77
|
+
const inst = defineInstrument({
|
|
78
|
+
name: 'Custom',
|
|
79
|
+
config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
|
|
80
|
+
initialState: () => ({}),
|
|
81
|
+
render: () => null,
|
|
82
|
+
});
|
|
83
|
+
const vector2: ControlRenderer = (ctx) => (
|
|
84
|
+
<span>vec:{String((ctx.value as { x: number }).x)}</span>
|
|
85
|
+
);
|
|
86
|
+
render(
|
|
87
|
+
<Lab instruments={[inst]} defaultInstrument="Custom" controls={{ vector2 }} />,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByText('vec:3')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('shows the placeholder when no control is supplied for that kind', () => {
|
|
93
|
+
const inst = defineInstrument({
|
|
94
|
+
name: 'Unwired',
|
|
95
|
+
config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
|
|
96
|
+
initialState: () => ({}),
|
|
97
|
+
render: () => null,
|
|
98
|
+
});
|
|
99
|
+
render(<Lab instruments={[inst]} defaultInstrument="Unwired" />);
|
|
100
|
+
expect(screen.getByText(/vector2/)).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('omits the settings section for an instrument with no config', () => {
|
|
104
|
+
const inst = defineInstrument({
|
|
105
|
+
name: 'Bare',
|
|
106
|
+
defaultConfig: () => ({}),
|
|
107
|
+
initialState: () => ({}),
|
|
108
|
+
render: () => null,
|
|
109
|
+
});
|
|
110
|
+
render(<Lab instruments={[inst]} defaultInstrument="Bare" />);
|
|
111
|
+
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, waitFor } from '@testing-library/react';
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
@@ -13,22 +13,22 @@ interface C {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function makeInstrument(name: string, n: number, delay: number) {
|
|
16
|
-
return defineInstrument<S, C>({
|
|
16
|
+
return defineInstrument<S, C, number>({
|
|
17
17
|
name,
|
|
18
18
|
defaultConfig: () => ({ n }),
|
|
19
19
|
initialState: () => ({ items: [] }),
|
|
20
20
|
render: () => null,
|
|
21
21
|
job: {
|
|
22
22
|
auto: true,
|
|
23
|
-
run: async function* ({ config, signal }): AsyncGenerator<JobEvent<
|
|
23
|
+
run: async function* ({ config, signal }): AsyncGenerator<JobEvent<number>> {
|
|
24
24
|
yield { kind: 'total', total: config.n };
|
|
25
25
|
for (let i = 0; i < config.n; i++) {
|
|
26
26
|
await new Promise((r) => setTimeout(r, delay));
|
|
27
27
|
if (signal.aborted) return;
|
|
28
|
-
yield { kind: 'item', item: i
|
|
28
|
+
yield { kind: 'item', item: i };
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
|
-
onItem: (item, state) => ({ items: [...state.items, item
|
|
31
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
32
32
|
},
|
|
33
33
|
});
|
|
34
34
|
}
|
|
@@ -37,25 +37,23 @@ describe('a trial whose instrument declares a job', () => {
|
|
|
37
37
|
it('shows progress in the trial chrome', async () => {
|
|
38
38
|
const fast = makeInstrument('fast', 3, 1);
|
|
39
39
|
render(<Lab instruments={[fast]} defaultInstrument="fast" storage={null} />);
|
|
40
|
+
const bar = await waitFor(() => screen.getByRole('progressbar', { name: /job progress/i }));
|
|
40
41
|
await waitFor(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
expect(
|
|
42
|
+
// A job that reported a total drives a determinate bar, so the count and
|
|
43
|
+
// the accessible value have to agree.
|
|
44
|
+
expect(bar).toHaveAttribute('aria-valuemax', '3');
|
|
45
|
+
expect(bar).toHaveAttribute('aria-valuenow', '3');
|
|
45
46
|
});
|
|
47
|
+
expect(document.querySelector('.lk-job__count')?.textContent).toMatch(/3\s*\/\s*3/);
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
it('offers a cancel control while running, and drops it once cancelled', async () => {
|
|
49
51
|
const slow = makeInstrument('slow', 40, 20);
|
|
50
52
|
render(<Lab instruments={[slow]} defaultInstrument="slow" storage={null} />);
|
|
51
|
-
const cancel = await waitFor(() => {
|
|
52
|
-
const el = document.querySelector('.lk-trial__job-cancel');
|
|
53
|
-
expect(el).toBeInTheDocument();
|
|
54
|
-
return el as HTMLElement;
|
|
55
|
-
});
|
|
53
|
+
const cancel = await waitFor(() => screen.getByRole('button', { name: /cancel/i }));
|
|
56
54
|
await userEvent.click(cancel);
|
|
57
55
|
await waitFor(() => {
|
|
58
|
-
expect(
|
|
56
|
+
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
|
|
59
57
|
});
|
|
60
58
|
});
|
|
61
59
|
|
package/src/trial/Trial.less
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
.lk-trial {
|
|
2
2
|
display: flex;
|
|
3
|
+
// Click-focusable so the trial's own shortcuts reach it; the trial is not a
|
|
4
|
+
// control, so it draws no focus ring of its own.
|
|
5
|
+
outline: none;
|
|
3
6
|
flex-direction: column;
|
|
4
7
|
height: 100%;
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
// The lab paints --wzl-surface, so a trial on the same token was separated
|
|
9
|
+
// from its own workspace by nothing but a hairline. Raised reads as a panel,
|
|
10
|
+
// and in dark it is translucent, so the backdrop still shows through.
|
|
11
|
+
background: var(--wzl-surface-raised);
|
|
12
|
+
border: var(--wzl-border-w) solid var(--wzl-border-raised);
|
|
7
13
|
border-radius: var(--wzl-radius-md);
|
|
14
|
+
box-shadow: 0 1px 3px var(--wzl-shadow);
|
|
8
15
|
overflow: hidden;
|
|
9
16
|
|
|
10
17
|
&__body {
|
|
@@ -15,21 +22,52 @@
|
|
|
15
22
|
|
|
16
23
|
&__sidebar {
|
|
17
24
|
flex-shrink: 0;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
min-height: 0;
|
|
28
|
+
// Sections floor at their content height, so without this the last ones
|
|
29
|
+
// are cut rather than reachable.
|
|
30
|
+
overflow-y: auto;
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
// An instrument's rendered output is dropped in here directly, so the
|
|
34
|
+
// content well is what makes `height: 100%` mean anything to it. Without
|
|
35
|
+
// the column context and the stretched child, anything a lab returns
|
|
36
|
+
// collapses to its intrinsic height and the trial looks empty below it.
|
|
20
37
|
&__content {
|
|
38
|
+
// The viewport region anchors against this well.
|
|
39
|
+
position: relative;
|
|
21
40
|
flex: 1;
|
|
41
|
+
min-width: 0;
|
|
42
|
+
min-height: 0;
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
22
45
|
overflow: auto;
|
|
46
|
+
|
|
47
|
+
> * {
|
|
48
|
+
flex: 1 1 auto;
|
|
49
|
+
min-height: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// An instrument owns its own interior; the frame contributes chrome, not
|
|
53
|
+
// inset. `--flush` is now only about scrolling.
|
|
54
|
+
&--flush {
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&__canvas-host {
|
|
60
|
+
position: relative;
|
|
61
|
+
width: 100%;
|
|
62
|
+
height: 100%;
|
|
23
63
|
}
|
|
24
64
|
|
|
25
65
|
&__toolbar {
|
|
26
66
|
flex-shrink: 0;
|
|
27
|
-
border-bottom: 1px solid var(--wzl-line-subtle);
|
|
28
67
|
}
|
|
29
68
|
|
|
30
69
|
&__status {
|
|
31
70
|
flex-shrink: 0;
|
|
32
|
-
border-top: 1px solid var(--wzl-line-subtle);
|
|
33
71
|
}
|
|
34
72
|
|
|
35
73
|
&--unknown {
|
|
@@ -51,20 +89,87 @@
|
|
|
51
89
|
font-size: var(--wzl-font-size-sm);
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
// Same `width: 100%` default as the header's Select — pinned so it cannot
|
|
93
|
+
// absorb the toolbar's slack. Placeholder-only, so it need only fit "Load…".
|
|
94
|
+
.lk-root .lk-toolbar__load-select {
|
|
95
|
+
width: 88px;
|
|
96
|
+
}
|
|
97
|
+
|
|
54
98
|
.lk-sidebar__placeholder {
|
|
55
99
|
padding: var(--wzl-space-sm);
|
|
56
100
|
color: var(--wzl-fg-muted);
|
|
57
101
|
font-size: var(--wzl-font-size-sm);
|
|
58
102
|
}
|
|
59
103
|
|
|
60
|
-
|
|
104
|
+
|
|
105
|
+
.lk-trial__titlebar {
|
|
61
106
|
display: flex;
|
|
62
|
-
gap: 8px;
|
|
63
107
|
align-items: center;
|
|
64
|
-
|
|
108
|
+
gap: var(--wzl-space-sm);
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
padding: 0 var(--wzl-space-sm);
|
|
111
|
+
min-height: calc(var(--wzl-font-size-sm) * var(--wzl-leading) + var(--wzl-space-xs));
|
|
112
|
+
background: var(--wzl-surface-sunken);
|
|
113
|
+
border-bottom: var(--wzl-border-w) solid var(--wzl-border);
|
|
114
|
+
border-radius: var(--wzl-radius-md) var(--wzl-radius-md) 0 0;
|
|
115
|
+
|
|
116
|
+
// The bar is the drag surface, so it carries the cursor rather than an
|
|
117
|
+
// affordance of its own.
|
|
118
|
+
&--draggable {
|
|
119
|
+
cursor: grab;
|
|
120
|
+
touch-action: none;
|
|
121
|
+
|
|
122
|
+
&:active { cursor: grabbing; }
|
|
123
|
+
}
|
|
65
124
|
}
|
|
66
125
|
|
|
67
|
-
.lk-
|
|
68
|
-
|
|
69
|
-
|
|
126
|
+
.lk-trial__titlebar-actions {
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 2px;
|
|
130
|
+
margin-left: auto;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Prefixed with `.lk-root` to outrank base.less's `:where(button)` default,
|
|
134
|
+
// which would give a 12px glyph a 24px chrome box on the title bar.
|
|
135
|
+
.lk-root .lk-titlebar-button {
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
width: 18px;
|
|
140
|
+
height: 18px;
|
|
141
|
+
padding: 0;
|
|
142
|
+
border: 0;
|
|
143
|
+
border-radius: var(--wzl-radius-sm);
|
|
144
|
+
background: transparent;
|
|
145
|
+
backdrop-filter: none;
|
|
146
|
+
-webkit-backdrop-filter: none;
|
|
147
|
+
// Matches .lk-trial__title — the X reads as part of the bar's own type,
|
|
148
|
+
// not as chrome sitting on top of it.
|
|
149
|
+
color: var(--wzl-fg-muted);
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
|
|
152
|
+
&:hover {
|
|
153
|
+
background: var(--wzl-surface-hover);
|
|
154
|
+
color: var(--wzl-fg);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
&:disabled {
|
|
158
|
+
opacity: 0.4;
|
|
159
|
+
cursor: default;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
&--danger:hover:not(:disabled) {
|
|
163
|
+
background: color-mix(in srgb, var(--wzl-danger) 15%, transparent);
|
|
164
|
+
color: var(--wzl-danger);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.lk-trial__title {
|
|
169
|
+
font-size: var(--wzl-font-size-sm);
|
|
170
|
+
font-weight: var(--wzl-font-weight-bold);
|
|
171
|
+
color: var(--wzl-fg-muted);
|
|
172
|
+
white-space: nowrap;
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
text-overflow: ellipsis;
|
|
70
175
|
}
|