@weasel-js/labkit 1.4.2 → 1.4.4
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/README.md +20 -2
- package/dist/_dts/{CanvasStackContext-kjILVnPj.d.ts → CanvasStackContext-kTEZvEgE.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-CgxUequc.d.ts → PrefsForm.d-DDHFANkc.d.ts} +1 -1
- package/dist/_dts/{frac-A4R6v4ld.d.ts → frac-z7ker2Vx.d.ts} +11 -7
- package/dist/_dts/{index-CsU9WhjM.d.ts → index-Blv9uQzu.d.ts} +42 -9
- package/dist/_dts/types-B9_zrHmb.d.ts +166 -0
- package/dist/_dts/{types-BbXpvQa8.d.ts → types-BP2OCcpg.d.ts} +21 -3
- package/dist/_dts/types-x92Kfeme.d.ts +62 -0
- package/dist/_dts/{useTrialState-DKjqpv20.d.ts → useTrialState-D6Vb3T-g.d.ts} +7 -7
- package/dist/canvas/index.d.ts +9 -8
- package/dist/canvas/index.js +1 -2
- package/dist/chrome/index.d.ts +7 -6
- package/dist/chrome/index.js +5 -5
- package/dist/{chunk-ULDW42CR.js → chunk-2P6PP5N4.js} +33 -23
- package/dist/chunk-2P6PP5N4.js.map +1 -0
- package/dist/{chunk-W2FJR5FF.js → chunk-64ZCN3DA.js} +74 -76
- package/dist/chunk-64ZCN3DA.js.map +1 -0
- package/dist/{chunk-NSOVI3AZ.js → chunk-BKFVHKJH.js} +6 -6
- package/dist/{chunk-NSOVI3AZ.js.map → chunk-BKFVHKJH.js.map} +1 -1
- package/dist/chunk-E44SU6XS.js +50 -0
- package/dist/chunk-E44SU6XS.js.map +1 -0
- package/dist/{chunk-WS6ZRV75.js → chunk-FQMJUVHJ.js} +3 -3
- package/dist/{chunk-WS6ZRV75.js.map → chunk-FQMJUVHJ.js.map} +1 -1
- package/dist/{chunk-FJG4PHTL.js → chunk-I6JCVE24.js} +3 -3
- package/dist/chunk-I6JCVE24.js.map +1 -0
- package/dist/{chunk-UDXOYZEC.js → chunk-MOM3GOVY.js} +4 -4
- package/dist/{chunk-UDXOYZEC.js.map → chunk-MOM3GOVY.js.map} +1 -1
- package/dist/{chunk-TN7YSJVU.js → chunk-RS3HRQWU.js} +3 -3
- package/dist/{chunk-TN7YSJVU.js.map → chunk-RS3HRQWU.js.map} +1 -1
- package/dist/{chunk-UTOEDPNU.js → chunk-SMHP6XZ4.js} +7 -6
- package/dist/chunk-SMHP6XZ4.js.map +1 -0
- package/dist/{chunk-D5KQ5OY6.js → chunk-TJ7QY3OC.js} +3 -3
- package/dist/{chunk-D5KQ5OY6.js.map → chunk-TJ7QY3OC.js.map} +1 -1
- package/dist/{chunk-XKENZTNE.js → chunk-U3IYHIAE.js} +71 -55
- package/dist/chunk-U3IYHIAE.js.map +1 -0
- package/dist/chunk-W3ECWC2K.js +7335 -0
- package/dist/chunk-W3ECWC2K.js.map +1 -0
- package/dist/controls/index.d.ts +5 -4
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +6 -5
- package/dist/dragdrop/index.js +1 -2
- package/dist/index.d.ts +139 -26
- package/dist/index.js +309 -144
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +7 -6
- package/dist/layers/index.js +2 -3
- package/dist/loupe/index.d.ts +8 -7
- package/dist/loupe/index.js +1 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -3
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-canvas.js.map +1 -1
- package/dist/passthrough/weasel-ui.d.ts +98 -20
- package/dist/passthrough/weasel-ui.js +1 -2
- package/dist/primitives/index.js +3 -4
- package/dist/state/index.d.ts +6 -3
- package/dist/state/index.js +3 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +82 -15
- package/dist/surface/index.js +1 -2
- package/dist/ui/layers/index.js +1 -2
- package/dist/undo/index.d.ts +6 -6
- package/package.json +7 -7
- package/src/annotations/ExportMenu.test.tsx +16 -0
- package/src/annotations/ExportMenu.tsx +0 -5
- package/src/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- package/src/annotations/store.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/config/builder.test.ts +56 -2
- package/src/config/builder.ts +73 -13
- package/src/config/index.ts +16 -1
- package/src/config/path.test.ts +109 -0
- package/src/config/path.ts +76 -0
- package/src/config/resolve.test.ts +93 -2
- package/src/config/resolve.ts +72 -29
- package/src/config/types.ts +99 -9
- package/src/config/visible.ts +6 -4
- package/src/controls/ControlPanel.stories.tsx +38 -4
- package/src/controls/ControlPanel.test.tsx +114 -0
- package/src/controls/ControlPanel.tsx +99 -50
- package/src/index.ts +17 -2
- package/src/instrument/serializers.ts +36 -0
- package/src/instrument/types.ts +4 -2
- package/src/lab/Lab.test.tsx +29 -1
- package/src/lab/Lab.tsx +10 -1
- package/src/lab/LabShell.test.tsx +8 -0
- package/src/lab/LabShell.tsx +17 -3
- package/src/lab/LabSwitcher.less +85 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/index.ts +2 -0
- package/src/primitives/ZoomControl.less +6 -2
- package/src/primitives/ZoomControl.tsx +1 -0
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +17 -1
- package/src/state/store.test.ts +155 -1
- package/src/state/store.ts +32 -26
- package/src/state/types.ts +24 -3
- package/src/state/useTrialState.ts +1 -1
- package/src/styles.less +1 -0
- package/src/theme/base.less +4 -4
- package/src/trial/Trial.config.test.tsx +44 -0
- package/src/trial/Trial.tsx +3 -3
- package/src/trial/TrialChrome.tsx +10 -5
- package/src/trial/trialOps.ts +7 -10
- package/dist/_dts/types-lg4TSCb2.d.ts +0 -164
- package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +0 -5041
- package/dist/chunk-54ZWZ5FQ.js +0 -29997
- package/dist/chunk-54ZWZ5FQ.js.map +0 -1
- package/dist/chunk-FJG4PHTL.js.map +0 -1
- package/dist/chunk-ISSVF5PT.js +0 -7263
- package/dist/chunk-ISSVF5PT.js.map +0 -1
- package/dist/chunk-ULDW42CR.js.map +0 -1
- package/dist/chunk-UTOEDPNU.js.map +0 -1
- package/dist/chunk-W2FJR5FF.js.map +0 -1
- package/dist/chunk-XKENZTNE.js.map +0 -1
|
@@ -2,12 +2,15 @@ import { isBuiltinToolPref } from '@weasel-js/core';
|
|
|
2
2
|
import {
|
|
3
3
|
CheckboxRow,
|
|
4
4
|
ColorRow,
|
|
5
|
+
isPrefLeaf,
|
|
5
6
|
NumberRow,
|
|
7
|
+
type PrefGroup,
|
|
6
8
|
type PrefLeaf,
|
|
7
9
|
type PropertyAlign,
|
|
8
10
|
type PropertyDensity,
|
|
9
11
|
PropertyGroup,
|
|
10
12
|
PropertyList,
|
|
13
|
+
type PropertyListPack,
|
|
11
14
|
PropertyRow,
|
|
12
15
|
type PropertyRowLayout,
|
|
13
16
|
SelectRow,
|
|
@@ -15,9 +18,10 @@ import {
|
|
|
15
18
|
TextRow,
|
|
16
19
|
ToggleRow,
|
|
17
20
|
} from '@weasel-js/ui';
|
|
18
|
-
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
21
|
+
import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
22
|
import { fromConfigFields } from '../config/fromConfigField';
|
|
20
|
-
import
|
|
23
|
+
import { schemaNodeAtPath, valueAtPath } from '../config/path';
|
|
24
|
+
import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
|
|
21
25
|
import { isLeafVisible } from '../config/visible';
|
|
22
26
|
import type { ConfigField } from './types';
|
|
23
27
|
|
|
@@ -37,7 +41,9 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
37
41
|
/** @deprecated Pass `schema`. A field list is adapted into one internally. */
|
|
38
42
|
fields?: ConfigField[];
|
|
39
43
|
config: TC;
|
|
40
|
-
|
|
44
|
+
/** Writes one value. The path is dotted for a leaf inside an `f.group`, and
|
|
45
|
+
* the bare key for one at the root. */
|
|
46
|
+
setConfig: (path: string, value: unknown) => void;
|
|
41
47
|
/**
|
|
42
48
|
* Control overrides and app-defined kinds, PrefsForm-style. Keys are config
|
|
43
49
|
* paths (checked first) or leaf kinds. A renderer returning `null` collapses
|
|
@@ -58,16 +64,21 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
|
|
|
58
64
|
align?: PropertyAlign;
|
|
59
65
|
/**
|
|
60
66
|
* Fold each section away behind a twisty, starting `'open'` or `'closed'`.
|
|
61
|
-
* Unset draws the heading alone, as the panel always has
|
|
67
|
+
* Unset draws the heading alone, as the panel always has — unless a section
|
|
68
|
+
* in the schema declares how it opens, which makes that section foldable on
|
|
69
|
+
* its own and outranks this for that one section.
|
|
62
70
|
*/
|
|
63
71
|
collapse?: 'open' | 'closed';
|
|
64
72
|
/**
|
|
65
|
-
* Which sections are folded, keyed
|
|
66
|
-
* no state of its own: every toggle arrives at `onCollapse`
|
|
67
|
-
* is where a lab that remembers a trial's sections writes
|
|
73
|
+
* Which sections are folded, keyed as `onCollapse` reports them. Given, the
|
|
74
|
+
* panel keeps no state of its own: every toggle arrives at `onCollapse`
|
|
75
|
+
* instead, which is where a lab that remembers a trial's sections writes
|
|
76
|
+
* them.
|
|
68
77
|
*/
|
|
69
78
|
collapsed?: Readonly<Record<string, boolean>>;
|
|
70
|
-
|
|
79
|
+
/** A fold moved. The key is a section's label — prefixed by its group's
|
|
80
|
+
* dotted path when the section sits inside one — or a group's own path. */
|
|
81
|
+
onCollapse?: (key: string, collapsed: boolean) => void;
|
|
71
82
|
/** Draw leaves marked `hidden`. */
|
|
72
83
|
showHidden?: boolean;
|
|
73
84
|
className?: string;
|
|
@@ -94,27 +105,75 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
94
105
|
}: ControlPanelProps<TC>) {
|
|
95
106
|
const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
|
|
96
107
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
const gridPack: PropertyListPack = pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
109
|
+
// A section that declares how it opens is foldable whether or not the lab
|
|
110
|
+
// asked for folds — there is nothing else for the declaration to mean.
|
|
111
|
+
const folds =
|
|
112
|
+
collapse !== undefined ||
|
|
113
|
+
collapsed !== undefined ||
|
|
114
|
+
onCollapse !== undefined ||
|
|
115
|
+
resolved.sections.some((s) => s.collapsed !== undefined);
|
|
116
|
+
const startsFolded = collapse === 'closed';
|
|
100
117
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
const fold = (key: string, declared?: boolean) => ({
|
|
119
|
+
pack: gridPack,
|
|
120
|
+
collapsible: folds,
|
|
121
|
+
defaultCollapsed: declared ?? startsFolded,
|
|
122
|
+
collapsed: collapsed ? (collapsed[key] ?? declared ?? startsFolded) : undefined,
|
|
123
|
+
onCollapsedChange: onCollapse ? (next: boolean) => onCollapse(key, next) : undefined,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** One node, which is either a group to recurse into or a row to draw. */
|
|
127
|
+
const node = (path: string): ReactNode => {
|
|
128
|
+
const found = schemaNodeAtPath(resolved.group, path);
|
|
129
|
+
if (!found) return null;
|
|
130
|
+
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
131
|
+
if (isPrefLeaf(found)) {
|
|
132
|
+
return (
|
|
133
|
+
<ControlRow
|
|
134
|
+
key={path}
|
|
135
|
+
path={path}
|
|
136
|
+
leaf={found}
|
|
137
|
+
resolved={resolved}
|
|
138
|
+
config={config}
|
|
139
|
+
setConfig={setConfig}
|
|
140
|
+
renderers={renderers}
|
|
141
|
+
pack={pack}
|
|
142
|
+
layout={layout}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
// A group with no name organizes without heading it — core's rule for an
|
|
147
|
+
// empty `PrefGroup.name` — so it contributes its rows and no chrome.
|
|
148
|
+
if (found.name === '') return <Fragment key={path}>{body(found, path)}</Fragment>;
|
|
149
|
+
return (
|
|
150
|
+
<PropertyGroup key={path} title={found.name} {...fold(path)}>
|
|
151
|
+
{body(found, path)}
|
|
152
|
+
</PropertyGroup>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** One group's children: its loose nodes, then its sections. */
|
|
157
|
+
const body = (group: PrefGroup, at: string): ReactNode => {
|
|
158
|
+
const sections = resolved.sections.filter((s) => s.at === at);
|
|
159
|
+
const sectioned = new Set(sections.flatMap((s) => s.paths));
|
|
160
|
+
const paths = Object.keys(group.children).map((key) => (at === '' ? key : `${at}.${key}`));
|
|
161
|
+
return (
|
|
162
|
+
<>
|
|
163
|
+
{paths.filter((p) => !sectioned.has(p)).map(node)}
|
|
164
|
+
{sections.map((section) => (
|
|
165
|
+
<PropertyGroup
|
|
166
|
+
key={sectionKey(section)}
|
|
167
|
+
title={section.label}
|
|
168
|
+
{...fold(sectionKey(section), section.collapsed)}
|
|
169
|
+
>
|
|
170
|
+
{section.paths.map(node)}
|
|
171
|
+
</PropertyGroup>
|
|
172
|
+
))}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
114
176
|
|
|
115
|
-
const gridPack = pack === 'one-up' ? 'auto-color' : 'pairs';
|
|
116
|
-
const folds = collapse !== undefined || collapsed !== undefined || onCollapse !== undefined;
|
|
117
|
-
const startsFolded = collapse === 'closed';
|
|
118
177
|
return (
|
|
119
178
|
<PropertyList
|
|
120
179
|
pack={gridPack}
|
|
@@ -122,33 +181,27 @@ export function ControlPanel<TC extends Record<string, unknown>>({
|
|
|
122
181
|
align={align}
|
|
123
182
|
className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}
|
|
124
183
|
>
|
|
125
|
-
{
|
|
126
|
-
{resolved.sections.map((section) => (
|
|
127
|
-
<PropertyGroup
|
|
128
|
-
key={section.label}
|
|
129
|
-
title={section.label}
|
|
130
|
-
pack={gridPack}
|
|
131
|
-
collapsible={folds}
|
|
132
|
-
defaultCollapsed={startsFolded}
|
|
133
|
-
collapsed={collapsed ? (collapsed[section.label] ?? startsFolded) : undefined}
|
|
134
|
-
onCollapsedChange={onCollapse ? (next) => onCollapse(section.label, next) : undefined}
|
|
135
|
-
>
|
|
136
|
-
{section.paths.map(row)}
|
|
137
|
-
</PropertyGroup>
|
|
138
|
-
))}
|
|
184
|
+
{body(resolved.group, '')}
|
|
139
185
|
</PropertyList>
|
|
140
186
|
);
|
|
141
187
|
}
|
|
142
188
|
|
|
189
|
+
/** How a section is keyed for folding: its label at the root, and its group's
|
|
190
|
+
* path in front of it anywhere else, so two groups may both have an
|
|
191
|
+
* `Advanced`. */
|
|
192
|
+
function sectionKey(section: SectionSpec): string {
|
|
193
|
+
return section.at === '' ? section.label : `${section.at}.${section.label}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
interface ControlRowProps<TC extends Record<string, unknown>> {
|
|
144
197
|
path: string;
|
|
198
|
+
leaf: PrefLeaf;
|
|
145
199
|
resolved: ResolvedConfig;
|
|
146
200
|
config: TC;
|
|
147
|
-
setConfig: (
|
|
201
|
+
setConfig: (path: string, value: unknown) => void;
|
|
148
202
|
renderers?: Record<string, ControlRenderer>;
|
|
149
203
|
pack: ControlPack;
|
|
150
204
|
layout?: PropertyRowLayout;
|
|
151
|
-
showHidden: boolean;
|
|
152
205
|
}
|
|
153
206
|
|
|
154
207
|
/** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
|
|
@@ -159,21 +212,17 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
|
|
|
159
212
|
|
|
160
213
|
function ControlRow<TC extends Record<string, unknown>>({
|
|
161
214
|
path,
|
|
215
|
+
leaf,
|
|
162
216
|
resolved,
|
|
163
217
|
config,
|
|
164
218
|
setConfig,
|
|
165
219
|
renderers,
|
|
166
220
|
pack,
|
|
167
221
|
layout,
|
|
168
|
-
showHidden,
|
|
169
222
|
}: ControlRowProps<TC>) {
|
|
170
|
-
const
|
|
171
|
-
if (!leaf || !('kind' in leaf)) return null;
|
|
172
|
-
if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
|
|
173
|
-
|
|
174
|
-
const write = (value: unknown): void => setConfig(path as keyof TC, value);
|
|
223
|
+
const write = (value: unknown): void => setConfig(path, value);
|
|
175
224
|
const fallback = extra<unknown>(leaf, 'default');
|
|
176
|
-
const value = config
|
|
225
|
+
const value = valueAtPath(config, path) ?? fallback;
|
|
177
226
|
|
|
178
227
|
// Most specific wins, and within a tier the lab's entry beats the
|
|
179
228
|
// instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
|
package/src/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ export {
|
|
|
76
76
|
createAnnotationScene,
|
|
77
77
|
createAnnotationStore,
|
|
78
78
|
fracContains,
|
|
79
|
-
|
|
79
|
+
fracEncloses,
|
|
80
80
|
fracToWorld,
|
|
81
81
|
isStale,
|
|
82
82
|
roundFrac,
|
|
@@ -145,25 +145,40 @@ export {
|
|
|
145
145
|
zoomAt,
|
|
146
146
|
} from './canvas';
|
|
147
147
|
export * from './chrome';
|
|
148
|
-
export { f } from './config/builder';
|
|
148
|
+
export { f, isConfigBranch } from './config/builder';
|
|
149
149
|
export { fromConfigFields } from './config/fromConfigField';
|
|
150
|
+
export {
|
|
151
|
+
fillConfigDefaults,
|
|
152
|
+
hasConfigPath,
|
|
153
|
+
schemaNodeAtPath,
|
|
154
|
+
valueAtPath,
|
|
155
|
+
withValueAtPath,
|
|
156
|
+
} from './config/path';
|
|
150
157
|
export { resolveConfigSchema } from './config/resolve';
|
|
151
158
|
export { applyRules, builtinRules, titleCase } from './config/rules';
|
|
152
159
|
export type {
|
|
153
160
|
Annotations,
|
|
161
|
+
BranchAnnotations,
|
|
162
|
+
BranchOptions,
|
|
163
|
+
ConfigBranch,
|
|
164
|
+
ConfigEntry,
|
|
154
165
|
ConfigNode,
|
|
155
166
|
ConfigOf,
|
|
156
167
|
ConfigOption,
|
|
168
|
+
ConfigPath,
|
|
157
169
|
ConfigRule,
|
|
158
170
|
ConfigRuleContext,
|
|
159
171
|
ConfigSchema,
|
|
172
|
+
ConfigShape,
|
|
160
173
|
ControlRenderer,
|
|
174
|
+
EntryValue,
|
|
161
175
|
InferConfig,
|
|
162
176
|
LeafPatch,
|
|
163
177
|
NodeOptions,
|
|
164
178
|
NodeValue,
|
|
165
179
|
ResolvedConfig,
|
|
166
180
|
SectionSpec,
|
|
181
|
+
ValueAtPath,
|
|
167
182
|
} from './config/types';
|
|
168
183
|
export { useConfigSchema } from './config/useConfigSchema';
|
|
169
184
|
export { isLeafVisible } from './config/visible';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InstrumentSerializers } from '../state/types';
|
|
2
|
+
import type { InstrumentList } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Each instrument's default config, keyed by name — what `createLabStore`
|
|
6
|
+
* fills a stored config's gaps from, so one saved before its schema grew a
|
|
7
|
+
* branch still loads.
|
|
8
|
+
*/
|
|
9
|
+
export function configDefaultsOf(instruments: InstrumentList): Record<string, () => unknown> {
|
|
10
|
+
const out: Record<string, () => unknown> = {};
|
|
11
|
+
for (const instrument of instruments) out[instrument.name] = () => instrument.defaultConfig();
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `serialize` / `deserialize` an instrument list declares, keyed by
|
|
17
|
+
* instrument name — what `createLabStore` needs to rebuild a trial's state
|
|
18
|
+
* from storage.
|
|
19
|
+
*
|
|
20
|
+
* An instrument declaring neither gets no entry, which is what leaves a
|
|
21
|
+
* JSON-safe state to pass through untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function serializersOf(instruments: InstrumentList): InstrumentSerializers {
|
|
24
|
+
const out: InstrumentSerializers = {};
|
|
25
|
+
for (const instrument of instruments) {
|
|
26
|
+
const { name, serialize, deserialize } = instrument;
|
|
27
|
+
if (!serialize && !deserialize) continue;
|
|
28
|
+
out[name] = {
|
|
29
|
+
...(serialize ? { serialize: (state: unknown) => serialize(state) } : {}),
|
|
30
|
+
...(deserialize
|
|
31
|
+
? { deserialize: (data: unknown, config: unknown) => deserialize(data, config) }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/src/instrument/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { AnnotationsCapability } from '../annotations/types';
|
|
3
3
|
import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
|
|
4
4
|
import type { TrialContribution } from '../chrome/types';
|
|
5
|
-
import type { ConfigSchema } from '../config/types';
|
|
5
|
+
import type { ConfigPath, ConfigSchema } from '../config/types';
|
|
6
6
|
import type { ConfigField } from '../controls/types';
|
|
7
7
|
import type { JobCapability, JobHandle } from '../job/types';
|
|
8
8
|
import type { LoupeDeclaration } from '../loupe/types';
|
|
@@ -15,7 +15,9 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
15
15
|
state: TS;
|
|
16
16
|
config: TC;
|
|
17
17
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
18
|
-
|
|
18
|
+
/** Write one config value, by dotted path — `'grid.size'` for a leaf under
|
|
19
|
+
* an `f.group`, `'cellSize'` for one at the root. */
|
|
20
|
+
setConfig: (path: ConfigPath<TC>, value: unknown) => void;
|
|
19
21
|
/** labkit persists `view` and restores it on Reset without ever reading
|
|
20
22
|
* into it. */
|
|
21
23
|
trial: TrialInfo & {
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { act, render, screen } from '@testing-library/react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
6
|
+
import { labDocumentKey } from '../state/document';
|
|
5
7
|
import { Lab } from './Lab';
|
|
6
8
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
7
9
|
|
|
@@ -135,4 +137,30 @@ describe('<Lab>', () => {
|
|
|
135
137
|
/Unknown instrument/,
|
|
136
138
|
);
|
|
137
139
|
});
|
|
140
|
+
|
|
141
|
+
// The store reads its serializers while it is being built, so `<Lab>` is
|
|
142
|
+
// the only thing positioned to collect them off the instruments. They went
|
|
143
|
+
// uncollected for months, and an instrument holding anything JSON drops
|
|
144
|
+
// lost it with no error.
|
|
145
|
+
it('gives the store the serializers its instruments declare', () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
const storage = createMemoryAdapter();
|
|
148
|
+
const mapped: Instrument = {
|
|
149
|
+
...stub,
|
|
150
|
+
name: 'Mapped',
|
|
151
|
+
initialState: () => ({ seen: new Set(['a']) }),
|
|
152
|
+
serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
|
|
153
|
+
deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
|
|
158
|
+
);
|
|
159
|
+
act(() => vi.advanceTimersByTime(500));
|
|
160
|
+
expect(storage.read(labDocumentKey('s')) ?? '').toContain('"seen":["a"]');
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
|
|
163
|
+
mountLab({ instruments: [mapped], defaultInstrument: 'Mapped', storage, storageKey: 's' });
|
|
164
|
+
expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
|
|
165
|
+
});
|
|
138
166
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useStore } from 'zustand/react';
|
|
12
12
|
import type { TrialContribution } from '../chrome/types';
|
|
13
13
|
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
14
|
+
import { configDefaultsOf, serializersOf } from '../instrument/serializers';
|
|
14
15
|
import type { InstrumentList } from '../instrument/types';
|
|
15
16
|
import { noneAdapter } from '../state/adapters';
|
|
16
17
|
import { LabStoreContext } from '../state/context';
|
|
@@ -78,7 +79,15 @@ function buildStore(
|
|
|
78
79
|
storageKey: string,
|
|
79
80
|
initialMode: LabMode,
|
|
80
81
|
): LabStore {
|
|
81
|
-
|
|
82
|
+
// Hydration reads the serializers, so they go in with the store rather than
|
|
83
|
+
// being registered onto it afterwards.
|
|
84
|
+
const store = createLabStore({
|
|
85
|
+
storageKey,
|
|
86
|
+
storage,
|
|
87
|
+
initialMode,
|
|
88
|
+
serializers: serializersOf(instruments),
|
|
89
|
+
configDefaults: configDefaultsOf(instruments),
|
|
90
|
+
});
|
|
82
91
|
if (store.getState().trials.length === 0) {
|
|
83
92
|
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
84
93
|
const record = seeded[0];
|
|
@@ -39,6 +39,14 @@ describe('LabShell', () => {
|
|
|
39
39
|
expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
// The overlays weasel-ui ships default to the nearest portal host, and
|
|
43
|
+
// `.lk-root` is where labkit's element defaults live — not the themed
|
|
44
|
+
// wrapper above it.
|
|
45
|
+
test('marks lk-root as the portal host for overlays', () => {
|
|
46
|
+
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
47
|
+
expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test('always applies lk-root class', () => {
|
|
43
51
|
const { container } = render(<LabShell title="t">x</LabShell>);
|
|
44
52
|
expect(container.querySelector('.lk-root')).not.toBeNull();
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
|
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import type { LabMode } from '../state/types';
|
|
4
4
|
import { interstellarTheme } from '../theme/interstellar';
|
|
5
|
+
import type { LabPage } from './LabSwitcher';
|
|
6
|
+
import { LabSwitcher } from './LabSwitcher';
|
|
5
7
|
import { useResolvedMode } from './useSystemMode';
|
|
6
8
|
|
|
7
9
|
/** Props for `<LabShell>`. */
|
|
@@ -14,19 +16,31 @@ export interface LabShellProps {
|
|
|
14
16
|
footer?: ReactNode;
|
|
15
17
|
/** Color mode. "auto" (default) follows prefers-color-scheme. */
|
|
16
18
|
mode?: LabMode;
|
|
19
|
+
/** The project's other labs. Given two or more, the title becomes the way
|
|
20
|
+
* to reach them; given none, it stays a plain heading. */
|
|
21
|
+
pages?: readonly LabPage[];
|
|
22
|
+
/** The path the switcher reads to mark the open page. Defaults to the
|
|
23
|
+
* current location. */
|
|
24
|
+
path?: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
20
28
|
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
21
29
|
* the trial runtime is wanted too. */
|
|
22
|
-
export function LabShell({ title, children, header, footer, mode = 'auto'
|
|
30
|
+
export function LabShell({ title, children, header, footer, mode = 'auto',
|
|
31
|
+
pages, path }: LabShellProps) {
|
|
23
32
|
const resolved = useResolvedMode(mode);
|
|
24
33
|
const outer = useThemeOptional();
|
|
25
34
|
|
|
26
35
|
const shell = (
|
|
27
|
-
|
|
36
|
+
// Overlays portal here rather than to the themed wrapper above: `.lk-root`
|
|
37
|
+
// carries the element defaults a lab's bare markup is styled by.
|
|
38
|
+
<div className="lk-root lk-shell" data-wzl-portal-host="">
|
|
28
39
|
<header className="lk-shell-header">
|
|
29
|
-
|
|
40
|
+
{pages && pages.length > 0
|
|
41
|
+
? <LabSwitcher title={title} pages={pages} path={path}
|
|
42
|
+
className="lk-shell-title" />
|
|
43
|
+
: <h1 className="lk-shell-title">{title}</h1>}
|
|
30
44
|
{header && <div className="lk-shell-header-actions">{header}</div>}
|
|
31
45
|
</header>
|
|
32
46
|
<main className="lk-shell-body">{children}</main>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
.lk-switcher {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-block;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.lk-switcher-title {
|
|
7
|
+
margin: 0;
|
|
8
|
+
font-size: var(--wzl-font-size-xl);
|
|
9
|
+
line-height: var(--wzl-line-height-tight, 1.2);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.lk-switcher-trigger {
|
|
13
|
+
display: inline-flex;
|
|
14
|
+
align-items: baseline;
|
|
15
|
+
gap: var(--wzl-space-xs);
|
|
16
|
+
// The title IS the control, so it keeps the title's type and gives up every
|
|
17
|
+
// other button affordance until it is pointed at.
|
|
18
|
+
font: inherit;
|
|
19
|
+
color: inherit;
|
|
20
|
+
background: none;
|
|
21
|
+
border: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
|
|
25
|
+
&:hover { color: var(--wzl-text-accent, var(--wzl-text)); }
|
|
26
|
+
|
|
27
|
+
&:focus-visible {
|
|
28
|
+
outline: 2px solid var(--wzl-focus-ring, currentColor);
|
|
29
|
+
outline-offset: 2px;
|
|
30
|
+
border-radius: var(--wzl-radius-sm, 2px);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.lk-switcher-caret {
|
|
35
|
+
// Sized in em so it tracks the title rather than needing a second rule per
|
|
36
|
+
// heading size.
|
|
37
|
+
width: 0.36em;
|
|
38
|
+
height: 0.36em;
|
|
39
|
+
border-right: 0.09em solid currentColor;
|
|
40
|
+
border-bottom: 0.09em solid currentColor;
|
|
41
|
+
transform: translateY(-0.18em) rotate(45deg);
|
|
42
|
+
opacity: 0.6;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.lk-switcher-trigger[aria-expanded='true'] .lk-switcher-caret {
|
|
46
|
+
transform: translateY(0.06em) rotate(-135deg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.lk-switcher-menu {
|
|
50
|
+
position: absolute;
|
|
51
|
+
top: calc(100% + var(--wzl-space-xs));
|
|
52
|
+
left: 0;
|
|
53
|
+
z-index: var(--wzl-z-popover, 60);
|
|
54
|
+
min-width: max(100%, 11rem);
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
padding: var(--wzl-space-xs);
|
|
58
|
+
background: var(--wzl-surface-raised);
|
|
59
|
+
border: 1px solid var(--wzl-border);
|
|
60
|
+
border-radius: var(--wzl-radius-md, 6px);
|
|
61
|
+
box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.lk-switcher-item {
|
|
65
|
+
padding: var(--wzl-space-xs) var(--wzl-space-sm);
|
|
66
|
+
border-radius: var(--wzl-radius-sm, 3px);
|
|
67
|
+
color: var(--wzl-text);
|
|
68
|
+
font-size: var(--wzl-font-size-md);
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
|
|
72
|
+
&:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
|
|
73
|
+
|
|
74
|
+
&:focus-visible {
|
|
75
|
+
outline: 2px solid var(--wzl-focus-ring, currentColor);
|
|
76
|
+
outline-offset: -2px;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The open page stays in the list rather than being filtered out: a menu that
|
|
81
|
+
// drops its current entry shifts every other one as you move between pages.
|
|
82
|
+
.lk-switcher-item[aria-current='page'] {
|
|
83
|
+
color: var(--wzl-text-strong, var(--wzl-text));
|
|
84
|
+
font-weight: var(--wzl-font-weight-semibold, 600);
|
|
85
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { currentPage, LabSwitcher } from './LabSwitcher';
|
|
4
|
+
import { LabShell } from './LabShell';
|
|
5
|
+
|
|
6
|
+
const PAGES = [
|
|
7
|
+
{ href: '/corpus', label: 'Wall' },
|
|
8
|
+
{ href: '/stats', label: 'Dashboard' },
|
|
9
|
+
{ href: '/ingest', label: 'Ingestion' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
|
|
13
|
+
|
|
14
|
+
describe('currentPage', () => {
|
|
15
|
+
it('matches a bare path', () => {
|
|
16
|
+
expect(currentPage('/stats', PAGES)).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('survives a query string, a trailing slash and a leftover extension', () => {
|
|
20
|
+
// A dev server maps `/stats` to `stats.html` and serves both spellings, so
|
|
21
|
+
// an old bookmark must still resolve to the same page.
|
|
22
|
+
for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
|
|
23
|
+
'/lab/stats.html#top']) {
|
|
24
|
+
expect(currentPage(path, PAGES)).toBe(1);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reports -1 for a page that is not one of them', () => {
|
|
29
|
+
expect(currentPage('/bench', PAGES)).toBe(-1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('<LabSwitcher>', () => {
|
|
34
|
+
it('shows the title and nothing else until it is opened', () => {
|
|
35
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
36
|
+
expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
|
|
37
|
+
'aria-expanded', 'false');
|
|
38
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('opens a menu of every lab, the open one included', () => {
|
|
42
|
+
// Filtering the current page out shifts every other entry as you move
|
|
43
|
+
// between pages, so it stays and is marked instead.
|
|
44
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
45
|
+
open();
|
|
46
|
+
const items = screen.getAllByRole('menuitem');
|
|
47
|
+
expect(items.map((i) => i.textContent)).toEqual(['Wall', 'Dashboard', 'Ingestion']);
|
|
48
|
+
expect(items[1]).toHaveAttribute('aria-current', 'page');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('offers real links, so cmd-click and the back button keep working', () => {
|
|
52
|
+
// These are separate documents. A <select> would fire a change event and
|
|
53
|
+
// get none of the three for free.
|
|
54
|
+
render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
|
|
55
|
+
open();
|
|
56
|
+
expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
|
|
57
|
+
expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
|
|
58
|
+
'href', '/corpus');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('closes on Escape and on a press outside it', () => {
|
|
62
|
+
render(
|
|
63
|
+
<div>
|
|
64
|
+
<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
|
|
65
|
+
<button type="button">elsewhere</button>
|
|
66
|
+
</div>);
|
|
67
|
+
open();
|
|
68
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
69
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
70
|
+
|
|
71
|
+
open();
|
|
72
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'elsewhere' }));
|
|
73
|
+
expect(screen.queryByRole('menu')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('stays a plain heading when there is nowhere to switch to', () => {
|
|
77
|
+
// A disclosure arrow promising a menu of the page you are already on is
|
|
78
|
+
// worse than no control.
|
|
79
|
+
render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
|
|
80
|
+
expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
|
|
81
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('<LabShell> with pages', () => {
|
|
86
|
+
it('turns its title into the switcher', () => {
|
|
87
|
+
render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
|
|
88
|
+
expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('leaves the title alone when no pages are given', () => {
|
|
92
|
+
render(<LabShell title="corpus stats">body</LabShell>);
|
|
93
|
+
expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole('button', { name: /corpus stats/ })).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|