@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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { f } from '../config/builder';
|
|
3
|
+
import type { ConfigOf } from '../config/types';
|
|
2
4
|
import { defineInstrument } from './defineInstrument';
|
|
3
5
|
import type { Instrument } from './types';
|
|
4
6
|
|
|
5
7
|
describe('defineInstrument', () => {
|
|
6
|
-
it('returns
|
|
8
|
+
it('returns a legacy spec by reference', () => {
|
|
7
9
|
const spec: Instrument<{ n: number }, { x: number }> = {
|
|
8
10
|
name: 'Test',
|
|
9
11
|
defaultConfig: () => ({ x: 1 }),
|
|
@@ -24,4 +26,39 @@ describe('defineInstrument', () => {
|
|
|
24
26
|
expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{ flag: boolean }>();
|
|
25
27
|
expectTypeOf(inst.initialState({ flag: true })).toEqualTypeOf<{ count: number }>();
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
it('synthesizes defaultConfig from a builder schema', () => {
|
|
31
|
+
const inst = defineInstrument({
|
|
32
|
+
name: 'Schema',
|
|
33
|
+
config: f.schema({ showGrid: f.boolean(true), cellSize: f.number(20) }),
|
|
34
|
+
initialState: () => ({}),
|
|
35
|
+
render: () => null,
|
|
36
|
+
});
|
|
37
|
+
expect(inst.defaultConfig()).toEqual({ showGrid: true, cellSize: 20 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('infers TC from the schema', () => {
|
|
41
|
+
const config = f.schema({ showGrid: f.boolean(true), mode: f.enum('fast', ['fast', 'slow']) });
|
|
42
|
+
const inst = defineInstrument<{ n: number }, ConfigOf<typeof config>>({
|
|
43
|
+
name: 'Schema',
|
|
44
|
+
config,
|
|
45
|
+
initialState: () => ({ n: 0 }),
|
|
46
|
+
render: () => null,
|
|
47
|
+
});
|
|
48
|
+
expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{
|
|
49
|
+
showGrid: boolean;
|
|
50
|
+
mode: 'fast' | 'slow';
|
|
51
|
+
}>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('leaves an explicit defaultConfig alone even alongside a schema', () => {
|
|
55
|
+
const inst = defineInstrument({
|
|
56
|
+
name: 'Both',
|
|
57
|
+
config: f.schema({ a: f.number(1) }),
|
|
58
|
+
defaultConfig: () => ({ a: 99 }),
|
|
59
|
+
initialState: () => ({}),
|
|
60
|
+
render: () => null,
|
|
61
|
+
});
|
|
62
|
+
expect(inst.defaultConfig()).toEqual({ a: 99 });
|
|
63
|
+
});
|
|
27
64
|
});
|
|
@@ -1,7 +1,34 @@
|
|
|
1
|
+
import type { ConfigSchema } from '../config/types';
|
|
1
2
|
import type { Instrument } from './types';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
export
|
|
6
|
-
|
|
4
|
+
/** An instrument as it is written: either the legacy `defaultConfig` (plus an
|
|
5
|
+
* optional `configSchema`), or a `config` schema that supplies both. */
|
|
6
|
+
export type InstrumentSpec<TS, TC, TItem = unknown> =
|
|
7
|
+
| Instrument<TS, TC, TItem>
|
|
8
|
+
| (Omit<Instrument<TS, TC, TItem>, 'defaultConfig'> & { config: ConfigSchema<TC> });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes an instrument spec, and infers its state and config types from
|
|
12
|
+
* the literal rather than making them be written out.
|
|
13
|
+
*
|
|
14
|
+
* A spec declaring `config` gets its `defaultConfig` synthesized from the
|
|
15
|
+
* schema, so nothing downstream branches on which way the config was
|
|
16
|
+
* declared. A spec written the legacy way is returned unchanged, by
|
|
17
|
+
* reference.
|
|
18
|
+
*
|
|
19
|
+
* Resolving the schema into controls does not happen here: rules are
|
|
20
|
+
* lab-scoped and this runs at module load. `useConfigSchema` does that.
|
|
21
|
+
*
|
|
22
|
+
* TypeScript infers all three type arguments or none, so a call that names
|
|
23
|
+
* `TS` and `TC` leaves `TItem` at `unknown` and a job's `onItem` needs a cast.
|
|
24
|
+
* Name the item type too, or pass no type arguments at all.
|
|
25
|
+
*/
|
|
26
|
+
export function defineInstrument<TS, TC, TItem = unknown>(
|
|
27
|
+
spec: InstrumentSpec<TS, TC, TItem>,
|
|
28
|
+
): Instrument<TS, TC, TItem> {
|
|
29
|
+
if (spec.config && !('defaultConfig' in spec && spec.defaultConfig)) {
|
|
30
|
+
const schema = spec.config;
|
|
31
|
+
return { ...spec, defaultConfig: () => schema.defaults() } as Instrument<TS, TC, TItem>;
|
|
32
|
+
}
|
|
33
|
+
return spec as Instrument<TS, TC, TItem>;
|
|
7
34
|
}
|
package/src/instrument/index.ts
CHANGED
package/src/instrument/types.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TrialContribution } from '../chrome/types';
|
|
3
|
+
import type { ConfigSchema } from '../config/types';
|
|
2
4
|
import type { ConfigField } from '../controls/types';
|
|
3
5
|
import type { JobCapability, JobHandle } from '../job/types';
|
|
6
|
+
import type { ToolCapability } from '../tools/types';
|
|
4
7
|
|
|
5
8
|
/** What an instrument's `render` is handed: its state and config, the setters
|
|
6
9
|
* for both, the trial it is mounted in, and a way to emit named events. */
|
|
@@ -19,6 +22,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
19
22
|
* a view that is not the 2D one. */
|
|
20
23
|
zoom: number;
|
|
21
24
|
setZoom: (z: number) => void;
|
|
25
|
+
/** Resolved active tool: this trial's slot, or the lab's. Null when neither
|
|
26
|
+
* holds one. */
|
|
27
|
+
activeToolId: string | null;
|
|
22
28
|
};
|
|
23
29
|
emit: (event: string) => void;
|
|
24
30
|
/** Present only when the instrument declares a `job`. */
|
|
@@ -40,12 +46,18 @@ export interface CanvasLayer<TS = unknown, TC = unknown> {
|
|
|
40
46
|
export interface CanvasCapability<TS = unknown, TC = unknown> {
|
|
41
47
|
layers: CanvasLayer<TS, TC>[];
|
|
42
48
|
initialView?: { zoom: number; pan: { x: number; y: number } };
|
|
49
|
+
/** Widens `usePanZoom`'s default clamp; the opening zoom stays reachable
|
|
50
|
+
* regardless of these. */
|
|
51
|
+
minZoom?: number;
|
|
52
|
+
maxZoom?: number;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/** Declares which of an instrument's layers the trial should offer
|
|
46
56
|
* show/hide controls for. */
|
|
47
57
|
export interface LayerCapability {
|
|
48
|
-
|
|
58
|
+
/** In list order. A bare string is a layer id that doubles as its own label;
|
|
59
|
+
* give a descriptor instead to label a layer or mark it `alwaysOn`. */
|
|
60
|
+
ids: readonly (string | LayerDescriptor)[];
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
/** Declares that an instrument accepts items dragged from a palette: what the
|
|
@@ -91,10 +103,16 @@ export type DragFeedback = { ok: boolean; reason?: string };
|
|
|
91
103
|
* a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
|
|
92
104
|
* is what makes the trial provide the corresponding chrome.
|
|
93
105
|
*/
|
|
94
|
-
export interface Instrument<TS = unknown, TC = unknown> {
|
|
106
|
+
export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
|
|
95
107
|
name: string;
|
|
96
108
|
defaultConfig: () => TC;
|
|
97
109
|
initialState: (config: TC) => TS;
|
|
110
|
+
/** The instrument's config, declared once: values, types and controls.
|
|
111
|
+
* Supplying this makes `defaultConfig` optional — `defineInstrument`
|
|
112
|
+
* synthesizes it. Prefer it over `defaultConfig` + `configSchema`. */
|
|
113
|
+
config?: ConfigSchema<TC>;
|
|
114
|
+
/** @deprecated Declare `config` instead; this repeats what `TC` already
|
|
115
|
+
* says and nothing holds the two to one answer. */
|
|
98
116
|
configSchema?: () => ConfigField[];
|
|
99
117
|
/** The instrument's DOM. With `canvas`, this renders as an overlay above the
|
|
100
118
|
* layers rather than instead of them; return `null` for canvas only. */
|
|
@@ -106,13 +124,18 @@ export interface Instrument<TS = unknown, TC = unknown> {
|
|
|
106
124
|
layers?: LayerCapability;
|
|
107
125
|
dragDrop?: DragDropCapability<TS, TC>;
|
|
108
126
|
undo?: UndoCapability;
|
|
127
|
+
/** Tools this instrument offers. Declaring them gives the trial a palette
|
|
128
|
+
* region and its own tool slot. */
|
|
129
|
+
tools?: ToolCapability;
|
|
130
|
+
/** Chrome this instrument contributes beyond what its capabilities imply. */
|
|
131
|
+
chrome?: TrialContribution[];
|
|
109
132
|
/** Work too slow to do during a render. The runtime starts it, aborts it on
|
|
110
133
|
* unmount and on a `key` change, and renders progress into the trial. */
|
|
111
|
-
job?: JobCapability<TS, TC,
|
|
134
|
+
job?: JobCapability<TS, TC, TItem>;
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/** Instruments as a lab receives them. `any` rather than `unknown` because
|
|
115
138
|
* parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
|
|
116
139
|
* an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
|
|
117
140
|
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
118
|
-
export type InstrumentList = readonly Instrument<any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
|
|
141
|
+
export type InstrumentList = readonly Instrument<any, any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
|
|
@@ -21,12 +21,22 @@ describe('validateConfigSchema', () => {
|
|
|
21
21
|
expect(r.errors).toContain('Duplicate config key: "a"');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it('
|
|
24
|
+
it('accepts an unknown field type — a lab may supply its renderer', () => {
|
|
25
25
|
const fields = [
|
|
26
|
-
{ key: 'x', label: 'X', type: '
|
|
26
|
+
{ key: 'x', label: 'X', type: 'vector2', default: 1 },
|
|
27
27
|
] as unknown as ConfigField[];
|
|
28
28
|
const r = validateConfigSchema(fields);
|
|
29
|
-
expect(r.
|
|
29
|
+
expect(r.valid).toBe(true);
|
|
30
|
+
expect(r.errors).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('still checks keys and labels on an unknown field type', () => {
|
|
34
|
+
const fields = [
|
|
35
|
+
{ key: '', label: '', type: 'vector2', default: 1 },
|
|
36
|
+
] as unknown as ConfigField[];
|
|
37
|
+
const r = validateConfigSchema(fields);
|
|
38
|
+
expect(r.errors).toContain('Field has empty key');
|
|
39
|
+
expect(r.errors).toContain('Field "" has empty label');
|
|
30
40
|
});
|
|
31
41
|
|
|
32
42
|
it('flags slider min >= max', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigField
|
|
1
|
+
import type { ConfigField } from '../controls/types';
|
|
2
2
|
|
|
3
3
|
/** Whether a config schema is usable, and every problem found rather than
|
|
4
4
|
* just the first. */
|
|
@@ -7,11 +7,13 @@ export interface ValidationResult {
|
|
|
7
7
|
errors: string[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
|
|
11
|
-
|
|
12
10
|
/** Check a config schema for the mistakes that would otherwise surface as a
|
|
13
|
-
* silently broken control: empty or duplicate keys,
|
|
14
|
-
*
|
|
11
|
+
* silently broken control: empty or duplicate keys, and fields whose own
|
|
12
|
+
* constraints do not hold.
|
|
13
|
+
*
|
|
14
|
+
* An unrecognized type is not an error — a lab supplies controls for its own
|
|
15
|
+
* kinds through `ControlPanel`'s `renderers`, and this cannot see them. Such
|
|
16
|
+
* a field simply has no constraints to check here. */
|
|
15
17
|
export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
16
18
|
const errors: string[] = [];
|
|
17
19
|
const seenKeys = new Set<string>();
|
|
@@ -29,11 +31,6 @@ export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
|
29
31
|
errors.push(`Field "${field.key}" has empty label`);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
if (!KNOWN_TYPES.includes(field.type)) {
|
|
33
|
-
errors.push(`Unknown field type: "${field.type}" on key "${field.key}"`);
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
if (field.type === 'slider') {
|
|
38
35
|
if (!Number.isFinite(field.default)) {
|
|
39
36
|
errors.push(`Field "${field.key}": default must be a finite number`);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialContribution } from '../chrome/types';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { Lab } from './Lab';
|
|
6
|
+
|
|
7
|
+
const Glyph = () => <svg />;
|
|
8
|
+
const bare: Instrument = {
|
|
9
|
+
name: 'Bare',
|
|
10
|
+
defaultConfig: () => ({}),
|
|
11
|
+
initialState: () => ({}),
|
|
12
|
+
render: () => null,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** React logs the error it re-throws from a failed render; the assertions are
|
|
16
|
+
* on the throw itself, so the console noise is not informative. */
|
|
17
|
+
function silenceRenderError(): void {
|
|
18
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('<Lab> chrome', () => {
|
|
22
|
+
it('renders a consumer contribution', () => {
|
|
23
|
+
const extra: TrialContribution = {
|
|
24
|
+
id: 'export',
|
|
25
|
+
region: 'toolbar',
|
|
26
|
+
item: { icon: Glyph, label: 'Export', onActivate: () => {} },
|
|
27
|
+
};
|
|
28
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />);
|
|
29
|
+
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('suppresses a built-in by id', () => {
|
|
33
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />);
|
|
34
|
+
expect(screen.queryByRole('button', { name: 'Save snapshot' })).toBeNull();
|
|
35
|
+
expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws when a consumer id collides with a built-in', () => {
|
|
39
|
+
silenceRenderError();
|
|
40
|
+
const clash: TrialContribution = {
|
|
41
|
+
id: 'clone',
|
|
42
|
+
region: 'toolbar',
|
|
43
|
+
item: { icon: Glyph, label: 'Mine', onActivate: () => {} },
|
|
44
|
+
};
|
|
45
|
+
expect(() =>
|
|
46
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[clash]} />),
|
|
47
|
+
).toThrow(/duplicate contribution id "clone"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws when suppressing an id that is not there', () => {
|
|
51
|
+
silenceRenderError();
|
|
52
|
+
expect(() =>
|
|
53
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['nope']} />),
|
|
54
|
+
).toThrow(/cannot suppress "nope"/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders an instrument-declared contribution', () => {
|
|
58
|
+
const withChrome: Instrument = {
|
|
59
|
+
...bare,
|
|
60
|
+
chrome: [{ id: 'mine', region: 'status', item: { text: 'ready' } }],
|
|
61
|
+
};
|
|
62
|
+
render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
|
|
63
|
+
expect(screen.getByText('ready')).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { ThemeProvider } from '@weasel-js/theme/react';
|
|
2
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { useStore } from 'zustand/react';
|
|
4
|
+
import type { TrialContribution } from '../chrome/types';
|
|
5
|
+
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
4
6
|
import type { InstrumentList } from '../instrument/types';
|
|
5
7
|
import { noneAdapter } from '../state/adapters';
|
|
6
8
|
import { LabStoreContext } from '../state/context';
|
|
7
9
|
import { createLabStore, type LabStore } from '../state/store';
|
|
8
10
|
import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
|
|
9
11
|
import { interstellarTheme } from '../theme/interstellar';
|
|
12
|
+
import type { TrialTool } from '../tools/types';
|
|
10
13
|
import { Trial } from '../trial/Trial';
|
|
11
14
|
import {
|
|
12
15
|
addTrial as addTrialOp,
|
|
@@ -16,6 +19,8 @@ import {
|
|
|
16
19
|
resetTrial as resetTrialOp,
|
|
17
20
|
} from '../trial/trialOps';
|
|
18
21
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
22
|
+
import { LabHeader } from './LabHeader';
|
|
23
|
+
import { LabPalette } from './LabPalette';
|
|
19
24
|
import { LabShell } from './LabShell';
|
|
20
25
|
import { useResolvedMode } from './useSystemMode';
|
|
21
26
|
import { type TrialLayout, Workspace } from './Workspace';
|
|
@@ -35,6 +40,22 @@ export interface LabProps {
|
|
|
35
40
|
*/
|
|
36
41
|
nebula?: readonly string[];
|
|
37
42
|
title?: string;
|
|
43
|
+
/** Rendered in the shell's footer, below the workspace. */
|
|
44
|
+
footer?: ReactNode;
|
|
45
|
+
/** Contributions added to every trial's chrome, after the instrument's own. */
|
|
46
|
+
chrome?: readonly TrialContribution[];
|
|
47
|
+
/** Built-in contribution ids to drop. Throws on an id that is not there. */
|
|
48
|
+
suppress?: readonly string[];
|
|
49
|
+
/** Tools offered lab-wide. A trial whose instrument declares none of its own
|
|
50
|
+
* reflects and writes this slot. */
|
|
51
|
+
tools?: readonly TrialTool[];
|
|
52
|
+
/** Rules run over every instrument's config leaves, before labkit's own
|
|
53
|
+
* inference — where a lab states a convention once instead of annotating
|
|
54
|
+
* each field. Memoize or hoist. */
|
|
55
|
+
configRules?: readonly ConfigRule[];
|
|
56
|
+
/** Control overrides and app-defined kinds for every trial's settings panel.
|
|
57
|
+
* Keys are config paths (checked first) or leaf kinds. Memoize or hoist. */
|
|
58
|
+
controls?: Record<string, ControlRenderer>;
|
|
38
59
|
children?: ReactNode;
|
|
39
60
|
}
|
|
40
61
|
|
|
@@ -88,6 +109,12 @@ export function Lab({
|
|
|
88
109
|
mode,
|
|
89
110
|
nebula,
|
|
90
111
|
title,
|
|
112
|
+
footer,
|
|
113
|
+
chrome,
|
|
114
|
+
suppress,
|
|
115
|
+
tools,
|
|
116
|
+
configRules,
|
|
117
|
+
controls,
|
|
91
118
|
children,
|
|
92
119
|
}: LabProps) {
|
|
93
120
|
if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
|
|
@@ -160,7 +187,8 @@ export function Lab({
|
|
|
160
187
|
},
|
|
161
188
|
savedSnapshots,
|
|
162
189
|
saveSnapshot: (trialId, name) => {
|
|
163
|
-
store.getState().
|
|
190
|
+
const taken = store.getState().savedSnapshots.filter((s) => s.trialId === trialId).length;
|
|
191
|
+
store.getState().saveSnapshot(trialId, name ?? `Snapshot ${taken + 1}`);
|
|
164
192
|
},
|
|
165
193
|
loadSnapshot: (trialId, snapshotId) => {
|
|
166
194
|
store.getState().loadSnapshot(snapshotId, trialId);
|
|
@@ -172,8 +200,10 @@ export function Lab({
|
|
|
172
200
|
setMode: (m) => {
|
|
173
201
|
store.getState().setMode(m);
|
|
174
202
|
},
|
|
203
|
+
configRules,
|
|
204
|
+
controls,
|
|
175
205
|
};
|
|
176
|
-
}, [instruments, trials, savedSnapshots, modeValue, store]);
|
|
206
|
+
}, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
|
|
177
207
|
|
|
178
208
|
// Only override the backdrop in dark, where there is one to override.
|
|
179
209
|
// Setting a CSS custom property is the sanctioned use of inline style.
|
|
@@ -191,19 +221,32 @@ export function Lab({
|
|
|
191
221
|
className="lk-lab"
|
|
192
222
|
style={backdropStyle}
|
|
193
223
|
>
|
|
194
|
-
<LabShell
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
<LabShell
|
|
225
|
+
title={title ?? 'Labkit'}
|
|
226
|
+
mode={modeValue}
|
|
227
|
+
footer={footer}
|
|
228
|
+
header={
|
|
229
|
+
<>
|
|
230
|
+
<LabHeader />
|
|
231
|
+
{children}
|
|
232
|
+
</>
|
|
233
|
+
}
|
|
234
|
+
>
|
|
235
|
+
<div className="lk-lab__body">
|
|
236
|
+
{tools ? <LabPalette tools={tools} /> : null}
|
|
237
|
+
<Workspace
|
|
238
|
+
ids={trials.map((w) => w.id)}
|
|
239
|
+
resizable
|
|
240
|
+
reorderable
|
|
241
|
+
onReorder={(ids) => contextValue.reorderTrials(ids)}
|
|
242
|
+
layout={layout as TrialLayout}
|
|
243
|
+
onLayoutChange={(next) => store.getState().setLayout(next)}
|
|
244
|
+
>
|
|
245
|
+
{trials.map((w) => (
|
|
246
|
+
<Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
|
|
247
|
+
))}
|
|
248
|
+
</Workspace>
|
|
249
|
+
</div>
|
|
207
250
|
</LabShell>
|
|
208
251
|
</ThemeProvider>
|
|
209
252
|
</LabContext.Provider>
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
2
3
|
import type { InstrumentList } from '../instrument/types';
|
|
3
4
|
import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
4
5
|
|
|
@@ -19,6 +20,11 @@ export interface LabContextValue {
|
|
|
19
20
|
deleteSnapshot: (snapshotId: string) => void;
|
|
20
21
|
mode: LabMode;
|
|
21
22
|
setMode: (m: LabMode) => void;
|
|
23
|
+
/** Lab-wide rules run over every instrument's config leaves, before
|
|
24
|
+
* labkit's own inference. */
|
|
25
|
+
configRules?: readonly ConfigRule[];
|
|
26
|
+
/** Lab-wide control overrides, keyed by config path or by leaf kind. */
|
|
27
|
+
controls?: Record<string, ControlRenderer>;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** Context carrying the surrounding lab. Prefer `useLabContext`. */
|