@weasel-js/labkit 1.6.1 → 1.7.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.
Files changed (162) hide show
  1. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
  3. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  4. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  5. package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
  6. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
  7. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  8. package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
  9. package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
  10. package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
  11. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
  12. package/dist/canvas/index.d.ts +6 -6
  13. package/dist/canvas/index.js +2 -2
  14. package/dist/chrome/index.d.ts +5 -5
  15. package/dist/chrome/index.js +8 -8
  16. package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
  17. package/dist/chunk-275JRRVD.js.map +1 -0
  18. package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
  19. package/dist/chunk-4LJSC7MB.js.map +1 -0
  20. package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
  21. package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  22. package/dist/chunk-CZZPXUOU.js +3 -0
  23. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  24. package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
  25. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  26. package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
  27. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  28. package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
  29. package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
  30. package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
  31. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  32. package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
  33. package/dist/chunk-J7OWWTCW.js.map +1 -0
  34. package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
  35. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  36. package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
  37. package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
  38. package/dist/chunk-MIM5R4ZT.js +14972 -0
  39. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  40. package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
  41. package/dist/chunk-T5IBSPAM.js.map +1 -0
  42. package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
  43. package/dist/chunk-UNAZSLWE.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
  45. package/dist/chunk-Y23G2VZL.js.map +1 -0
  46. package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
  47. package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
  49. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  50. package/dist/config/index.d.ts +16 -9
  51. package/dist/config/index.js +4 -4
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +3 -3
  55. package/dist/index.d.ts +263 -24
  56. package/dist/index.js +376 -237
  57. package/dist/index.js.map +1 -1
  58. package/dist/layers/index.d.ts +4 -4
  59. package/dist/layers/index.js +2 -2
  60. package/dist/loupe/index.d.ts +4 -4
  61. package/dist/loupe/index.js +2 -2
  62. package/dist/overview/index.d.ts +2 -2
  63. package/dist/overview/index.js +9 -8
  64. package/dist/overview/index.js.map +1 -1
  65. package/dist/passthrough/weasel-ui.d.ts +208 -38
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +3 -178
  68. package/dist/primitives/index.js +6 -6
  69. package/dist/state/index.d.ts +5 -5
  70. package/dist/state/index.js +4 -4
  71. package/dist/styles.css +91 -83
  72. package/dist/undo/index.d.ts +3 -3
  73. package/package.json +13 -12
  74. package/src/annotations/AnnotationOverlay.tsx +2 -2
  75. package/src/annotations/Annotations.less +0 -10
  76. package/src/annotations/MarkList.tsx +4 -8
  77. package/src/annotations/paint.test.ts +3 -14
  78. package/src/annotations/paint.ts +3 -19
  79. package/src/annotations/svgNodes.test.ts +6 -9
  80. package/src/annotations/svgNodes.ts +3 -3
  81. package/src/canvas/AGENTS.md +1 -0
  82. package/src/canvas/CameraInput.tsx +44 -33
  83. package/src/canvas/CanvasStack.tsx +6 -1
  84. package/src/canvas/LinkedCursor.less +1 -1
  85. package/src/canvas/Stage.test.tsx +33 -4
  86. package/src/canvas/Stage.tsx +6 -1
  87. package/src/canvas/cameraRegistry.test.ts +55 -0
  88. package/src/canvas/cameraRegistry.ts +86 -0
  89. package/src/canvas/cameraZoom.test.ts +46 -0
  90. package/src/canvas/cameraZoom.ts +24 -0
  91. package/src/canvas/index.ts +14 -2
  92. package/src/canvas/sharedScope.test.tsx +94 -0
  93. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  94. package/src/chrome/regions/PaletteRegion.less +0 -7
  95. package/src/chrome/regions/SidebarRegion.less +12 -2
  96. package/src/config/builder.test.ts +2 -1
  97. package/src/config/builder.ts +12 -5
  98. package/src/config/fromConfigField.test.ts +14 -0
  99. package/src/config/fromConfigField.ts +18 -4
  100. package/src/config/path.ts +2 -5
  101. package/src/config/resolve.ts +4 -0
  102. package/src/config/types.ts +13 -9
  103. package/src/controls/ControlPanel.less +1 -1
  104. package/src/controls/ControlPanel.stories.tsx +3 -2
  105. package/src/controls/ControlPanel.test.tsx +64 -11
  106. package/src/controls/ControlPanel.tsx +52 -2
  107. package/src/index.ts +15 -1
  108. package/src/lab/Lab.tsx +83 -72
  109. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  110. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  111. package/src/lab/LabFullChrome.stories.tsx +1 -0
  112. package/src/lab/LabHeader.test.tsx +24 -14
  113. package/src/lab/LabHeader.tsx +11 -12
  114. package/src/lab/LabShell.less +8 -17
  115. package/src/lab/LabSwitcher.less +6 -6
  116. package/src/lab/LabZoom.test.tsx +223 -0
  117. package/src/lab/LabZoom.tsx +170 -0
  118. package/src/lab/index.ts +1 -0
  119. package/src/loupe/LoupeGestures.tsx +1 -1
  120. package/src/overview/TrialOverview.tsx +7 -5
  121. package/src/passthrough/weasel-ui.test.ts +2 -0
  122. package/src/passthrough/weasel-ui.ts +16 -4
  123. package/src/primitives/FloatingPanel.test.tsx +11 -0
  124. package/src/primitives/FloatingPanel.tsx +2 -5
  125. package/src/primitives/JobProgress.less +1 -1
  126. package/src/primitives/Legend.stories.tsx +1 -0
  127. package/src/primitives/Readout.browser.test.tsx +137 -0
  128. package/src/primitives/Readout.less +12 -0
  129. package/src/primitives/Readout.stories.tsx +58 -0
  130. package/src/primitives/Readout.test.tsx +93 -0
  131. package/src/primitives/Readout.tsx +56 -0
  132. package/src/primitives/index.ts +2 -0
  133. package/src/specimen/Specimen.stories.tsx +1 -0
  134. package/src/specimen/Specimen.tsx +2 -2
  135. package/src/state/Persistence.browser.test.tsx +47 -0
  136. package/src/state/store.test.ts +5 -1
  137. package/src/state/usePersistedState.test.tsx +1 -1
  138. package/src/styles.less +1 -0
  139. package/src/theme/Interstellar.stories.tsx +3 -1
  140. package/src/theme/base.less +21 -47
  141. package/src/theme/interstellar.test.ts +1 -1
  142. package/src/theme/interstellar.theme.json +10 -3
  143. package/src/trial/Trial.less +0 -13
  144. package/src/trial/Trial.tsx +12 -6
  145. package/src/trial/TrialChrome.tsx +21 -3
  146. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  147. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  148. package/dist/chunk-4TT3PNPR.js.map +0 -1
  149. package/dist/chunk-6FLXL3I7.js.map +0 -1
  150. package/dist/chunk-ASOK76ZH.js.map +0 -1
  151. package/dist/chunk-CGHF6RZV.js.map +0 -1
  152. package/dist/chunk-DWMSB4CA.js.map +0 -1
  153. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  154. package/dist/chunk-KHK5FGER.js.map +0 -1
  155. package/dist/chunk-NEXR37YJ.js.map +0 -1
  156. package/dist/chunk-O6FFFOHS.js.map +0 -1
  157. package/dist/chunk-QGMOA2YE.js.map +0 -1
  158. package/dist/chunk-SBXNQW4G.js +0 -3
  159. package/dist/chunk-TJ77A4A3.js.map +0 -1
  160. package/dist/chunk-TYN6MLXX.js +0 -13864
  161. package/dist/chunk-TYN6MLXX.js.map +0 -1
  162. package/src/state/Persistence.stories.tsx +0 -75
@@ -0,0 +1,86 @@
1
+ import { useActionsRegistry } from '@weasel-js/core';
2
+ import { createContext, useContext, useEffect } from 'react';
3
+ import type { CameraView } from './CameraInput';
4
+
5
+ /**
6
+ * Cameras by key, for chrome that acts on a camera it does not contain — the
7
+ * lab's zoom controls, which drive whichever trial has the focus.
8
+ */
9
+ export interface CameraRegistry {
10
+ /** Publish `camera` under `key` and return its release. Registrants for one
11
+ * key stack, newest live. */
12
+ register(key: string, camera: CameraView): () => void;
13
+ get(key: string): CameraView | null;
14
+ /** Fires after any register or release. Shaped for `useSyncExternalStore`. */
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+
18
+ export function createCameraRegistry(): CameraRegistry {
19
+ const stacks = new Map<string, CameraView[]>();
20
+ const listeners = new Set<() => void>();
21
+ const notify = (): void => {
22
+ for (const l of listeners) l();
23
+ };
24
+ return {
25
+ register(key, camera) {
26
+ const stack = stacks.get(key) ?? [];
27
+ stack.push(camera);
28
+ stacks.set(key, stack);
29
+ notify();
30
+ let released = false;
31
+ return () => {
32
+ if (released) return;
33
+ released = true;
34
+ const cur = stacks.get(key);
35
+ const i = cur?.lastIndexOf(camera) ?? -1;
36
+ if (!cur || i === -1) return;
37
+ cur.splice(i, 1);
38
+ if (cur.length === 0) stacks.delete(key);
39
+ notify();
40
+ };
41
+ },
42
+ get: (key) => stacks.get(key)?.at(-1) ?? null,
43
+ subscribe(listener) {
44
+ listeners.add(listener);
45
+ return () => {
46
+ listeners.delete(listener);
47
+ };
48
+ },
49
+ };
50
+ }
51
+
52
+ /** The registry a lab keeps its trials' cameras in. */
53
+ export const CameraRegistryContext = createContext<CameraRegistry | null>(null);
54
+
55
+ /** Where a camera publishes itself. The host binds the key — a trial provides
56
+ * one that registers under its own id — so the camera needs no name. */
57
+ export const CameraPublishContext = createContext<((camera: CameraView) => () => void) | null>(
58
+ null,
59
+ );
60
+
61
+ /** Publish `camera` to the host in scope for as long as the caller is mounted,
62
+ * and again each time its input scope becomes the active one — a registry
63
+ * key stacks newest-live, so the camera last used is the one chrome reaches.
64
+ * Call it inside the camera's scope. */
65
+ export function usePublishCamera(camera: CameraView): void {
66
+ const publish = useContext(CameraPublishContext);
67
+ const actions = useActionsRegistry();
68
+ useEffect(() => {
69
+ if (!publish) return;
70
+ let release = publish(camera);
71
+ if (!actions) return release;
72
+ let was = actions.isActive();
73
+ const off = actions.subscribe(() => {
74
+ const now = actions.isActive();
75
+ if (now && !was) {
76
+ release();
77
+ release = publish(camera);
78
+ }
79
+ was = now;
80
+ });
81
+ return () => {
82
+ off();
83
+ release();
84
+ };
85
+ }, [publish, camera, actions]);
86
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CameraView } from './CameraInput';
3
+ import { zoomCameraTo } from './cameraZoom';
4
+
5
+ function fakeCamera(
6
+ host: { width: number; height: number } | null,
7
+ range = { min: 0.1, max: 32 },
8
+ ): CameraView & {
9
+ view: { x: number; y: number; scale: { x: number; y: number } };
10
+ } {
11
+ const cam = {
12
+ view: { x: 0, y: 0, scale: { x: 1, y: 1 } },
13
+ get: () => cam.view,
14
+ set: (v: { x: number; y: number; scale: { x: number; y: number } }) => {
15
+ const s = Math.min(range.max, Math.max(range.min, v.scale.x));
16
+ cam.view = { ...v, scale: { x: s, y: s } };
17
+ },
18
+ hostSize: () => host,
19
+ zoomRange: () => range,
20
+ };
21
+ return cam;
22
+ }
23
+
24
+ describe('zoomCameraTo', () => {
25
+ it('keeps the middle of the view where it was', () => {
26
+ const cam = fakeCamera({ width: 200, height: 100 });
27
+ zoomCameraTo(cam, 2);
28
+ // The world point under the screen middle (100, 50) is still under it.
29
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
30
+ expect(cam.view.y + 50 / cam.view.scale.y).toBeCloseTo(50);
31
+ expect(cam.view.scale.x).toBe(2);
32
+ });
33
+
34
+ it('anchors at the origin when the camera cannot measure its host', () => {
35
+ const cam = fakeCamera(null);
36
+ zoomCameraTo(cam, 2);
37
+ expect(cam.view).toEqual({ x: 0, y: 0, scale: { x: 2, y: 2 } });
38
+ });
39
+
40
+ it('stops at the edge of the camera range', () => {
41
+ const cam = fakeCamera({ width: 200, height: 100 }, { min: 0.5, max: 1.2 });
42
+ zoomCameraTo(cam, 5);
43
+ expect(cam.view.scale.x).toBe(1.2);
44
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
45
+ });
46
+ });
@@ -0,0 +1,24 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import type { CameraView } from './CameraInput';
3
+
4
+ /** `view` at `scale`, about the screen point `at`. */
5
+ export function atScale(view: View, scale: number, at: { x: number; y: number }): View {
6
+ return {
7
+ x: view.x + at.x / view.scale.x - at.x / scale,
8
+ y: view.y + at.y / view.scale.y - at.y / scale,
9
+ scale: { x: scale, y: scale },
10
+ };
11
+ }
12
+
13
+ /** The middle of the camera's host, in its own screen space; the origin when it cannot measure. */
14
+ export function viewMiddle(camera: CameraView): { x: number; y: number } {
15
+ const size = camera.hostSize?.();
16
+ return size ? { x: size.width / 2, y: size.height / 2 } : { x: 0, y: 0 };
17
+ }
18
+
19
+ /** Zoom `camera` to `zoom` about the middle of its view, within its range. */
20
+ export function zoomCameraTo(camera: CameraView, zoom: number): void {
21
+ const { min, max } = camera.zoomRange();
22
+ const scale = Math.min(max, Math.max(min, zoom));
23
+ camera.set(atScale(camera.get(), scale, viewMiddle(camera)));
24
+ }
@@ -1,9 +1,14 @@
1
- export type { CameraContextValue, CameraInputProps, CameraViewOptions } from './CameraInput';
1
+ export type {
2
+ CameraContextValue,
3
+ CameraInputProps,
4
+ CameraView,
5
+ CameraViewOptions,
6
+ ZoomRange,
7
+ } from './CameraInput';
2
8
  export {
3
9
  CameraContext,
4
10
  CameraInput,
5
11
  CameraScope,
6
- CameraScopeContext,
7
12
  STAGE_VIEW_ID,
8
13
  useCameraView,
9
14
  } from './CameraInput';
@@ -15,6 +20,13 @@ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackC
15
20
  export { CanvasStackContext } from './CanvasStackContext';
16
21
  export type { ZoomAtOptions } from './camera';
17
22
  export { centerOn, zoomAt } from './camera';
23
+ export type { CameraRegistry } from './cameraRegistry';
24
+ export {
25
+ CameraPublishContext,
26
+ CameraRegistryContext,
27
+ createCameraRegistry,
28
+ usePublishCamera,
29
+ } from './cameraRegistry';
18
30
  export { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
19
31
  export { screenToWorld, worldToScreen } from './canvasCoords';
20
32
  export { LinkedCursor } from './LinkedCursor';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Two cameras under one host scope — what a trial gives every canvas inside it,
3
+ * and what a forge index page puts several of in. Each must answer only for
4
+ * itself: the newest must not take the others' gestures.
5
+ */
6
+
7
+ import { act, render } from '@testing-library/react';
8
+ import { WeaselProvider } from '@weasel-js/core';
9
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
10
+ import type { ViewTransform } from '../instrument/types';
11
+ import { CanvasStack } from './CanvasStack';
12
+
13
+ beforeAll(() => {
14
+ HTMLCanvasElement.prototype.getContext = vi.fn(
15
+ () => null,
16
+ ) as unknown as HTMLCanvasElement['getContext'];
17
+ const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
18
+ proto.setPointerCapture = vi.fn();
19
+ proto.releasePointerCapture = vi.fn();
20
+ proto.hasPointerCapture = vi.fn(() => true);
21
+ });
22
+
23
+ function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
24
+ el.dispatchEvent(
25
+ new PointerEvent(type, {
26
+ bubbles: true,
27
+ cancelable: true,
28
+ pointerId: 1,
29
+ clientX: x,
30
+ clientY: y,
31
+ button: 0,
32
+ buttons,
33
+ }),
34
+ );
35
+ }
36
+
37
+ const VIEW: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
38
+
39
+ function mountTwo() {
40
+ const views = [vi.fn(), vi.fn()];
41
+ const taps = [vi.fn(), vi.fn()];
42
+ const { container } = render(
43
+ <WeaselProvider isolate>
44
+ {(['a', 'b'] as const).map((id, i) => (
45
+ <CanvasStack
46
+ key={id}
47
+ layers={[]}
48
+ view={VIEW}
49
+ onViewChange={views[i]}
50
+ onHitTest={taps[i]}
51
+ width={100}
52
+ height={100}
53
+ />
54
+ ))}
55
+ </WeaselProvider>,
56
+ );
57
+ const hosts = [...container.querySelectorAll<HTMLElement>('.lk-canvas-stack')];
58
+ return { hosts, views, taps };
59
+ }
60
+
61
+ describe('cameras sharing a host scope', () => {
62
+ it('pans the camera the drag began on, not the newest', () => {
63
+ const { hosts, views } = mountTwo();
64
+ act(() => {
65
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
66
+ pointer(hosts[0]!, 'pointermove', 60, 55);
67
+ pointer(hosts[0]!, 'pointermove', 80, 70);
68
+ pointer(hosts[0]!, 'pointerup', 80, 70, 0);
69
+ });
70
+ expect(views[0]).toHaveBeenCalled();
71
+ expect(views[1]).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('reports a tap to the canvas it landed on', () => {
75
+ const { hosts, taps } = mountTwo();
76
+ act(() => {
77
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
78
+ pointer(hosts[0]!, 'pointerup', 50, 50, 0);
79
+ });
80
+ expect(taps[0]).toHaveBeenCalledTimes(1);
81
+ expect(taps[1]).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('still drives the newest camera from its own canvas', () => {
85
+ const { hosts, views } = mountTwo();
86
+ act(() => {
87
+ pointer(hosts[1]!, 'pointerdown', 50, 50);
88
+ pointer(hosts[1]!, 'pointermove', 80, 70);
89
+ pointer(hosts[1]!, 'pointerup', 80, 70, 0);
90
+ });
91
+ expect(views[1]).toHaveBeenCalled();
92
+ expect(views[0]).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -72,6 +72,7 @@ export default meta;
72
72
  type Story = StoryObj<typeof Lab>;
73
73
 
74
74
  export const EveryRegion: Story = {
75
+ tags: ['gallery'],
75
76
  args: {
76
77
  instruments: [FullInstrument],
77
78
  defaultInstrument: 'Every Region',
@@ -4,11 +4,4 @@
4
4
  padding: var(--wzl-space-xs);
5
5
  border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
6
6
  background: var(--wzl-surface-sunken);
7
-
8
- // labkit's `:where(button)` default sets an explicit height, and because
9
- // :where() carries no specificity it also wins over ToolButton, which sizes
10
- // itself from padding.
11
- button {
12
- height: auto;
13
- }
14
7
  }
@@ -23,6 +23,7 @@
23
23
  --_s-tone: var(--wzl-tone, var(--_s-surface));
24
24
  --_s-accent: var(--wzl-tone);
25
25
  --_s-title-font: var(--wzl-font-display);
26
+ --_s-title-weight: var(--wzl-font-weight-bold);
26
27
  --_s-title-size: var(--wzl-font-size-sm);
27
28
  --_s-title-case: uppercase;
28
29
  --_s-title-tracking: 0.08em;
@@ -36,6 +37,7 @@
36
37
  --_s-tone: var(--wzl-tone, var(--wzl-stance-scope-tone, var(--_s-surface)));
37
38
  --_s-accent: var(--wzl-stance-scope-accent, var(--wzl-tone));
38
39
  --_s-title-font: var(--wzl-stance-scope-title-font, var(--wzl-font-display));
40
+ --_s-title-weight: var(--wzl-stance-scope-title-weight, var(--wzl-font-weight-bold));
39
41
  --_s-title-size: var(--wzl-stance-scope-title-size, var(--wzl-font-size-sm));
40
42
  --_s-title-case: var(--wzl-stance-scope-title-case, uppercase);
41
43
  --_s-title-tracking: var(--wzl-stance-scope-title-tracking, 0.08em);
@@ -49,6 +51,7 @@
49
51
  --_s-tone: var(--wzl-tone, var(--wzl-stance-aside-tone, var(--_s-surface)));
50
52
  --_s-accent: var(--wzl-stance-aside-accent, var(--wzl-tone));
51
53
  --_s-title-font: var(--wzl-stance-aside-title-font, var(--wzl-font-display));
54
+ --_s-title-weight: var(--wzl-stance-aside-title-weight, var(--wzl-font-weight-bold));
52
55
  --_s-title-size: var(--wzl-stance-aside-title-size, var(--wzl-font-size-sm));
53
56
  --_s-title-case: var(--wzl-stance-aside-title-case, uppercase);
54
57
  --_s-title-tracking: var(--wzl-stance-aside-title-tracking, 0.08em);
@@ -62,6 +65,7 @@
62
65
  --_s-tone: var(--wzl-tone, var(--wzl-stance-advanced-tone, var(--_s-surface)));
63
66
  --_s-accent: var(--wzl-stance-advanced-accent, var(--wzl-tone));
64
67
  --_s-title-font: var(--wzl-stance-advanced-title-font, var(--wzl-font-display));
68
+ --_s-title-weight: var(--wzl-stance-advanced-title-weight, var(--wzl-font-weight-bold));
65
69
  --_s-title-size: var(--wzl-stance-advanced-title-size, var(--wzl-font-size-sm));
66
70
  --_s-title-case: var(--wzl-stance-advanced-title-case, uppercase);
67
71
  --_s-title-tracking: var(--wzl-stance-advanced-title-tracking, 0.08em);
@@ -75,6 +79,7 @@
75
79
  --_s-tone: var(--wzl-tone, var(--wzl-stance-debug-tone, var(--_s-surface)));
76
80
  --_s-accent: var(--wzl-stance-debug-accent, var(--wzl-tone));
77
81
  --_s-title-font: var(--wzl-stance-debug-title-font, var(--wzl-font-display));
82
+ --_s-title-weight: var(--wzl-stance-debug-title-weight, var(--wzl-font-weight-bold));
78
83
  --_s-title-size: var(--wzl-stance-debug-title-size, var(--wzl-font-size-sm));
79
84
  --_s-title-case: var(--wzl-stance-debug-title-case, uppercase);
80
85
  --_s-title-tracking: var(--wzl-stance-debug-title-tracking, 0.08em);
@@ -88,6 +93,7 @@
88
93
  --_s-tone: var(--wzl-tone, var(--wzl-stance-danger-tone, var(--_s-surface)));
89
94
  --_s-accent: var(--wzl-stance-danger-accent, var(--wzl-tone));
90
95
  --_s-title-font: var(--wzl-stance-danger-title-font, var(--wzl-font-display));
96
+ --_s-title-weight: var(--wzl-stance-danger-title-weight, var(--wzl-font-weight-bold));
91
97
  --_s-title-size: var(--wzl-stance-danger-title-size, var(--wzl-font-size-sm));
92
98
  --_s-title-case: var(--wzl-stance-danger-title-case, uppercase);
93
99
  --_s-title-tracking: var(--wzl-stance-danger-title-tracking, 0.08em);
@@ -101,6 +107,7 @@
101
107
  --_s-tone: var(--wzl-tone, var(--wzl-stance-notice-tone, var(--_s-surface)));
102
108
  --_s-accent: var(--wzl-stance-notice-accent, var(--wzl-tone));
103
109
  --_s-title-font: var(--wzl-stance-notice-title-font, var(--wzl-font-display));
110
+ --_s-title-weight: var(--wzl-stance-notice-title-weight, var(--wzl-font-weight-bold));
104
111
  --_s-title-size: var(--wzl-stance-notice-title-size, var(--wzl-font-size-sm));
105
112
  --_s-title-case: var(--wzl-stance-notice-title-case, uppercase);
106
113
  --_s-title-tracking: var(--wzl-stance-notice-title-tracking, 0.08em);
@@ -114,6 +121,7 @@
114
121
  --_s-tone: var(--wzl-tone, var(--wzl-stance-important-tone, var(--_s-surface)));
115
122
  --_s-accent: var(--wzl-stance-important-accent, var(--wzl-tone));
116
123
  --_s-title-font: var(--wzl-stance-important-title-font, var(--wzl-font-display));
124
+ --_s-title-weight: var(--wzl-stance-important-title-weight, var(--wzl-font-weight-bold));
117
125
  --_s-title-size: var(--wzl-stance-important-title-size, var(--wzl-font-size-sm));
118
126
  --_s-title-case: var(--wzl-stance-important-title-case, uppercase);
119
127
  --_s-title-tracking: var(--wzl-stance-important-title-tracking, 0.08em);
@@ -127,6 +135,7 @@
127
135
  --_s-tone: var(--wzl-tone, var(--wzl-stance-preview-tone, var(--_s-surface)));
128
136
  --_s-accent: var(--wzl-stance-preview-accent, var(--wzl-tone));
129
137
  --_s-title-font: var(--wzl-stance-preview-title-font, var(--wzl-font-display));
138
+ --_s-title-weight: var(--wzl-stance-preview-title-weight, var(--wzl-font-weight-bold));
130
139
  --_s-title-size: var(--wzl-stance-preview-title-size, var(--wzl-font-size-sm));
131
140
  --_s-title-case: var(--wzl-stance-preview-title-case, uppercase);
132
141
  --_s-title-tracking: var(--wzl-stance-preview-title-tracking, 0.08em);
@@ -174,12 +183,13 @@
174
183
  .lk-sidebar-section__head {
175
184
  flex: 1;
176
185
  width: 100%;
177
- padding: var(--wzl-space-xs);
186
+ padding: var(--wzl-space-xs) var(--wzl-space-sm);
178
187
  border: 0;
179
188
  background: transparent;
180
189
  color: var(--_s-title-color);
181
190
  font-family: var(--_s-title-font);
182
191
  font-size: var(--_s-title-size);
192
+ font-weight: var(--_s-title-weight);
183
193
  letter-spacing: var(--_s-title-tracking);
184
194
  text-align: left;
185
195
  text-transform: var(--_s-title-case);
@@ -193,5 +203,5 @@
193
203
  // The section body is the only thing that insets a panel — the panels
194
204
  // themselves carry no padding, so this is the whole gutter.
195
205
  .lk-sidebar-section__body {
196
- padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
206
+ padding: 0 var(--wzl-space-sm) var(--wzl-space-sm);
197
207
  }
@@ -1,3 +1,4 @@
1
+ import { compact } from '@weasel-js/quantity';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { auto } from './auto';
3
4
  import { f } from './builder';
@@ -37,7 +38,7 @@ describe('builder', () => {
37
38
  });
38
39
 
39
40
  it('annotates a number with a display format', () => {
40
- expect(f.number(0).format('compact').annotations.format).toBe('compact');
41
+ expect(f.number(0).display(compact()).annotations.display).toEqual(compact());
41
42
  });
42
43
 
43
44
  it('keeps a custom node kind across a chain', () => {
@@ -1,4 +1,5 @@
1
- import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
1
+ import type { Display } from '@weasel-js/quantity';
2
+ import type { PrefLeaf, PrefNumberUnit } from '@weasel-js/ui';
2
3
  import { type Auto, isAuto } from './auto';
3
4
  import type {
4
5
  Annotations,
@@ -141,10 +142,10 @@ export class NumberNode extends BaseNode<number> {
141
142
  return this.ann({ suffix });
142
143
  }
143
144
 
144
- /** Show the value in a named format — `'compact'` reads `2.00M`. Presentation
145
- * only: the stored value stays a plain number, and a typed `2.5m` reads back. */
146
- format(format: PrefNumberFormat): this {
147
- return this.ann({ format });
145
+ /** Show, speak and read the value through a display — `compact()` reads
146
+ * `2.00M`. Presentation only: the stored value stays a plain number. */
147
+ display(display: Display): this {
148
+ return this.ann({ display });
148
149
  }
149
150
 
150
151
  /**
@@ -286,6 +287,12 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
286
287
  showIf(predicate: (config: Record<string, unknown>) => boolean): GroupNode<S> {
287
288
  return this.with(this.annotations, { ...this.options, showIf: predicate });
288
289
  }
290
+
291
+ /** Put a button on the group's heading that returns every value beneath it
292
+ * to its default, leaving the rest of the config alone. */
293
+ resettable(): GroupNode<S> {
294
+ return this.with(this.annotations, { ...this.options, resettable: true });
295
+ }
289
296
  }
290
297
 
291
298
  /** Every default in a shape, nested the way the shape is. */
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import { fromConfigFields } from './fromConfigField';
4
+ import { schemaNodeAtPath } from './path';
4
5
  import { isLeafVisible } from './visible';
5
6
 
6
7
  const leaf = (fields: ConfigField[], k: string) =>
@@ -76,6 +77,19 @@ describe('fromConfigFields', () => {
76
77
  ]);
77
78
  expect(Object.keys(r.group.children)).toEqual(['z', 'a']);
78
79
  });
80
+
81
+ it('files a dotted key at the path it names, the one config reads and writes use', () => {
82
+ const r = fromConfigFields([
83
+ { type: 'number', key: 'pose.x', label: 'X', default: 0 },
84
+ { type: 'number', key: 'pose.y', label: 'Y', default: 0 },
85
+ { type: 'checkbox', key: 'on', label: 'On', default: true },
86
+ ]);
87
+ expect(schemaNodeAtPath(r.group, 'pose.x')).toMatchObject({ kind: 'number', name: 'X' });
88
+ expect(schemaNodeAtPath(r.group, 'pose.y')).toMatchObject({ kind: 'number', name: 'Y' });
89
+ // The intermediate is headless: a flat field list never asked for a heading.
90
+ expect(schemaNodeAtPath(r.group, 'pose')).toMatchObject({ name: '' });
91
+ expect(Object.keys(r.group.children)).toEqual(['pose', 'on']);
92
+ });
79
93
  });
80
94
 
81
95
  describe('isLeafVisible', () => {
@@ -1,4 +1,4 @@
1
- import type { PrefLeaf } from '@weasel-js/ui';
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import type { LeafPatch, ResolvedConfig } from './types';
4
4
 
@@ -53,9 +53,13 @@ function toLeaf(field: ConfigField): LeafPatch {
53
53
  *
54
54
  * Rules do not run here: a hand-written `ConfigField` already states its label
55
55
  * and control, which is everything the rule chain would have inferred.
56
+ *
57
+ * A dotted `key` is a config path, as it is to every read and write, so its
58
+ * leaf is filed under headless groups at that path rather than as one child
59
+ * whose name contains dots — which no path lookup would find.
56
60
  */
57
61
  export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig {
58
- const children: Record<string, PrefLeaf> = {};
62
+ const root: PrefGroup = { name: '', children: {} };
59
63
  for (const field of fields) {
60
64
  const patch = toLeaf(field);
61
65
  for (const key of Object.keys(patch)) {
@@ -63,10 +67,20 @@ export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig
63
67
  delete (patch as Record<string, unknown>)[key];
64
68
  }
65
69
  }
66
- children[field.key] = { ...patch, default: field.default } as PrefLeaf;
70
+ const segments = field.key.split('.');
71
+ const leafKey = segments.pop() as string;
72
+ let group = root;
73
+ for (const segment of segments) {
74
+ const existing = group.children[segment];
75
+ if (existing === undefined || !('children' in existing) || 'kind' in existing) {
76
+ group.children[segment] = { name: '', children: {} };
77
+ }
78
+ group = group.children[segment] as PrefGroup;
79
+ }
80
+ group.children[leafKey] = { ...patch, default: field.default } as PrefLeaf;
67
81
  }
68
82
  return {
69
- group: { name: '', children },
83
+ group: root,
70
84
  sections: [],
71
85
  showIf: new Map(),
72
86
  renderers: {},
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from '@weasel-js/core';
1
2
  import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
3
 
3
4
  /**
@@ -9,11 +10,7 @@ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
9
10
  /** A plain object a config tree can be walked into. An array, a `Date`, a
10
11
  * `Map` or a class instance is a leaf value: a walk that descended into one
11
12
  * 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;
16
- }
13
+ export const isRecord = isPlainObject;
17
14
 
18
15
  /** The value at a dotted path. `undefined` when a segment is missing or the
19
16
  * walk hits something that is not a record. */
@@ -27,6 +27,7 @@ interface Sink {
27
27
  showIf: Map<string, (config: Record<string, unknown>) => boolean>;
28
28
  renderers: Record<string, ControlRenderer>;
29
29
  dialogs: Record<string, DialogSpec>;
30
+ resettable: Set<string>;
30
31
  chain: readonly ConfigRule[];
31
32
  }
32
33
 
@@ -48,6 +49,7 @@ export function resolveConfigSchema<TC>(
48
49
  showIf: new Map(),
49
50
  renderers: {},
50
51
  dialogs: {},
52
+ resettable: new Set(),
51
53
  chain: [...rules, ...builtinRules],
52
54
  };
53
55
  const group = resolveShape(schema.nodes, '', '', sink);
@@ -57,6 +59,7 @@ export function resolveConfigSchema<TC>(
57
59
  showIf: sink.showIf,
58
60
  renderers: sink.renderers,
59
61
  dialogs: sink.dialogs,
62
+ resettable: sink.resettable,
60
63
  };
61
64
  }
62
65
 
@@ -89,6 +92,7 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
89
92
  if (section.pack !== undefined && spec.pack === undefined) spec.pack = section.pack;
90
93
  }
91
94
  if (predicate) sink.showIf.set(path, predicate);
95
+ if (isConfigBranch(entry) && entry.options.resettable) sink.resettable.add(path);
92
96
  if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
93
97
  if (!isConfigBranch(entry) && entry.options.dialog) sink.dialogs[path] = entry.options.dialog;
94
98
  }
@@ -1,10 +1,5 @@
1
- import type {
2
- PrefGroup,
3
- PrefLeaf,
4
- PrefNumberFormat,
5
- PrefNumberUnit,
6
- PrefRenderer,
7
- } from '@weasel-js/ui';
1
+ import type { Display } from '@weasel-js/quantity';
2
+ import type { PrefGroup, PrefLeaf, PrefNumberUnit, PrefRenderer } from '@weasel-js/ui';
8
3
  import type { ReactNode } from 'react';
9
4
 
10
5
  /**
@@ -18,6 +13,9 @@ export type ControlRenderer = PrefRenderer;
18
13
  export interface ConfigOption {
19
14
  value: string;
20
15
  label: string;
16
+ /** A glyph in weasel-ui's icon set (`IconName`). A segmented row draws it in
17
+ * place of `label`, which stays the accessible name and becomes the tooltip. */
18
+ icon?: string;
21
19
  }
22
20
 
23
21
  /** Everything a leaf can carry beyond its kind and default. A flat union of
@@ -32,8 +30,8 @@ export interface Annotations {
32
30
  max?: number;
33
31
  step?: number;
34
32
  suffix?: string;
35
- /** How a number's value is shown. `compact` abbreviates from a thousand up. */
36
- format?: PrefNumberFormat;
33
+ /** How a number's value shows, speaks and reads back — `compact()` reads `2.00M`. */
34
+ display?: Display;
37
35
  /** Display-unit conversion for a number stored in a canonical unit — the
38
36
  * value, its bounds and its step all convert at the control's edge. */
39
37
  unit?: PrefNumberUnit;
@@ -107,6 +105,9 @@ export interface ResolvedConfig {
107
105
  renderers: Readonly<Record<string, ControlRenderer>>;
108
106
  /** Node-level `.dialog` rows, keyed by path. */
109
107
  dialogs: Readonly<Record<string, DialogSpec>>;
108
+ /** Paths of the groups marked `.resettable()`, whose heading carries a
109
+ * button that puts every value beneath it back to its default. */
110
+ resettable?: ReadonlySet<string>;
110
111
  }
111
112
 
112
113
  /** Options for a row drawn as a button that opens a dialog. */
@@ -162,6 +163,9 @@ export interface BranchOptions {
162
163
  /** Show this node only while the predicate holds. On a group it hides the
163
164
  * whole subtree; the values stay in config either way. */
164
165
  showIf?: (config: Record<string, unknown>) => boolean;
166
+ /** Put a button on this group's heading that returns every value beneath it
167
+ * to its default. Groups only. */
168
+ resettable?: boolean;
165
169
  }
166
170
 
167
171
  /** A group's own annotations: what it is called and, optionally, why. */
@@ -17,7 +17,7 @@
17
17
  .lk-pair-cell {
18
18
  display: inline-flex;
19
19
  align-items: center;
20
- gap: var(--wzl-space-3, 6px);
20
+ gap: var(--wzl-space-3);
21
21
  min-width: 0;
22
22
  }
23
23
 
@@ -1,5 +1,6 @@
1
1
  import { ANGLE_RADIANS, prefUnit } from '@weasel-js/core';
2
2
  import type { Meta, StoryObj } from '@weasel-js/forge';
3
+ import { compact } from '@weasel-js/quantity';
3
4
  import { type PrefNumberUnit, PropertyRow } from '@weasel-js/ui';
4
5
  import { useState } from 'react';
5
6
  import { f } from '../config/builder';
@@ -278,7 +279,7 @@ export const Presentation: Story = {
278
279
 
279
280
  const inline = f.schema({
280
281
  folderPad: f.number(1.1).range(0, 4).step(0.1).label('Folder pad'),
281
- glyphs: f.number(1_000_000).range(0, 2_000_000).format('compact'),
282
+ glyphs: f.number(1_000_000).range(0, 2_000_000).display(compact()),
282
283
  names: f.number(60).range(0, 100),
283
284
  labels: f.number(0).range(0, 100),
284
285
  placement: f.enum('spread', ['spread', 'stack', 'ring']),
@@ -337,7 +338,7 @@ const sidebarSliders = f.schema({
337
338
  pad: f.number(0.5).range(0, 4).step(0.25).label('Window pad'),
338
339
  folderPad: f.number(1).range(0, 4).step(0.1).label('Folder pad'),
339
340
  levels: f.number(2).range(0, 6).step(1).label('Levels'),
340
- glyphs: f.number(1_000_000).range(0, 2_000_000).step(2000).format('compact').label('Glyphs'),
341
+ glyphs: f.number(1_000_000).range(0, 2_000_000).step(2000).display(compact()).label('Glyphs'),
341
342
  names: f.number(40).range(0, 200).step(5).label('Names'),
342
343
  labels: f.number(0).range(0, 30).step(1).label('Labels'),
343
344
  text: f.number(100).range(50, 250).step(5).unit(shownIn('%')).label('Text'),