@weasel-js/labkit 1.5.2 → 1.6.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.
Files changed (217) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-Wo6OwIVM.d.ts} +1 -1
  3. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  4. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +83 -0
  5. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  6. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +404 -0
  7. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-Cr8-zoup.d.ts} +105 -15
  8. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → Stage-DWBBi8IU.d.ts} +80 -35
  9. package/dist/_dts/{index-DlKolEIZ.d.ts → index-CKyX0ME_.d.ts} +12 -13
  10. package/dist/_dts/{frac-B4hFJMiN.d.ts → types-B7yBTTF9.d.ts} +709 -651
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-DTuViBvc.d.ts} +21 -2
  12. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CMaV6UD4.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +41 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +6 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-2RVCB2PT.js +3 -0
  20. package/dist/chunk-2RVCB2PT.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/{chunk-CKUKF72R.js → chunk-4TT3PNPR.js} +68 -55
  24. package/dist/chunk-4TT3PNPR.js.map +1 -0
  25. package/dist/{chunk-KJCUERQF.js → chunk-6FLXL3I7.js} +9 -164
  26. package/dist/chunk-6FLXL3I7.js.map +1 -0
  27. package/dist/chunk-ASOK76ZH.js +431 -0
  28. package/dist/chunk-ASOK76ZH.js.map +1 -0
  29. package/dist/chunk-AWQG7FCC.js +19 -0
  30. package/dist/chunk-AWQG7FCC.js.map +1 -0
  31. package/dist/chunk-CGHF6RZV.js +300 -0
  32. package/dist/chunk-CGHF6RZV.js.map +1 -0
  33. package/dist/chunk-DWMSB4CA.js +228 -0
  34. package/dist/chunk-DWMSB4CA.js.map +1 -0
  35. package/dist/chunk-GHGO52SO.js +92 -0
  36. package/dist/chunk-GHGO52SO.js.map +1 -0
  37. package/dist/{chunk-LRI3MBGR.js → chunk-HKV7LRHY.js} +4 -4
  38. package/dist/{chunk-LRI3MBGR.js.map → chunk-HKV7LRHY.js.map} +1 -1
  39. package/dist/{chunk-EMXAIRUJ.js → chunk-IRTGUQ5O.js} +5 -4
  40. package/dist/chunk-IRTGUQ5O.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/chunk-KHK5FGER.js +145 -0
  44. package/dist/chunk-KHK5FGER.js.map +1 -0
  45. package/dist/chunk-NEXR37YJ.js +144 -0
  46. package/dist/chunk-NEXR37YJ.js.map +1 -0
  47. package/dist/{chunk-A2ACAGW6.js → chunk-O6FFFOHS.js} +32 -7
  48. package/dist/chunk-O6FFFOHS.js.map +1 -0
  49. package/dist/chunk-QGMOA2YE.js +731 -0
  50. package/dist/chunk-QGMOA2YE.js.map +1 -0
  51. package/dist/{chunk-ERFMC6WU.js → chunk-S4NCI32V.js} +19 -10
  52. package/dist/chunk-S4NCI32V.js.map +1 -0
  53. package/dist/{chunk-753Y6HE6.js → chunk-SLAQPUI4.js} +4 -4
  54. package/dist/chunk-SLAQPUI4.js.map +1 -0
  55. package/dist/{chunk-REOBGYMC.js → chunk-TJ77A4A3.js} +3 -5
  56. package/dist/chunk-TJ77A4A3.js.map +1 -0
  57. package/dist/chunk-TYN6MLXX.js +13864 -0
  58. package/dist/chunk-TYN6MLXX.js.map +1 -0
  59. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  60. package/dist/chunk-WPLAANH4.js.map +1 -0
  61. package/dist/config/index.d.ts +51 -7
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +92 -5
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -6
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +108 -154
  68. package/dist/index.js +456 -600
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +5 -35
  71. package/dist/layers/index.js +3 -3
  72. package/dist/loupe/index.d.ts +21 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +288 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +434 -231
  78. package/dist/passthrough/weasel-ui.js +2 -1
  79. package/dist/primitives/index.d.ts +19 -54
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -7
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +360 -174
  85. package/dist/undo/index.d.ts +5 -4
  86. package/package.json +13 -13
  87. package/src/annotations/frac.test.ts +7 -0
  88. package/src/annotations/index.ts +1 -0
  89. package/src/annotations/paint.test.ts +21 -0
  90. package/src/annotations/paint.ts +12 -0
  91. package/src/annotations/store.test.ts +57 -0
  92. package/src/annotations/store.ts +25 -6
  93. package/src/annotations/toolMap.test.ts +37 -2
  94. package/src/annotations/toolMap.ts +13 -0
  95. package/src/annotations/types.ts +30 -2
  96. package/src/canvas/AGENTS.md +8 -10
  97. package/src/canvas/CameraInput.test.tsx +136 -0
  98. package/src/canvas/CameraInput.tsx +311 -0
  99. package/src/canvas/CanvasStack.tsx +42 -58
  100. package/src/canvas/LinkedCursor.less +16 -0
  101. package/src/canvas/LinkedCursor.tsx +54 -0
  102. package/src/canvas/Stage.test.tsx +18 -0
  103. package/src/canvas/Stage.tsx +42 -37
  104. package/src/canvas/cameraView.test.ts +60 -0
  105. package/src/canvas/cameraView.ts +61 -0
  106. package/src/canvas/index.ts +11 -2
  107. package/src/chrome/regions/SidebarRegion.less +197 -0
  108. package/src/chrome/regions/SidebarRegion.test.tsx +27 -0
  109. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  110. package/src/chrome/types.ts +5 -2
  111. package/src/config/autoConfig.ts +2 -2
  112. package/src/config/builder.ts +21 -2
  113. package/src/config/entry.test.ts +10 -0
  114. package/src/config/fromConfigField.ts +1 -0
  115. package/src/config/index.ts +5 -0
  116. package/src/config/resolve.ts +11 -1
  117. package/src/config/rules.ts +2 -0
  118. package/src/config/sectionTree.test.ts +152 -0
  119. package/src/config/sectionTree.ts +134 -0
  120. package/src/config/types.ts +21 -0
  121. package/src/controls/ControlPanel.less +4 -0
  122. package/src/controls/ControlPanel.stories.tsx +55 -2
  123. package/src/controls/ControlPanel.test.tsx +40 -3
  124. package/src/controls/ControlPanel.tsx +134 -336
  125. package/src/controls/inDialog.test.tsx +100 -0
  126. package/src/controls/inDialog.tsx +38 -0
  127. package/src/controls/index.ts +1 -0
  128. package/src/index.test.ts +1 -1
  129. package/src/index.ts +18 -29
  130. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  131. package/src/instrument/types.ts +15 -3
  132. package/src/lab/Lab.nebula.test.tsx +40 -0
  133. package/src/lab/Lab.test.tsx +3 -1
  134. package/src/lab/Lab.tsx +42 -19
  135. package/src/lab/LabBoundary.test.tsx +66 -0
  136. package/src/lab/LabBoundary.tsx +56 -0
  137. package/src/lab/LabHeader.tsx +4 -23
  138. package/src/lab/LabRoot.tsx +2 -3
  139. package/src/lab/LabShell.less +0 -1
  140. package/src/lab/LabShell.stories.tsx +15 -0
  141. package/src/lab/LabShell.test.tsx +18 -0
  142. package/src/lab/LabShell.tsx +14 -1
  143. package/src/lab/LabSwitcher.less +1 -1
  144. package/src/lab/LabSwitcher.test.tsx +41 -0
  145. package/src/lab/LabSwitcher.tsx +30 -8
  146. package/src/lab/index.ts +1 -0
  147. package/src/layers/AGENTS.md +18 -74
  148. package/src/layers/index.ts +4 -2
  149. package/src/loupe/AGENTS.md +7 -6
  150. package/src/loupe/LoupeGestures.tsx +14 -9
  151. package/src/loupe/TrialLoupe.tsx +5 -5
  152. package/src/loupe/index.ts +1 -0
  153. package/src/loupe/loupeActions.ts +6 -2
  154. package/src/overview/AGENTS.md +38 -0
  155. package/src/overview/OverviewMarks.test.tsx +100 -0
  156. package/src/overview/OverviewMarks.tsx +93 -0
  157. package/src/overview/TrialOverview.less +53 -0
  158. package/src/overview/TrialOverview.stories.tsx +50 -0
  159. package/src/overview/TrialOverview.test.tsx +116 -0
  160. package/src/overview/TrialOverview.tsx +374 -0
  161. package/src/overview/index.ts +2 -0
  162. package/src/passthrough/weasel-ui.ts +32 -10
  163. package/src/primitives/index.ts +0 -2
  164. package/src/specimen/Specimen.tsx +59 -56
  165. package/src/state/labRecords.test.ts +4 -1
  166. package/src/state/store.test.ts +4 -4
  167. package/src/state/types.ts +2 -1
  168. package/src/state/useTrialState.test.tsx +1 -1
  169. package/src/styles.less +6 -2
  170. package/src/theme/Interstellar.stories.tsx +8 -13
  171. package/src/theme/base.less +3 -20
  172. package/src/theme/interstellar.test.ts +6 -3
  173. package/src/theme/interstellar.theme.json +0 -56
  174. package/src/theme/interstellar.ts +2 -2
  175. package/src/tools/labTool.ts +6 -2
  176. package/src/trial/Trial.annotations.test.tsx +16 -0
  177. package/src/trial/Trial.canvas.test.tsx +1 -1
  178. package/src/trial/Trial.loupe.test.tsx +73 -1
  179. package/src/trial/Trial.stories.tsx +2 -0
  180. package/src/trial/Trial.tsx +47 -9
  181. package/src/trial/trialOps.test.ts +4 -4
  182. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  183. package/dist/chunk-2AYGEN57.js +0 -32
  184. package/dist/chunk-2AYGEN57.js.map +0 -1
  185. package/dist/chunk-6BUQ77ZV.js +0 -18
  186. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  187. package/dist/chunk-753Y6HE6.js.map +0 -1
  188. package/dist/chunk-A2ACAGW6.js.map +0 -1
  189. package/dist/chunk-CKUKF72R.js.map +0 -1
  190. package/dist/chunk-D35PFTS7.js +0 -170
  191. package/dist/chunk-D35PFTS7.js.map +0 -1
  192. package/dist/chunk-EMXAIRUJ.js.map +0 -1
  193. package/dist/chunk-ERFMC6WU.js.map +0 -1
  194. package/dist/chunk-FKLT3ITC.js +0 -590
  195. package/dist/chunk-FKLT3ITC.js.map +0 -1
  196. package/dist/chunk-KJCUERQF.js.map +0 -1
  197. package/dist/chunk-LASCBWW7.js +0 -13456
  198. package/dist/chunk-LASCBWW7.js.map +0 -1
  199. package/dist/chunk-REOBGYMC.js.map +0 -1
  200. package/dist/chunk-RS3HRQWU.js.map +0 -1
  201. package/dist/chunk-TT5C5S6R.js +0 -368
  202. package/dist/chunk-TT5C5S6R.js.map +0 -1
  203. package/dist/ui/layers/index.d.ts +0 -66
  204. package/dist/ui/layers/index.js +0 -3
  205. package/dist/ui/layers/index.js.map +0 -1
  206. package/src/canvas/usePanZoom.test.ts +0 -244
  207. package/src/canvas/usePanZoom.ts +0 -147
  208. package/src/lab/useSystemMode.ts +0 -30
  209. package/src/layers/LayerList.less +0 -93
  210. package/src/layers/LayerList.stories.tsx +0 -66
  211. package/src/layers/LayerList.test.tsx +0 -232
  212. package/src/layers/LayerList.tsx +0 -268
  213. package/src/primitives/Sidebar.less +0 -112
  214. package/src/primitives/Sidebar.stories.tsx +0 -38
  215. package/src/primitives/Sidebar.test.tsx +0 -74
  216. package/src/primitives/Sidebar.tsx +0 -35
  217. package/src/ui/layers/index.ts +0 -5
@@ -0,0 +1,60 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
4
+ import { worldToScreen } from './canvasCoords';
5
+ import type { WorldFrame } from './worldSpec';
6
+
7
+ const UP: WorldFrame = { originPx: { x: 200, y: 150 }, yDir: -1 };
8
+ const vt = { zoom: 2.5, pan: { x: 30, y: -40 } };
9
+
10
+ /** Where a weasel View puts a frame-local point. */
11
+ const project = (v: View, p: { x: number; y: number }) => ({
12
+ x: (p.x - v.x) * v.scale.x,
13
+ y: (p.y - v.y) * v.scale.y,
14
+ });
15
+
16
+ describe('camera as a weasel View', () => {
17
+ it.each([
18
+ ['default frame', undefined],
19
+ ['moved origin, y up', UP],
20
+ ] as const)('places every world point where labkit does (%s)', (_, frame) => {
21
+ const v = toCameraView(vt, frame);
22
+ for (const w of [
23
+ { x: 0, y: 0 },
24
+ { x: 13, y: -7 },
25
+ { x: -120, y: 44 },
26
+ ]) {
27
+ const local = frameLocalToWorld(w, frame);
28
+ const a = project(v, local);
29
+ const b = worldToScreen(w, vt, frame);
30
+ expect(a.x).toBeCloseTo(b.x);
31
+ expect(a.y).toBeCloseTo(b.y);
32
+ }
33
+ });
34
+
35
+ it('round-trips', () => {
36
+ const back = fromCameraView(toCameraView(vt, UP), UP);
37
+ expect(back.zoom).toBeCloseTo(vt.zoom);
38
+ expect(back.pan.x).toBeCloseTo(vt.pan.x);
39
+ expect(back.pan.y).toBeCloseTo(vt.pan.y);
40
+ });
41
+ });
42
+
43
+ describe('clampZoomAbout', () => {
44
+ const prev: View = { x: 0, y: 0, scale: { x: 1, y: 1 } };
45
+ it('holds the anchor of a zoom that hits the bound', () => {
46
+ // A 4x zoom about screen (100, 50): world under it stays (100, 50).
47
+ const next: View = { x: 75, y: 37.5, scale: { x: 4, y: 4 } };
48
+ const out = clampZoomAbout(prev, next, 0.5, 2);
49
+ expect(out.scale.x).toBe(2);
50
+ expect(100 / out.scale.x + out.x).toBeCloseTo(100);
51
+ expect(50 / out.scale.y + out.y).toBeCloseTo(50);
52
+ });
53
+
54
+ it('passes a zoom inside the bounds and a pan through', () => {
55
+ const inside: View = { x: 10, y: 5, scale: { x: 1.5, y: 1.5 } };
56
+ expect(clampZoomAbout(prev, inside, 0.5, 2)).toBe(inside);
57
+ const pan: View = { x: 10, y: 5, scale: { x: 1, y: 1 } };
58
+ expect(clampZoomAbout(prev, pan, 0.5, 2)).toBe(pan);
59
+ });
60
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * labkit's camera as a weasel `View`, so core's viewport actions drive it.
3
+ *
4
+ * labkit places a world point on screen at
5
+ * `originPx + pan + zoom * (x, yDir * y)`. A weasel `View` places one at
6
+ * `(p - (x, y)) * scale`. Over *frame-local* coordinates — the world with `y`
7
+ * multiplied by `yDir` — the two are the same affine map, which is all a pan
8
+ * or a zoom about a screen point needs.
9
+ */
10
+ import type { View } from '@weasel-js/core';
11
+ import type { Point, ViewTransform } from '../instrument/types';
12
+ import { normalize2DView } from '../state/view';
13
+ import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
14
+
15
+ /** The weasel `View` equal to `view` under `frame`. */
16
+ export function toCameraView(view: ViewTransform, frame: WorldFrame = DEFAULT_FRAME): View {
17
+ const v = normalize2DView(view);
18
+ return {
19
+ x: -(frame.originPx.x + v.pan.x) / v.zoom,
20
+ y: -(frame.originPx.y + v.pan.y) / v.zoom,
21
+ scale: { x: v.zoom, y: v.zoom },
22
+ };
23
+ }
24
+
25
+ /** The labkit camera equal to weasel `view` under `frame`. */
26
+ export function fromCameraView(view: View, frame: WorldFrame = DEFAULT_FRAME): ViewTransform {
27
+ const zoom = view.scale.x;
28
+ return {
29
+ zoom,
30
+ pan: { x: -view.x * zoom - frame.originPx.x, y: -view.y * zoom - frame.originPx.y },
31
+ };
32
+ }
33
+
34
+ /** A frame-local point in the instrument's world, and back — the map is its
35
+ * own inverse. */
36
+ export function frameLocalToWorld(p: Point, frame: WorldFrame = DEFAULT_FRAME): Point {
37
+ return { x: p.x, y: p.y * frame.yDir };
38
+ }
39
+
40
+ /**
41
+ * `next` with its zoom held to [`min`, `max`], about the screen point the step
42
+ * from `prev` was anchored on, so a zoom that hits a bound stays anchored where
43
+ * the pointer is. A pan (equal scales) passes through.
44
+ */
45
+ export function clampZoomAbout(prev: View, next: View, min: number, max: number): View {
46
+ const s0 = prev.scale.x;
47
+ const s1 = next.scale.x;
48
+ const clamped = Math.min(max, Math.max(min, s1));
49
+ if (clamped === s1) return next;
50
+ if (s0 === s1) return { ...next, scale: { x: clamped, y: clamped } };
51
+ // The screen point both views agree on: p / s0 + x0 = p / s1 + x1.
52
+ const inv = 1 / s0 - 1 / s1;
53
+ const px = (next.x - prev.x) / inv;
54
+ const py = (next.y - prev.y) / inv;
55
+ // The same point under the clamped scale.
56
+ return {
57
+ x: prev.x + px / s0 - px / clamped,
58
+ y: prev.y + py / s0 - py / clamped,
59
+ scale: { x: clamped, y: clamped },
60
+ };
61
+ }
@@ -1,3 +1,12 @@
1
+ export type { CameraContextValue, CameraInputProps, CameraViewOptions } from './CameraInput';
2
+ export {
3
+ CameraContext,
4
+ CameraInput,
5
+ CameraScope,
6
+ CameraScopeContext,
7
+ STAGE_VIEW_ID,
8
+ useCameraView,
9
+ } from './CameraInput';
1
10
  export type { CameraWheelSlot } from './CameraWheelContext';
2
11
  export { CameraWheelContext } from './CameraWheelContext';
3
12
  export type { CanvasStackProps } from './CanvasStack';
@@ -6,11 +15,11 @@ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackC
6
15
  export { CanvasStackContext } from './CanvasStackContext';
7
16
  export type { ZoomAtOptions } from './camera';
8
17
  export { centerOn, zoomAt } from './camera';
18
+ export { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
9
19
  export { screenToWorld, worldToScreen } from './canvasCoords';
20
+ export { LinkedCursor } from './LinkedCursor';
10
21
  export type { StageProps } from './Stage';
11
22
  export { fitStage, Stage } from './Stage';
12
23
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
13
- export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
14
- export { usePanZoom } from './usePanZoom';
15
24
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
16
25
  export { applyCamera, DEFAULT_FRAME, resolveFrame } from './worldSpec';
@@ -0,0 +1,197 @@
1
+ .lk-sidebar-section {
2
+ background: var(--_s-fill);
3
+ border-bottom: var(--wzl-border-w) solid var(--_s-border-color);
4
+
5
+ // The last section runs to the bottom of its pane. Stopped at its content,
6
+ // its rule and the bare pane under it read as a second, empty zone.
7
+ &:last-child {
8
+ flex-grow: 1;
9
+ border-bottom: 0;
10
+ }
11
+ }
12
+
13
+ /* BEGIN GENERATED stance rules: sidebar-section — `npm run gen:stances`, do not edit. */
14
+
15
+ .lk-sidebar-section {
16
+ --wzl-tone: initial;
17
+ }
18
+
19
+ .lk-sidebar-section {
20
+ --_s-surface: transparent;
21
+ --_s-border-color: var(--wzl-line-subtle);
22
+ --_s-tone-mix: var(--wzl-panel-tone-mix);
23
+ --_s-tone: var(--wzl-tone, var(--_s-surface));
24
+ --_s-accent: var(--wzl-tone);
25
+ --_s-title-font: var(--wzl-font-display);
26
+ --_s-title-size: var(--wzl-font-size-sm);
27
+ --_s-title-case: uppercase;
28
+ --_s-title-tracking: 0.08em;
29
+ --_s-title-color: var(--wzl-fg-muted);
30
+ }
31
+
32
+ .lk-sidebar-section[data-stance='scope'] {
33
+ --_s-surface: var(--wzl-stance-scope-surface, transparent);
34
+ --_s-border-color: var(--wzl-stance-scope-border-color, var(--wzl-line-subtle));
35
+ --_s-tone-mix: var(--wzl-stance-scope-tone-mix, var(--wzl-panel-tone-mix));
36
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-scope-tone, var(--_s-surface)));
37
+ --_s-accent: var(--wzl-stance-scope-accent, var(--wzl-tone));
38
+ --_s-title-font: var(--wzl-stance-scope-title-font, var(--wzl-font-display));
39
+ --_s-title-size: var(--wzl-stance-scope-title-size, var(--wzl-font-size-sm));
40
+ --_s-title-case: var(--wzl-stance-scope-title-case, uppercase);
41
+ --_s-title-tracking: var(--wzl-stance-scope-title-tracking, 0.08em);
42
+ --_s-title-color: var(--wzl-stance-scope-title-color, var(--wzl-fg-muted));
43
+ }
44
+
45
+ .lk-sidebar-section[data-stance='aside'] {
46
+ --_s-surface: var(--wzl-stance-aside-surface, transparent);
47
+ --_s-border-color: var(--wzl-stance-aside-border-color, var(--wzl-line-subtle));
48
+ --_s-tone-mix: var(--wzl-stance-aside-tone-mix, var(--wzl-panel-tone-mix));
49
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-aside-tone, var(--_s-surface)));
50
+ --_s-accent: var(--wzl-stance-aside-accent, var(--wzl-tone));
51
+ --_s-title-font: var(--wzl-stance-aside-title-font, var(--wzl-font-display));
52
+ --_s-title-size: var(--wzl-stance-aside-title-size, var(--wzl-font-size-sm));
53
+ --_s-title-case: var(--wzl-stance-aside-title-case, uppercase);
54
+ --_s-title-tracking: var(--wzl-stance-aside-title-tracking, 0.08em);
55
+ --_s-title-color: var(--wzl-stance-aside-title-color, var(--wzl-fg-muted));
56
+ }
57
+
58
+ .lk-sidebar-section[data-stance='advanced'] {
59
+ --_s-surface: var(--wzl-stance-advanced-surface, transparent);
60
+ --_s-border-color: var(--wzl-stance-advanced-border-color, var(--wzl-line-subtle));
61
+ --_s-tone-mix: var(--wzl-stance-advanced-tone-mix, var(--wzl-panel-tone-mix));
62
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-advanced-tone, var(--_s-surface)));
63
+ --_s-accent: var(--wzl-stance-advanced-accent, var(--wzl-tone));
64
+ --_s-title-font: var(--wzl-stance-advanced-title-font, var(--wzl-font-display));
65
+ --_s-title-size: var(--wzl-stance-advanced-title-size, var(--wzl-font-size-sm));
66
+ --_s-title-case: var(--wzl-stance-advanced-title-case, uppercase);
67
+ --_s-title-tracking: var(--wzl-stance-advanced-title-tracking, 0.08em);
68
+ --_s-title-color: var(--wzl-stance-advanced-title-color, var(--wzl-fg-muted));
69
+ }
70
+
71
+ .lk-sidebar-section[data-stance='debug'] {
72
+ --_s-surface: var(--wzl-stance-debug-surface, transparent);
73
+ --_s-border-color: var(--wzl-stance-debug-border-color, var(--wzl-line-subtle));
74
+ --_s-tone-mix: var(--wzl-stance-debug-tone-mix, var(--wzl-panel-tone-mix));
75
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-debug-tone, var(--_s-surface)));
76
+ --_s-accent: var(--wzl-stance-debug-accent, var(--wzl-tone));
77
+ --_s-title-font: var(--wzl-stance-debug-title-font, var(--wzl-font-display));
78
+ --_s-title-size: var(--wzl-stance-debug-title-size, var(--wzl-font-size-sm));
79
+ --_s-title-case: var(--wzl-stance-debug-title-case, uppercase);
80
+ --_s-title-tracking: var(--wzl-stance-debug-title-tracking, 0.08em);
81
+ --_s-title-color: var(--wzl-stance-debug-title-color, var(--wzl-fg-muted));
82
+ }
83
+
84
+ .lk-sidebar-section[data-stance='danger'] {
85
+ --_s-surface: var(--wzl-stance-danger-surface, transparent);
86
+ --_s-border-color: var(--wzl-stance-danger-border-color, var(--wzl-line-subtle));
87
+ --_s-tone-mix: var(--wzl-stance-danger-tone-mix, var(--wzl-panel-tone-mix));
88
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-danger-tone, var(--_s-surface)));
89
+ --_s-accent: var(--wzl-stance-danger-accent, var(--wzl-tone));
90
+ --_s-title-font: var(--wzl-stance-danger-title-font, var(--wzl-font-display));
91
+ --_s-title-size: var(--wzl-stance-danger-title-size, var(--wzl-font-size-sm));
92
+ --_s-title-case: var(--wzl-stance-danger-title-case, uppercase);
93
+ --_s-title-tracking: var(--wzl-stance-danger-title-tracking, 0.08em);
94
+ --_s-title-color: var(--wzl-stance-danger-title-color, var(--wzl-fg-muted));
95
+ }
96
+
97
+ .lk-sidebar-section[data-stance='notice'] {
98
+ --_s-surface: var(--wzl-stance-notice-surface, transparent);
99
+ --_s-border-color: var(--wzl-stance-notice-border-color, var(--wzl-line-subtle));
100
+ --_s-tone-mix: var(--wzl-stance-notice-tone-mix, var(--wzl-panel-tone-mix));
101
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-notice-tone, var(--_s-surface)));
102
+ --_s-accent: var(--wzl-stance-notice-accent, var(--wzl-tone));
103
+ --_s-title-font: var(--wzl-stance-notice-title-font, var(--wzl-font-display));
104
+ --_s-title-size: var(--wzl-stance-notice-title-size, var(--wzl-font-size-sm));
105
+ --_s-title-case: var(--wzl-stance-notice-title-case, uppercase);
106
+ --_s-title-tracking: var(--wzl-stance-notice-title-tracking, 0.08em);
107
+ --_s-title-color: var(--wzl-stance-notice-title-color, var(--wzl-fg-muted));
108
+ }
109
+
110
+ .lk-sidebar-section[data-stance='important'] {
111
+ --_s-surface: var(--wzl-stance-important-surface, transparent);
112
+ --_s-border-color: var(--wzl-stance-important-border-color, var(--wzl-line-subtle));
113
+ --_s-tone-mix: var(--wzl-stance-important-tone-mix, var(--wzl-panel-tone-mix));
114
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-important-tone, var(--_s-surface)));
115
+ --_s-accent: var(--wzl-stance-important-accent, var(--wzl-tone));
116
+ --_s-title-font: var(--wzl-stance-important-title-font, var(--wzl-font-display));
117
+ --_s-title-size: var(--wzl-stance-important-title-size, var(--wzl-font-size-sm));
118
+ --_s-title-case: var(--wzl-stance-important-title-case, uppercase);
119
+ --_s-title-tracking: var(--wzl-stance-important-title-tracking, 0.08em);
120
+ --_s-title-color: var(--wzl-stance-important-title-color, var(--wzl-fg-muted));
121
+ }
122
+
123
+ .lk-sidebar-section[data-stance='preview'] {
124
+ --_s-surface: var(--wzl-stance-preview-surface, transparent);
125
+ --_s-border-color: var(--wzl-stance-preview-border-color, var(--wzl-line-subtle));
126
+ --_s-tone-mix: var(--wzl-stance-preview-tone-mix, var(--wzl-panel-tone-mix));
127
+ --_s-tone: var(--wzl-tone, var(--wzl-stance-preview-tone, var(--_s-surface)));
128
+ --_s-accent: var(--wzl-stance-preview-accent, var(--wzl-tone));
129
+ --_s-title-font: var(--wzl-stance-preview-title-font, var(--wzl-font-display));
130
+ --_s-title-size: var(--wzl-stance-preview-title-size, var(--wzl-font-size-sm));
131
+ --_s-title-case: var(--wzl-stance-preview-title-case, uppercase);
132
+ --_s-title-tracking: var(--wzl-stance-preview-title-tracking, 0.08em);
133
+ --_s-title-color: var(--wzl-stance-preview-title-color, var(--wzl-fg-muted));
134
+ }
135
+
136
+ .lk-sidebar-section {
137
+ --_s-fill: var(--_s-surface);
138
+ }
139
+
140
+ .lk-sidebar-section[data-tone],
141
+ .lk-sidebar-section[data-stance] {
142
+ --_s-fill: color-mix(in oklab, var(--_s-tone) var(--_s-tone-mix), var(--_s-surface));
143
+ }
144
+
145
+ .lk-sidebar-section[data-tone] {
146
+ --wzl-accent: var(--_s-accent);
147
+ --wzl-accent-base: var(--_s-accent);
148
+ --wzl-accent-strong: color-mix(in oklab, var(--_s-accent) 70%, var(--wzl-fg));
149
+ --wzl-accent-soft: color-mix(in oklab, var(--_s-accent) 45%, var(--wzl-surface));
150
+ --wzl-accent-hover: var(--wzl-accent-strong);
151
+ --wzl-accent-fg: var(--wzl-accent-strong);
152
+ --wzl-focus-ring: var(--wzl-accent-strong);
153
+ }
154
+
155
+ /* END GENERATED stance rules: sidebar-section */
156
+
157
+ .lk-sidebar-section__bar {
158
+ display: flex;
159
+ align-items: center;
160
+ }
161
+
162
+ .lk-sidebar-section__undock {
163
+ padding: 0 var(--wzl-space-xs);
164
+ border: 0;
165
+ background: transparent;
166
+ color: var(--wzl-fg-subtle);
167
+ cursor: pointer;
168
+
169
+ &:hover {
170
+ color: var(--wzl-fg);
171
+ }
172
+ }
173
+
174
+ .lk-sidebar-section__head {
175
+ flex: 1;
176
+ width: 100%;
177
+ padding: var(--wzl-space-xs);
178
+ border: 0;
179
+ background: transparent;
180
+ color: var(--_s-title-color);
181
+ font-family: var(--_s-title-font);
182
+ font-size: var(--_s-title-size);
183
+ letter-spacing: var(--_s-title-tracking);
184
+ text-align: left;
185
+ text-transform: var(--_s-title-case);
186
+ cursor: pointer;
187
+
188
+ &:hover {
189
+ color: var(--wzl-fg);
190
+ }
191
+ }
192
+
193
+ // The section body is the only thing that insets a panel — the panels
194
+ // themselves carry no padding, so this is the whole gutter.
195
+ .lk-sidebar-section__body {
196
+ padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
197
+ }
@@ -0,0 +1,27 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { RegionContribution, TrialChromeContext } from '../types';
4
+ import { SidebarRegion } from './SidebarRegion';
5
+
6
+ const ctx = {
7
+ collapsedSections: {},
8
+ setSectionCollapsed: () => {},
9
+ } as unknown as TrialChromeContext;
10
+
11
+ describe('<SidebarRegion> stance and tone', () => {
12
+ it('puts a section’s stance and tone on the section', () => {
13
+ const contributions: RegionContribution<TrialChromeContext>[] = [
14
+ { id: 'a', region: 'sidebar', item: { title: 'Plain', body: 'x' } },
15
+ {
16
+ id: 'b',
17
+ region: 'sidebar',
18
+ item: { title: 'Debug', body: 'y', stance: 'debug', tone: '#224a63' },
19
+ },
20
+ ];
21
+ const { container } = render(<SidebarRegion contributions={contributions} ctx={ctx} />);
22
+ const [plain, debug] = [...container.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
23
+ expect(plain.hasAttribute('data-stance')).toBe(false);
24
+ expect(debug.dataset.stance).toBe('debug');
25
+ expect(debug.style.getPropertyValue('--wzl-tone')).toBe('#224a63');
26
+ });
27
+ });
@@ -1,3 +1,4 @@
1
+ import { type StanceProps, useStance } from '@weasel-js/ui';
1
2
  import type { ReactNode } from 'react';
2
3
  import type {
3
4
  RegionContribution,
@@ -20,7 +21,9 @@ function Section({
20
21
  onCollapsedChange,
21
22
  onUndock,
22
23
  children,
23
- }: {
24
+ stance,
25
+ tone,
26
+ }: StanceProps & {
24
27
  title: string;
25
28
  collapsed: boolean;
26
29
  onCollapsedChange: (collapsed: boolean) => void;
@@ -28,7 +31,7 @@ function Section({
28
31
  children: ReactNode;
29
32
  }) {
30
33
  return (
31
- <section className="lk-sidebar-section">
34
+ <section className="lk-sidebar-section" {...useStance({ stance, tone })}>
32
35
  <div className="lk-sidebar-section__bar">
33
36
  <button
34
37
  type="button"
@@ -76,6 +79,8 @@ export function SidebarRegion<TCtx extends SidebarSlotContext = TrialChromeConte
76
79
  <Section
77
80
  key={c.id}
78
81
  title={item.title}
82
+ stance={item.stance}
83
+ tone={item.tone}
79
84
  collapsed={ctx.collapsedSections[c.id] ?? item.defaultCollapsed ?? false}
80
85
  onCollapsedChange={(next) => ctx.setSectionCollapsed(c.id, next)}
81
86
  onUndock={
@@ -1,3 +1,4 @@
1
+ import type { StanceProps } from '@weasel-js/ui';
1
2
  import type { ComponentType, ReactNode } from 'react';
2
3
  import type { ResolvedConfig } from '../config/types';
3
4
  import type { ConfigField } from '../controls/types';
@@ -45,8 +46,10 @@ export interface ToolItem<TCtx = TrialChromeContext> {
45
46
  onActivate?: (ctx: TCtx) => void;
46
47
  }
47
48
 
48
- /** A titled block in the sidebar. */
49
- export interface SidebarSection {
49
+ /** A titled block in the sidebar. `stance` and `tone` work as on a
50
+ * `<PropertyPanel>`: what kind of content the section holds, and which of its
51
+ * peers it is. */
52
+ export interface SidebarSection extends StanceProps {
50
53
  title: string;
51
54
  /** Starts collapsed, until the trial remembers a fold of its own. */
52
55
  defaultCollapsed?: boolean;
@@ -1,4 +1,4 @@
1
- import { isPrefLeaf, type PrefGroup, type PrefLeaf } from '@weasel-js/ui';
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
2
  import { isAuto } from './auto';
3
3
  import { isRecord, withValueAtPath } from './path';
4
4
  import type { ResolvedConfig } from './types';
@@ -15,7 +15,7 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
15
15
  function* leaves(group: PrefGroup, at = ''): Generator<[string, PrefLeaf]> {
16
16
  for (const [key, child] of Object.entries(group.children)) {
17
17
  const path = at === '' ? key : `${at}.${key}`;
18
- if (isPrefLeaf(child)) yield [path, child];
18
+ if ('kind' in child) yield [path, child];
19
19
  else yield* leaves(child, path);
20
20
  }
21
21
  }
@@ -11,6 +11,7 @@ import type {
11
11
  ConfigSchema,
12
12
  ConfigShape,
13
13
  ControlRenderer,
14
+ InDialogOptions,
14
15
  InferConfig,
15
16
  NodeOptions,
16
17
  SectionOption,
@@ -88,7 +89,13 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
88
89
 
89
90
  /** Draw this one row yourself, keeping the kind, default and validation. */
90
91
  render(renderer: ControlRenderer): this {
91
- return this.opt({ render: renderer });
92
+ return this.opt({ render: renderer, dialog: undefined });
93
+ }
94
+
95
+ /** Draw this row as a button that opens a dialog, with `body` as the
96
+ * dialog's content — for an editor too big for the row. */
97
+ dialog(body: ControlRenderer, opts?: InDialogOptions): this {
98
+ return this.opt({ dialog: { ...opts, body }, render: undefined });
92
99
  }
93
100
 
94
101
  /**
@@ -134,7 +141,7 @@ export class NumberNode extends BaseNode<number> {
134
141
  return this.ann({ suffix });
135
142
  }
136
143
 
137
- /** Show the value in a named format — `'compact'` reads `2.0M`. Presentation
144
+ /** Show the value in a named format — `'compact'` reads `2.00M`. Presentation
138
145
  * only: the stored value stays a plain number, and a typed `2.5m` reads back. */
139
146
  format(format: PrefNumberFormat): this {
140
147
  return this.ann({ format });
@@ -187,6 +194,15 @@ export class StringNode extends BaseNode<string> {
187
194
  }
188
195
  }
189
196
 
197
+ export class ListNode extends BaseNode<string[]> {
198
+ readonly kind = 'list';
199
+
200
+ /** Shown in an empty entry. */
201
+ placeholder(placeholder: string): this {
202
+ return this.ann({ placeholder });
203
+ }
204
+ }
205
+
190
206
  export class ColorNode extends BaseNode<string> {
191
207
  readonly kind = 'color';
192
208
 
@@ -304,6 +320,9 @@ export const f = {
304
320
  boolean: (def: boolean): BooleanNode => new BooleanNode(def),
305
321
  string: (def: string): StringNode => new StringNode(def),
306
322
  color: (def: string): ColorNode => new ColorNode(def),
323
+ /** A list of strings. Its row summarizes the list, and opens a dialog to
324
+ * edit it one entry per field. */
325
+ list: (def: readonly string[]): ListNode => new ListNode([...def]),
307
326
 
308
327
  /** A fixed set of choices. The default's literal type flows into the config
309
328
  * type, so `f.enum('fast', ['fast', 'accurate'])` gives
@@ -36,6 +36,16 @@ describe('@weasel-js/labkit/config', () => {
36
36
  expect(files.filter((f) => /\.(less|css)$/.test(f))).toEqual([]);
37
37
  });
38
38
 
39
+ // A forge frame loads this entry for every story, and ui's barrel is 167 modules on the dev server.
40
+ it('loads nothing from @weasel-js/ui at runtime', () => {
41
+ const importers = [...walk(resolve(__dirname, 'index.ts'))].filter((file) =>
42
+ [...readFileSync(file, 'utf8').matchAll(IMPORT)].some((m) =>
43
+ (m[1] ?? m[2] ?? m[3])?.startsWith('@weasel-js/ui'),
44
+ ),
45
+ );
46
+ expect(importers).toEqual([]);
47
+ });
48
+
39
49
  it('is published as an entry', async () => {
40
50
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
41
51
  expect(pkg.exports['./config']).toEqual({
@@ -70,5 +70,6 @@ export function fromConfigFields(fields: readonly ConfigField[]): ResolvedConfig
70
70
  sections: [],
71
71
  showIf: new Map(),
72
72
  renderers: {},
73
+ dialogs: {},
73
74
  };
74
75
  }
@@ -9,6 +9,7 @@ export {
9
9
  f,
10
10
  GroupNode,
11
11
  isConfigBranch,
12
+ ListNode,
12
13
  NumberNode,
13
14
  StringNode,
14
15
  ValueNode,
@@ -23,6 +24,7 @@ export {
23
24
  } from './path';
24
25
  export { resolveConfigSchema } from './resolve';
25
26
  export { applyRules, builtinRules, titleCase } from './rules';
27
+ export { type SectionTree, sectionTree } from './sectionTree';
26
28
  export type {
27
29
  Annotations,
28
30
  BranchAnnotations,
@@ -38,12 +40,15 @@ export type {
38
40
  ConfigSchema,
39
41
  ConfigShape,
40
42
  ControlRenderer,
43
+ DialogSpec,
41
44
  EntryValue,
45
+ InDialogOptions,
42
46
  InferConfig,
43
47
  LeafPatch,
44
48
  NodeOptions,
45
49
  NodeValue,
46
50
  ResolvedConfig,
51
+ SectionOption,
47
52
  SectionSpec,
48
53
  ValueAtPath,
49
54
  } from './types';
@@ -7,6 +7,7 @@ import type {
7
7
  ConfigSchema,
8
8
  ConfigShape,
9
9
  ControlRenderer,
10
+ DialogSpec,
10
11
  LeafPatch,
11
12
  ResolvedConfig,
12
13
  SectionSpec,
@@ -25,6 +26,7 @@ interface Sink {
25
26
  sections: SectionSpec[];
26
27
  showIf: Map<string, (config: Record<string, unknown>) => boolean>;
27
28
  renderers: Record<string, ControlRenderer>;
29
+ dialogs: Record<string, DialogSpec>;
28
30
  chain: readonly ConfigRule[];
29
31
  }
30
32
 
@@ -45,10 +47,17 @@ export function resolveConfigSchema<TC>(
45
47
  sections: [],
46
48
  showIf: new Map(),
47
49
  renderers: {},
50
+ dialogs: {},
48
51
  chain: [...rules, ...builtinRules],
49
52
  };
50
53
  const group = resolveShape(schema.nodes, '', '', sink);
51
- return { group, sections: sink.sections, showIf: sink.showIf, renderers: sink.renderers };
54
+ return {
55
+ group,
56
+ sections: sink.sections,
57
+ showIf: sink.showIf,
58
+ renderers: sink.renderers,
59
+ dialogs: sink.dialogs,
60
+ };
52
61
  }
53
62
 
54
63
  /** A section while it is still being filled. */
@@ -81,6 +90,7 @@ function resolveShape(shape: ConfigShape, at: string, name: string, sink: Sink):
81
90
  }
82
91
  if (predicate) sink.showIf.set(path, predicate);
83
92
  if (!isConfigBranch(entry) && entry.options.render) sink.renderers[path] = entry.options.render;
93
+ if (!isConfigBranch(entry) && entry.options.dialog) sink.dialogs[path] = entry.options.dialog;
84
94
  }
85
95
 
86
96
  return { name, children };
@@ -15,6 +15,8 @@ export function titleCase(key: string): string {
15
15
  const kindFromValue: ConfigRule = (ctx) => {
16
16
  const t = typeof ctx.default;
17
17
  if (t === 'boolean' || t === 'number' || t === 'string') return { kind: t };
18
+ const d = ctx.default;
19
+ if (Array.isArray(d) && d.every((v) => typeof v === 'string')) return { kind: 'list' };
18
20
  return null;
19
21
  };
20
22