@weasel-js/labkit 1.4.4 → 1.5.1
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 +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +42 -0
- package/src/config/builder.ts +68 -17
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +16 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +34 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { isPrefLeaf, type PrefGroup, type PrefLeaf } from '@weasel-js/ui';
|
|
2
|
+
import { isAuto } from './auto';
|
|
3
|
+
import { isRecord, withValueAtPath } from './path';
|
|
4
|
+
import type { ResolvedConfig } from './types';
|
|
5
|
+
|
|
6
|
+
type Resolver = (config: Record<string, unknown>) => unknown;
|
|
7
|
+
|
|
8
|
+
/** Reads a labkit-only extra off a resolved leaf. `PrefLeaf` has no field for
|
|
9
|
+
* these; they ride as extra keys and survive the resolve walk. */
|
|
10
|
+
function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
|
|
11
|
+
return (leaf as unknown as Record<string, T | undefined>)[key];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Every leaf in a resolved schema, in schema order, with its dotted path. */
|
|
15
|
+
function* leaves(group: PrefGroup, at = ''): Generator<[string, PrefLeaf]> {
|
|
16
|
+
for (const [key, child] of Object.entries(group.children)) {
|
|
17
|
+
const path = at === '' ? key : `${at}.${key}`;
|
|
18
|
+
if (isPrefLeaf(child)) yield [path, child];
|
|
19
|
+
else yield* leaves(child, path);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The dotted paths a schema declares as starting auto — every leaf that said
|
|
24
|
+
* `.initial(auto)`. This is what seeds a new trial's unpinned set. */
|
|
25
|
+
export function autoPathsOf(resolved: ResolvedConfig): string[] {
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
for (const [path, leaf] of leaves(resolved.group)) {
|
|
28
|
+
if (extra<boolean>(leaf, 'unpinned')) out.push(path);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The config an instrument reads: the stored one, with every unpinned path
|
|
35
|
+
* either computed by its resolver or removed.
|
|
36
|
+
*
|
|
37
|
+
* Resolution is demand-driven — a resolver reading another unpinned path
|
|
38
|
+
* forces that one first, whatever order the schema declared them in — so a
|
|
39
|
+
* cycle is a real cycle and not an ordering accident.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveAutoConfig<TC>(
|
|
42
|
+
resolved: ResolvedConfig,
|
|
43
|
+
config: TC,
|
|
44
|
+
autoPaths: ReadonlySet<string>,
|
|
45
|
+
): TC {
|
|
46
|
+
if (autoPaths.size === 0) return config;
|
|
47
|
+
|
|
48
|
+
const resolvers = new Map<string, Resolver | undefined>();
|
|
49
|
+
for (const [path, leaf] of leaves(resolved.group)) {
|
|
50
|
+
if (autoPaths.has(path)) resolvers.set(path, extra<Resolver>(leaf, 'autoResolve'));
|
|
51
|
+
}
|
|
52
|
+
if (resolvers.size === 0) return config;
|
|
53
|
+
|
|
54
|
+
// Drop first, so a resolver reading a still-unresolved auto path sees
|
|
55
|
+
// `undefined` rather than a stale pinned value it would silently believe.
|
|
56
|
+
let out = config as unknown as Record<string, unknown>;
|
|
57
|
+
for (const path of resolvers.keys()) out = dropAtPath(out, path);
|
|
58
|
+
|
|
59
|
+
const inFlight = new Set<string>();
|
|
60
|
+
const done = new Set<string>();
|
|
61
|
+
|
|
62
|
+
const need = (path: string): void => {
|
|
63
|
+
if (done.has(path)) return;
|
|
64
|
+
if (inFlight.has(path)) {
|
|
65
|
+
throw new Error(`[labkit] auto resolver cycle at "${path}"`);
|
|
66
|
+
}
|
|
67
|
+
const resolve = resolvers.get(path);
|
|
68
|
+
if (!resolve) {
|
|
69
|
+
done.add(path);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
inFlight.add(path);
|
|
73
|
+
try {
|
|
74
|
+
// Read `out` only after the resolver has run: it forces its own
|
|
75
|
+
// dependencies, and each of those replaced `out`.
|
|
76
|
+
const value = resolve(demand(() => out, resolvers, need));
|
|
77
|
+
out = withValueAtPath(out, path, value);
|
|
78
|
+
} finally {
|
|
79
|
+
inFlight.delete(path);
|
|
80
|
+
}
|
|
81
|
+
done.add(path);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const path of resolvers.keys()) need(path);
|
|
85
|
+
return out as unknown as TC;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The config a resolver reads. A top-level key that is itself unpinned is
|
|
90
|
+
* forced before it is handed back, which is what makes declaration order
|
|
91
|
+
* irrelevant and turns a genuine loop into a named error.
|
|
92
|
+
*
|
|
93
|
+
* Only top-level keys are trapped: a resolver reaching `c.grid.size` gets the
|
|
94
|
+
* already-dropped branch, and a nested auto path it depends on resolves by
|
|
95
|
+
* schema order alone.
|
|
96
|
+
*/
|
|
97
|
+
function demand(
|
|
98
|
+
read: () => Record<string, unknown>,
|
|
99
|
+
resolvers: ReadonlyMap<string, Resolver | undefined>,
|
|
100
|
+
need: (path: string) => void,
|
|
101
|
+
): Record<string, unknown> {
|
|
102
|
+
return new Proxy(read(), {
|
|
103
|
+
get(_target, key) {
|
|
104
|
+
if (typeof key === 'string' && resolvers.has(key)) need(key);
|
|
105
|
+
return Reflect.get(read(), key);
|
|
106
|
+
},
|
|
107
|
+
has(_target, key) {
|
|
108
|
+
return Reflect.has(read(), key);
|
|
109
|
+
},
|
|
110
|
+
ownKeys() {
|
|
111
|
+
return Reflect.ownKeys(read());
|
|
112
|
+
},
|
|
113
|
+
getOwnPropertyDescriptor(_target, key) {
|
|
114
|
+
return Reflect.getOwnPropertyDescriptor(read(), key);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A copy of `config` with the dotted path removed, copying every record on
|
|
120
|
+
* the way down. A path that is not there comes back unchanged. */
|
|
121
|
+
function dropAtPath(config: Record<string, unknown>, path: string): Record<string, unknown> {
|
|
122
|
+
const [head, ...rest] = path.split('.');
|
|
123
|
+
if (head === undefined || !(head in config)) return config;
|
|
124
|
+
if (rest.length === 0) {
|
|
125
|
+
const { [head]: _dropped, ...kept } = config;
|
|
126
|
+
return kept;
|
|
127
|
+
}
|
|
128
|
+
const child = config[head];
|
|
129
|
+
if (!isRecord(child)) return config;
|
|
130
|
+
return { ...config, [head]: dropAtPath(child, rest.join('.')) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What writing `value` at `path` makes of a trial's raw config and its set of
|
|
135
|
+
* unpinned paths. `auto` pins nothing and joins the set; any other value is
|
|
136
|
+
* written and leaves it. Both the store and the chrome go through this, so
|
|
137
|
+
* they cannot disagree about what a write meant.
|
|
138
|
+
*/
|
|
139
|
+
export function applyConfigWrite<TC>(
|
|
140
|
+
config: TC,
|
|
141
|
+
autoPaths: readonly string[],
|
|
142
|
+
path: string,
|
|
143
|
+
value: unknown,
|
|
144
|
+
): { config: TC; autoPaths: readonly string[] } {
|
|
145
|
+
if (isAuto(value)) {
|
|
146
|
+
if (autoPaths.includes(path)) return { config, autoPaths };
|
|
147
|
+
return { config, autoPaths: [...autoPaths, path] };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
config: withValueAtPath(config, path, value),
|
|
151
|
+
autoPaths: autoPaths.includes(path) ? autoPaths.filter((p) => p !== path) : autoPaths,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { auto } from './auto';
|
|
2
3
|
import { f } from './builder';
|
|
3
4
|
import type { ConfigOf, ConfigPath, ValueAtPath } from './types';
|
|
4
5
|
|
|
@@ -35,6 +36,10 @@ describe('builder', () => {
|
|
|
35
36
|
expect(n.annotations.suffix).toBe('px');
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
it('annotates a number with a display format', () => {
|
|
40
|
+
expect(f.number(0).format('compact').annotations.format).toBe('compact');
|
|
41
|
+
});
|
|
42
|
+
|
|
38
43
|
it('keeps a custom node kind across a chain', () => {
|
|
39
44
|
const n = f.custom('vector2', { x: 0 }).label('Offset');
|
|
40
45
|
expect(n.kind).toBe('vector2');
|
|
@@ -150,3 +155,40 @@ describe('builder / nested types', () => {
|
|
|
150
155
|
expect([size, path, value]).toEqual([20, 'grid.size', 40]);
|
|
151
156
|
});
|
|
152
157
|
});
|
|
158
|
+
|
|
159
|
+
describe('auto on a node', () => {
|
|
160
|
+
it('attaches a resolver without changing the default', () => {
|
|
161
|
+
const n = f.number(12).auto((c) => (c.width as number) / 24);
|
|
162
|
+
expect(n.default).toBe(12);
|
|
163
|
+
expect(n.options.autoResolve?.({ width: 432 })).toBe(18);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('.initial(auto) marks the node as starting unpinned', () => {
|
|
167
|
+
expect(f.number(3).initial(auto).options.unpinned).toBe(true);
|
|
168
|
+
expect(f.number(3).options.unpinned).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('.manual() opts the node out of ever being auto', () => {
|
|
172
|
+
expect(f.number(1).manual().options.manual).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('clones rather than mutating, like every other chaining method', () => {
|
|
176
|
+
const base = f.number(3);
|
|
177
|
+
const unpinned = base.initial(auto);
|
|
178
|
+
expect(base.options.unpinned).toBeUndefined();
|
|
179
|
+
expect(unpinned).not.toBe(base);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('keeps the subclass, so kind-specific methods still chain after', () => {
|
|
183
|
+
const n = f.number(3).initial(auto).range(0, 10).suffix('px');
|
|
184
|
+
expect(n.annotations.min).toBe(0);
|
|
185
|
+
expect(n.annotations.suffix).toBe('px');
|
|
186
|
+
expect(n.options.unpinned).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects anything but the sentinel, for a caller TypeScript is not checking', () => {
|
|
190
|
+
const n = f.number(3) as unknown as { initial: (value: unknown) => unknown };
|
|
191
|
+
expect(() => n.initial(5)).toThrow(/takes `auto`/);
|
|
192
|
+
expect(() => n.initial(Symbol('auto'))).toThrow(/takes `auto`/);
|
|
193
|
+
});
|
|
194
|
+
});
|
package/src/config/builder.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { PrefLeaf } from '@weasel-js/ui';
|
|
1
|
+
import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
|
|
2
|
+
import { type Auto, isAuto } from './auto';
|
|
2
3
|
import type {
|
|
3
4
|
Annotations,
|
|
4
5
|
BranchAnnotations,
|
|
@@ -12,6 +13,7 @@ import type {
|
|
|
12
13
|
ControlRenderer,
|
|
13
14
|
InferConfig,
|
|
14
15
|
NodeOptions,
|
|
16
|
+
SectionOption,
|
|
15
17
|
} from './types';
|
|
16
18
|
|
|
17
19
|
/** Whether a schema entry is a branch rather than a leaf. */
|
|
@@ -21,7 +23,7 @@ export function isConfigBranch(entry: ConfigEntry): entry is ConfigBranch {
|
|
|
21
23
|
|
|
22
24
|
/** Shared chaining surface. Every method clones, so a node can be reused as a
|
|
23
25
|
* base for several leaves without one bleeding into the next. */
|
|
24
|
-
abstract class BaseNode<T> implements ConfigNode<T> {
|
|
26
|
+
export abstract class BaseNode<T> implements ConfigNode<T> {
|
|
25
27
|
abstract readonly kind: string | null;
|
|
26
28
|
|
|
27
29
|
// `default` is a reserved word in a parameter position, so the field is
|
|
@@ -72,8 +74,9 @@ abstract class BaseNode<T> implements ConfigNode<T> {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/** Render under a named section heading. `collapsed` opens the section
|
|
75
|
-
* folded — say
|
|
76
|
-
section
|
|
77
|
+
* folded, and `layout` and `pack` lay out its rows — say each on any one of
|
|
78
|
+
* the section's leaves. */
|
|
79
|
+
section(label: string, opts: Omit<SectionOption, 'label'> = {}): this {
|
|
77
80
|
return this.opt({ section: { label, ...opts } });
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -87,9 +90,33 @@ abstract class BaseNode<T> implements ConfigNode<T> {
|
|
|
87
90
|
render(renderer: ControlRenderer): this {
|
|
88
91
|
return this.opt({ render: renderer });
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute this leaf's value while it is auto, instead of leaving it
|
|
96
|
+
* `undefined`. The resolver is given the config with every other auto path
|
|
97
|
+
* already resolved.
|
|
98
|
+
*/
|
|
99
|
+
auto(resolve: (config: Record<string, unknown>) => T): this {
|
|
100
|
+
return this.opt({ autoResolve: resolve as (c: Record<string, unknown>) => unknown });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start this leaf auto rather than pinned at its default. Takes `auto` and
|
|
105
|
+
* nothing else — the constructor argument already declares the value, and a
|
|
106
|
+
* second way to say it would fight with the first.
|
|
107
|
+
*/
|
|
108
|
+
initial(value: Auto): this {
|
|
109
|
+
if (!isAuto(value)) throw new Error('[labkit] .initial() takes `auto` and nothing else');
|
|
110
|
+
return this.opt({ unpinned: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Never auto. The row takes no pin dot and ignores the gesture. */
|
|
114
|
+
manual(): this {
|
|
115
|
+
return this.opt({ manual: true });
|
|
116
|
+
}
|
|
90
117
|
}
|
|
91
118
|
|
|
92
|
-
class NumberNode extends BaseNode<number> {
|
|
119
|
+
export class NumberNode extends BaseNode<number> {
|
|
93
120
|
readonly kind = 'number';
|
|
94
121
|
|
|
95
122
|
/** Bound the value. A number with both bounds renders as a slider. */
|
|
@@ -107,6 +134,22 @@ class NumberNode extends BaseNode<number> {
|
|
|
107
134
|
return this.ann({ suffix });
|
|
108
135
|
}
|
|
109
136
|
|
|
137
|
+
/** Show the value in a named format — `'compact'` reads `2.0M`. Presentation
|
|
138
|
+
* only: the stored value stays a plain number, and a typed `2.5m` reads back. */
|
|
139
|
+
format(format: PrefNumberFormat): this {
|
|
140
|
+
return this.ann({ format });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Store the value in one unit and edit it in another — radians stored,
|
|
145
|
+
* degrees typed. `min`, `max` and `step` are declared in the stored unit
|
|
146
|
+
* alongside the value, and convert with it. `prefUnit` builds one from a
|
|
147
|
+
* `UnitSystem`.
|
|
148
|
+
*/
|
|
149
|
+
unit(unit: PrefNumberUnit): this {
|
|
150
|
+
return this.ann({ unit });
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
/** Force a slider even without both bounds. */
|
|
111
154
|
slider(): this {
|
|
112
155
|
return this.ann({ control: 'slider' });
|
|
@@ -118,17 +161,16 @@ class NumberNode extends BaseNode<number> {
|
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
|
|
121
|
-
class BooleanNode extends BaseNode<boolean> {
|
|
164
|
+
export class BooleanNode extends BaseNode<boolean> {
|
|
122
165
|
readonly kind = 'boolean';
|
|
123
166
|
|
|
124
|
-
/** Ask for a switch
|
|
125
|
-
* `PrefsForm` honors the distinction. */
|
|
167
|
+
/** Ask for a switch rather than a checkbox. */
|
|
126
168
|
toggle(): this {
|
|
127
169
|
return this.ann({ control: 'switch' });
|
|
128
170
|
}
|
|
129
171
|
}
|
|
130
172
|
|
|
131
|
-
class StringNode extends BaseNode<string> {
|
|
173
|
+
export class StringNode extends BaseNode<string> {
|
|
132
174
|
readonly kind = 'string';
|
|
133
175
|
|
|
134
176
|
placeholder(placeholder: string): this {
|
|
@@ -145,11 +187,17 @@ class StringNode extends BaseNode<string> {
|
|
|
145
187
|
}
|
|
146
188
|
}
|
|
147
189
|
|
|
148
|
-
class ColorNode extends BaseNode<string> {
|
|
190
|
+
export class ColorNode extends BaseNode<string> {
|
|
149
191
|
readonly kind = 'color';
|
|
192
|
+
|
|
193
|
+
/** The value carries alpha as `#rrggbbaa`, and the row gets an opacity
|
|
194
|
+
* track beside the swatch. */
|
|
195
|
+
alpha(): this {
|
|
196
|
+
return this.ann({ alpha: true });
|
|
197
|
+
}
|
|
150
198
|
}
|
|
151
199
|
|
|
152
|
-
class EnumNode<T extends string> extends BaseNode<T> {
|
|
200
|
+
export class EnumNode<T extends string> extends BaseNode<T> {
|
|
153
201
|
readonly kind = 'enum';
|
|
154
202
|
|
|
155
203
|
/** Render as a segmented control rather than a select. */
|
|
@@ -165,12 +213,12 @@ class EnumNode<T extends string> extends BaseNode<T> {
|
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
/** A leaf whose kind the rule chain decides. */
|
|
168
|
-
class ValueNode<T> extends BaseNode<T> {
|
|
216
|
+
export class ValueNode<T> extends BaseNode<T> {
|
|
169
217
|
readonly kind = null;
|
|
170
218
|
}
|
|
171
219
|
|
|
172
220
|
/** A leaf of a kind labkit does not ship a row for. */
|
|
173
|
-
class CustomNode<T> extends BaseNode<T> {
|
|
221
|
+
export class CustomNode<T> extends BaseNode<T> {
|
|
174
222
|
constructor(
|
|
175
223
|
readonly kind: string,
|
|
176
224
|
defaultValue: T,
|
|
@@ -190,7 +238,7 @@ class CustomNode<T> extends BaseNode<T> {
|
|
|
190
238
|
* one is read and written at a dotted path. Distinct from `.section()`, which
|
|
191
239
|
* puts a heading over sibling leaves and leaves their paths alone.
|
|
192
240
|
*/
|
|
193
|
-
class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
|
|
241
|
+
export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
|
|
194
242
|
constructor(
|
|
195
243
|
readonly children: S,
|
|
196
244
|
readonly annotations: Readonly<BranchAnnotations> = {},
|
|
@@ -214,7 +262,7 @@ class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
|
|
|
214
262
|
}
|
|
215
263
|
|
|
216
264
|
/** Render this whole group under a named section heading. */
|
|
217
|
-
section(label: string, opts:
|
|
265
|
+
section(label: string, opts: Omit<SectionOption, 'label'> = {}): GroupNode<S> {
|
|
218
266
|
return this.with(this.annotations, { ...this.options, section: { label, ...opts } });
|
|
219
267
|
}
|
|
220
268
|
|
|
@@ -276,8 +324,11 @@ export const f = {
|
|
|
276
324
|
group: <const S extends ConfigShape>(children: S): GroupNode<S> => new GroupNode(children),
|
|
277
325
|
|
|
278
326
|
/** A leaf of a kind a lab supplies the control for, through `controls`. */
|
|
279
|
-
custom: <T>(
|
|
280
|
-
|
|
327
|
+
custom: <T>(
|
|
328
|
+
kind: string,
|
|
329
|
+
def: T,
|
|
330
|
+
validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[],
|
|
331
|
+
): CustomNode<T> => new CustomNode(kind, def, {}, validate ? { validate } : {}),
|
|
281
332
|
|
|
282
333
|
/** Collect leaves and groups into an instrument's config. */
|
|
283
334
|
schema<S extends ConfigShape>(nodes: S): ConfigSchema<InferConfig<S>> {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { f } from './builder';
|
|
2
|
+
|
|
3
|
+
export const flat = f.schema({ n: f.number(1).range(0, 4), s: f.string('x').label('S') });
|
|
4
|
+
|
|
5
|
+
export const grouped = f.schema({
|
|
6
|
+
g: f.group({ on: f.boolean(true).toggle(), e: f.enum('a', ['a', 'b']).radio() }).label('G'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const node = f.number(1).slider();
|
|
10
|
+
|
|
11
|
+
export const group = f.group({ c: f.color('#fff'), v: f.value(1), k: f.custom('k', 'x') });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const root = resolve(__dirname, '../../../..');
|
|
6
|
+
const fixture = resolve(__dirname, 'declarationEmit.fixture.ts');
|
|
7
|
+
|
|
8
|
+
/** Emits declarations for one file under the root tsconfig and returns every diagnostic, formatted. */
|
|
9
|
+
function declarationDiagnostics(file: string): string[] {
|
|
10
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(
|
|
11
|
+
resolve(root, 'tsconfig.json'),
|
|
12
|
+
{},
|
|
13
|
+
{
|
|
14
|
+
...ts.sys,
|
|
15
|
+
onUnRecoverableConfigFileDiagnostic: (d) => {
|
|
16
|
+
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
if (!parsed) throw new Error('could not read the root tsconfig');
|
|
21
|
+
const options: ts.CompilerOptions = {
|
|
22
|
+
...parsed.options,
|
|
23
|
+
noEmit: false,
|
|
24
|
+
declaration: true,
|
|
25
|
+
emitDeclarationOnly: true,
|
|
26
|
+
declarationMap: false,
|
|
27
|
+
sourceMap: false,
|
|
28
|
+
incremental: false,
|
|
29
|
+
tsBuildInfoFile: undefined,
|
|
30
|
+
types: [],
|
|
31
|
+
};
|
|
32
|
+
const program = ts.createProgram({ rootNames: [file], options });
|
|
33
|
+
const source = program.getSourceFile(file);
|
|
34
|
+
if (!source) throw new Error(`${file} is not in the program`);
|
|
35
|
+
const emitted = program.emit(source, () => {}, undefined, true);
|
|
36
|
+
const diagnostics = [
|
|
37
|
+
...program.getSyntacticDiagnostics(source),
|
|
38
|
+
...program.getSemanticDiagnostics(source),
|
|
39
|
+
...emitted.diagnostics,
|
|
40
|
+
];
|
|
41
|
+
return diagnostics.map((d) => {
|
|
42
|
+
const text = ts.flattenDiagnosticMessageText(d.messageText, '\n');
|
|
43
|
+
return d.file ? `${d.file.fileName}: TS${d.code} ${text}` : `TS${d.code} ${text}`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('config schema declarations', () => {
|
|
48
|
+
it('emit for exported schemas, groups and nodes', () => {
|
|
49
|
+
expect(declarationDiagnostics(fixture)).toEqual([]);
|
|
50
|
+
}, 60_000);
|
|
51
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const IMPORT =
|
|
6
|
+
/^\s*(?:import|export)\s+(?!type\b)[^'"]*from\s+['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gm;
|
|
7
|
+
|
|
8
|
+
const isFile = (path: string) => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
9
|
+
|
|
10
|
+
function walk(file: string, seen = new Set<string>()): Set<string> {
|
|
11
|
+
if (seen.has(file)) return seen;
|
|
12
|
+
seen.add(file);
|
|
13
|
+
const source = readFileSync(file, 'utf8');
|
|
14
|
+
for (const match of source.matchAll(IMPORT)) {
|
|
15
|
+
const spec = match[1] ?? match[2] ?? match[3];
|
|
16
|
+
if (!spec?.startsWith('.')) continue;
|
|
17
|
+
const base = resolve(dirname(file), spec.replace(/\.js$/, ''));
|
|
18
|
+
const candidates = [
|
|
19
|
+
resolve(dirname(file), spec),
|
|
20
|
+
`${base}.ts`,
|
|
21
|
+
`${base}.tsx`,
|
|
22
|
+
resolve(base, 'index.ts'),
|
|
23
|
+
resolve(base, 'index.tsx'),
|
|
24
|
+
];
|
|
25
|
+
const hit = candidates.find(isFile);
|
|
26
|
+
// An import the walk cannot follow would let a stylesheet behind it pass unseen.
|
|
27
|
+
if (!hit) throw new Error(`${file}: cannot resolve ${spec}`);
|
|
28
|
+
walk(hit, seen);
|
|
29
|
+
}
|
|
30
|
+
return seen;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('@weasel-js/labkit/config', () => {
|
|
34
|
+
it('reaches no stylesheet', () => {
|
|
35
|
+
const files = [...walk(resolve(__dirname, 'index.ts'))];
|
|
36
|
+
expect(files.filter((f) => /\.(less|css)$/.test(f))).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is published as an entry', async () => {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
|
|
41
|
+
expect(pkg.exports['./config']).toEqual({
|
|
42
|
+
types: './dist/config/index.d.ts',
|
|
43
|
+
import: './dist/config/index.js',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { type Auto, auto, isAuto } from './auto';
|
|
2
|
+
export { autoPathsOf, resolveAutoConfig } from './autoConfig';
|
|
3
|
+
export {
|
|
4
|
+
BaseNode,
|
|
5
|
+
BooleanNode,
|
|
6
|
+
ColorNode,
|
|
7
|
+
CustomNode,
|
|
8
|
+
EnumNode,
|
|
9
|
+
f,
|
|
10
|
+
GroupNode,
|
|
11
|
+
isConfigBranch,
|
|
12
|
+
NumberNode,
|
|
13
|
+
StringNode,
|
|
14
|
+
ValueNode,
|
|
15
|
+
} from './builder';
|
|
2
16
|
export { fromConfigFields } from './fromConfigField';
|
|
3
17
|
export {
|
|
4
18
|
fillConfigDefaults,
|
|
@@ -34,4 +48,5 @@ export type {
|
|
|
34
48
|
ValueAtPath,
|
|
35
49
|
} from './types';
|
|
36
50
|
export { useConfigSchema } from './useConfigSchema';
|
|
51
|
+
export { useResolvedConfig } from './useResolvedConfig';
|
|
37
52
|
export { isLeafVisible } from './visible';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { f, GroupNode, NumberNode } from './index';
|
|
3
|
+
|
|
4
|
+
describe('config node classes', () => {
|
|
5
|
+
it('are exported as values', () => {
|
|
6
|
+
expect(typeof NumberNode).toBe('function');
|
|
7
|
+
expect(typeof GroupNode).toBe('function');
|
|
8
|
+
expect(f.number(1)).toBeInstanceOf(NumberNode);
|
|
9
|
+
expect(f.group({ n: f.number(1) })).toBeInstanceOf(GroupNode);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('can be extended', () => {
|
|
13
|
+
class Mine extends NumberNode {}
|
|
14
|
+
const node = new Mine(2);
|
|
15
|
+
expect(node).toBeInstanceOf(NumberNode);
|
|
16
|
+
expect(node.default).toBe(2);
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/config/path.ts
CHANGED
|
@@ -6,8 +6,13 @@ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
|
|
|
6
6
|
* reached by exactly the same call.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** A plain object a config tree can be walked into. An array, a `Date`, a
|
|
10
|
+
* `Map` or a class instance is a leaf value: a walk that descended into one
|
|
11
|
+
* would rebuild it as a bare `{}`. */
|
|
12
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
14
|
+
const proto = Object.getPrototypeOf(value);
|
|
15
|
+
return proto === Object.prototype || proto === null;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
/** The value at a dotted path. `undefined` when a segment is missing or the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PrefGroup } from '@weasel-js/ui';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { auto } from './auto';
|
|
3
4
|
import { f } from './builder';
|
|
4
5
|
import { resolveConfigSchema } from './resolve';
|
|
5
6
|
import { titleCase } from './rules';
|
|
@@ -207,4 +208,28 @@ describe('resolveConfigSchema / nested groups', () => {
|
|
|
207
208
|
);
|
|
208
209
|
expect(r.sections).toEqual([{ at: '', label: 'Advanced', paths: ['grid'] }]);
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
it('rejects a leaf that is both manual and starts auto', () => {
|
|
213
|
+
const schema = f.schema({ seed: f.number(1).manual().initial(auto) });
|
|
214
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/seed/);
|
|
215
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/manual/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('carries autoResolve, unpinned and manual onto the resolved leaf', () => {
|
|
219
|
+
const r = resolveConfigSchema(
|
|
220
|
+
f.schema({
|
|
221
|
+
gap: f.number(12).auto(() => 18),
|
|
222
|
+
cols: f.number(3).initial(auto),
|
|
223
|
+
seed: f.number(1).manual(),
|
|
224
|
+
plain: f.number(0),
|
|
225
|
+
}),
|
|
226
|
+
[],
|
|
227
|
+
);
|
|
228
|
+
expect(typeof leafAt(r, 'gap').autoResolve).toBe('function');
|
|
229
|
+
expect(leafAt(r, 'cols').unpinned).toBe(true);
|
|
230
|
+
expect(leafAt(r, 'seed').manual).toBe(true);
|
|
231
|
+
expect(leafAt(r, 'plain').autoResolve).toBeUndefined();
|
|
232
|
+
expect(leafAt(r, 'plain').unpinned).toBeUndefined();
|
|
233
|
+
expect(leafAt(r, 'plain').manual).toBeUndefined();
|
|
234
|
+
});
|
|
210
235
|
});
|
package/src/config/resolve.ts
CHANGED
|
@@ -76,6 +76,8 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
|
|
|
76
76
|
if (section.collapsed !== undefined) {
|
|
77
77
|
spec.collapsed = (spec.collapsed ?? false) || section.collapsed;
|
|
78
78
|
}
|
|
79
|
+
if (section.layout !== undefined && spec.layout === undefined) spec.layout = section.layout;
|
|
80
|
+
if (section.pack !== undefined && spec.pack === undefined) spec.pack = section.pack;
|
|
79
81
|
}
|
|
80
82
|
if (predicate) sink.showIf.set(path, predicate);
|
|
81
83
|
if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
|
|
@@ -101,10 +103,24 @@ function resolveEntry(
|
|
|
101
103
|
? group
|
|
102
104
|
: { ...group, description: entry.annotations.description };
|
|
103
105
|
}
|
|
106
|
+
if (entry.options.manual && entry.options.unpinned) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[labkit] "${path}" is both .manual() and .initial(auto) — a field that can never be auto cannot start auto`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
104
111
|
const seed: LeafPatch = defined({
|
|
105
112
|
...entry.annotations,
|
|
106
113
|
...(entry.kind === null ? {} : { kind: entry.kind }),
|
|
107
114
|
});
|
|
108
115
|
const patch = applyRules(seed, { key, path, default: entry.default }, sink.chain);
|
|
109
|
-
|
|
116
|
+
const { autoResolve, unpinned, manual } = entry.options;
|
|
117
|
+
return {
|
|
118
|
+
...patch,
|
|
119
|
+
default: entry.default,
|
|
120
|
+
// Extra keys survive onto the leaf at runtime; `ControlPanel` reads them
|
|
121
|
+
// back with its `extra<T>` helper, the same as `min`, `step` and the rest.
|
|
122
|
+
...(autoResolve === undefined ? {} : { autoResolve }),
|
|
123
|
+
...(unpinned === undefined ? {} : { unpinned }),
|
|
124
|
+
...(manual === undefined ? {} : { manual }),
|
|
125
|
+
} as PrefLeaf;
|
|
110
126
|
}
|