@weasel-js/labkit 1.5.2 → 1.6.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 (139) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CMMo180l.d.ts → CanvasStackContext-DQgcH1ny.d.ts} +1 -1
  3. package/dist/_dts/LayerList.d-2c9yup5Y.d.ts +83 -0
  4. package/dist/_dts/{PrefsForm.d-DPy9I8It.d.ts → PrefsRow.d-DFpPIeIC.d.ts} +16 -1
  5. package/dist/_dts/{Select.d-ZTiwzYyT.d.ts → Select.d-BchGMC2Y.d.ts} +102 -11
  6. package/dist/_dts/{frac-B4hFJMiN.d.ts → frac-DTRq7Xpb.d.ts} +18 -10
  7. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +23 -0
  8. package/dist/_dts/{index-DlKolEIZ.d.ts → index-B0--8PEM.d.ts} +2 -2
  9. package/dist/_dts/index-DINmCuLA.d.ts +453 -0
  10. package/dist/_dts/number.d-OeQYQoUO.d.ts +28 -0
  11. package/dist/_dts/{types-Db-3NNk1.d.ts → types-B7y8hX1D.d.ts} +2 -2
  12. package/dist/_dts/{usePanZoom-SSTZe410.d.ts → usePanZoom-Bq73pwFe.d.ts} +2 -2
  13. package/dist/_dts/{useTrialState-pnAE-UBD.d.ts → useTrialState-CP4jfuKl.d.ts} +1 -1
  14. package/dist/canvas/index.d.ts +6 -5
  15. package/dist/chrome/index.d.ts +5 -4
  16. package/dist/chrome/index.js +7 -7
  17. package/dist/chunk-2RVCB2PT.js +3 -0
  18. package/dist/chunk-2RVCB2PT.js.map +1 -0
  19. package/dist/{chunk-753Y6HE6.js → chunk-3FI7Y4R4.js} +4 -4
  20. package/dist/chunk-3FI7Y4R4.js.map +1 -0
  21. package/dist/{chunk-REOBGYMC.js → chunk-AWH3SRPW.js} +31 -5
  22. package/dist/chunk-AWH3SRPW.js.map +1 -0
  23. package/dist/{chunk-A2ACAGW6.js → chunk-BVL7Y574.js} +22 -5
  24. package/dist/chunk-BVL7Y574.js.map +1 -0
  25. package/dist/{chunk-CKUKF72R.js → chunk-H5VIV7MQ.js} +26 -22
  26. package/dist/chunk-H5VIV7MQ.js.map +1 -0
  27. package/dist/chunk-HFRE4QQV.js +400 -0
  28. package/dist/chunk-HFRE4QQV.js.map +1 -0
  29. package/dist/{chunk-LRI3MBGR.js → chunk-MOLIUNVH.js} +4 -4
  30. package/dist/{chunk-LRI3MBGR.js.map → chunk-MOLIUNVH.js.map} +1 -1
  31. package/dist/chunk-USO6TERP.js +90 -0
  32. package/dist/chunk-USO6TERP.js.map +1 -0
  33. package/dist/{chunk-EMXAIRUJ.js → chunk-VQ47KWD5.js} +3 -3
  34. package/dist/{chunk-EMXAIRUJ.js.map → chunk-VQ47KWD5.js.map} +1 -1
  35. package/dist/chunk-WUJXJC5O.js +14568 -0
  36. package/dist/chunk-WUJXJC5O.js.map +1 -0
  37. package/dist/{chunk-KJCUERQF.js → chunk-ZORVNOKX.js} +6 -25
  38. package/dist/chunk-ZORVNOKX.js.map +1 -0
  39. package/dist/config/index.d.ts +50 -7
  40. package/dist/config/index.js +5 -5
  41. package/dist/controls/index.d.ts +6 -3
  42. package/dist/controls/index.js +4 -4
  43. package/dist/dragdrop/index.d.ts +4 -3
  44. package/dist/index.d.ts +83 -102
  45. package/dist/index.js +430 -458
  46. package/dist/index.js.map +1 -1
  47. package/dist/layers/index.d.ts +6 -35
  48. package/dist/layers/index.js +2 -3
  49. package/dist/loupe/index.d.ts +6 -5
  50. package/dist/passthrough/weasel-ui.d.ts +407 -204
  51. package/dist/passthrough/weasel-ui.js +1 -1
  52. package/dist/primitives/index.d.ts +2 -13
  53. package/dist/primitives/index.js +4 -4
  54. package/dist/state/index.d.ts +6 -5
  55. package/dist/state/index.js +5 -5
  56. package/dist/styles.css +298 -174
  57. package/dist/undo/index.d.ts +4 -3
  58. package/package.json +9 -13
  59. package/src/annotations/frac.test.ts +7 -0
  60. package/src/annotations/paint.test.ts +21 -0
  61. package/src/annotations/paint.ts +12 -0
  62. package/src/annotations/store.test.ts +31 -0
  63. package/src/annotations/types.ts +3 -2
  64. package/src/canvas/Stage.test.tsx +18 -0
  65. package/src/chrome/regions/SidebarRegion.less +197 -0
  66. package/src/chrome/regions/SidebarRegion.test.tsx +20 -0
  67. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  68. package/src/chrome/types.ts +5 -2
  69. package/src/config/autoConfig.ts +2 -2
  70. package/src/config/builder.ts +20 -1
  71. package/src/config/entry.test.ts +8 -0
  72. package/src/config/index.ts +2 -0
  73. package/src/config/rules.ts +2 -0
  74. package/src/config/sectionTree.test.ts +146 -0
  75. package/src/config/sectionTree.ts +124 -0
  76. package/src/controls/ControlPanel.less +4 -0
  77. package/src/controls/ControlPanel.stories.tsx +55 -2
  78. package/src/controls/ControlPanel.test.tsx +30 -3
  79. package/src/controls/ControlPanel.tsx +116 -337
  80. package/src/controls/inDialog.test.tsx +82 -0
  81. package/src/controls/inDialog.tsx +46 -0
  82. package/src/controls/index.ts +1 -0
  83. package/src/index.ts +11 -26
  84. package/src/instrument/types.ts +5 -2
  85. package/src/lab/Lab.nebula.test.tsx +31 -0
  86. package/src/lab/Lab.tsx +38 -15
  87. package/src/lab/LabBoundary.test.tsx +66 -0
  88. package/src/lab/LabBoundary.tsx +56 -0
  89. package/src/lab/LabHeader.tsx +4 -23
  90. package/src/lab/LabRoot.tsx +2 -3
  91. package/src/lab/LabShell.less +0 -1
  92. package/src/lab/LabShell.stories.tsx +15 -0
  93. package/src/lab/LabShell.test.tsx +18 -0
  94. package/src/lab/LabShell.tsx +14 -1
  95. package/src/lab/LabSwitcher.less +1 -1
  96. package/src/lab/LabSwitcher.test.tsx +41 -0
  97. package/src/lab/LabSwitcher.tsx +30 -8
  98. package/src/lab/index.ts +1 -0
  99. package/src/layers/AGENTS.md +18 -74
  100. package/src/layers/index.ts +4 -2
  101. package/src/passthrough/weasel-ui.ts +25 -10
  102. package/src/primitives/index.ts +0 -2
  103. package/src/specimen/Specimen.tsx +42 -56
  104. package/src/state/types.ts +2 -1
  105. package/src/styles.less +4 -2
  106. package/src/theme/Interstellar.stories.tsx +8 -9
  107. package/src/theme/base.less +3 -20
  108. package/src/theme/interstellar.test.ts +6 -3
  109. package/src/theme/interstellar.theme.json +0 -56
  110. package/src/theme/interstellar.ts +2 -2
  111. package/src/trial/Trial.canvas.test.tsx +1 -1
  112. package/src/trial/Trial.tsx +30 -8
  113. package/dist/_dts/index-8LiKe3A-.d.ts +0 -410
  114. package/dist/chunk-6BUQ77ZV.js +0 -18
  115. package/dist/chunk-6BUQ77ZV.js.map +0 -1
  116. package/dist/chunk-753Y6HE6.js.map +0 -1
  117. package/dist/chunk-A2ACAGW6.js.map +0 -1
  118. package/dist/chunk-CKUKF72R.js.map +0 -1
  119. package/dist/chunk-D35PFTS7.js +0 -170
  120. package/dist/chunk-D35PFTS7.js.map +0 -1
  121. package/dist/chunk-FKLT3ITC.js +0 -590
  122. package/dist/chunk-FKLT3ITC.js.map +0 -1
  123. package/dist/chunk-KJCUERQF.js.map +0 -1
  124. package/dist/chunk-LASCBWW7.js +0 -13456
  125. package/dist/chunk-LASCBWW7.js.map +0 -1
  126. package/dist/chunk-REOBGYMC.js.map +0 -1
  127. package/dist/ui/layers/index.d.ts +0 -66
  128. package/dist/ui/layers/index.js +0 -3
  129. package/dist/ui/layers/index.js.map +0 -1
  130. package/src/lab/useSystemMode.ts +0 -30
  131. package/src/layers/LayerList.less +0 -93
  132. package/src/layers/LayerList.stories.tsx +0 -66
  133. package/src/layers/LayerList.test.tsx +0 -232
  134. package/src/layers/LayerList.tsx +0 -268
  135. package/src/primitives/Sidebar.less +0 -112
  136. package/src/primitives/Sidebar.stories.tsx +0 -38
  137. package/src/primitives/Sidebar.test.tsx +0 -74
  138. package/src/primitives/Sidebar.tsx +0 -35
  139. package/src/ui/layers/index.ts +0 -5
@@ -1,8 +1,9 @@
1
- export { au as UndoCapability } from '../_dts/frac-B4hFJMiN.js';
1
+ export { au as UndoCapability } from '../_dts/frac-DTRq7Xpb.js';
2
2
  import 'react';
3
- import '../_dts/types-Db-3NNk1.js';
3
+ import '../_dts/PrefsRow.d-DFpPIeIC.js';
4
+ import '@weasel-js/theme';
4
5
  import '@weasel-js/core';
5
- import '../_dts/PrefsForm.d-DPy9I8It.js';
6
+ import '../_dts/types-B7y8hX1D.js';
6
7
  import '../_dts/types-DJ79Tg5J.js';
7
8
 
8
9
  /** A subscriber to a named event. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -82,10 +82,6 @@
82
82
  "types": "./dist/passthrough/weasel-canvas.d.ts",
83
83
  "import": "./dist/passthrough/weasel-canvas.js"
84
84
  },
85
- "./ui/layers": {
86
- "types": "./dist/ui/layers/index.d.ts",
87
- "import": "./dist/ui/layers/index.js"
88
- },
89
85
  "./styles.css": "./dist/styles.css",
90
86
  "./loupe": {
91
87
  "types": "./dist/loupe/index.d.ts",
@@ -115,17 +111,17 @@
115
111
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
112
  },
117
113
  "peerDependencies": {
118
- "@weasel-js/core": "1.5.2",
119
- "@weasel-js/theme": "1.5.2",
114
+ "@weasel-js/core": "1.6.0",
115
+ "@weasel-js/theme": "1.6.0",
120
116
  "react": "^19.0.0",
121
117
  "react-dom": "^19.0.0"
122
118
  },
123
119
  "dependencies": {
124
- "@weasel-js/geom": "1.5.2",
125
- "@weasel-js/kernel3d": "1.5.2",
126
- "@weasel-js/loupe": "1.5.2",
127
- "@weasel-js/svg": "1.5.2",
128
- "@weasel-js/ui": "1.5.2",
120
+ "@weasel-js/geom": "1.6.0",
121
+ "@weasel-js/kernel3d": "1.6.0",
122
+ "@weasel-js/loupe": "1.6.0",
123
+ "@weasel-js/svg": "1.6.0",
124
+ "@weasel-js/ui": "1.6.0",
129
125
  "earcut": "2.2.4",
130
126
  "polygon-clipping": "^0.15.7",
131
127
  "react-aria-components": "^1.5.0",
@@ -136,7 +132,7 @@
136
132
  "@biomejs/biome": "^2.4.12",
137
133
  "@rollup/plugin-alias": "^6.0.0",
138
134
  "@testing-library/jest-dom": "^6.6.0",
139
- "@weasel-js/modes": "1.5.2",
135
+ "@weasel-js/modes": "1.6.0",
140
136
  "less": "^4.2.0",
141
137
  "rollup-plugin-dts": "^6.4.1",
142
138
  "tsx": "^4.19.0",
@@ -51,6 +51,13 @@ describe('fraction hit geometry', () => {
51
51
  expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
52
52
  });
53
53
 
54
+ it('reaches a zero-size box through the tolerance alone', () => {
55
+ const dot: FracRect = { x: 0.25, y: 0.5, w: 0, h: 0 };
56
+ expect(fracContains(dot, { x: 0.25, y: 0.5 })).toBe(true);
57
+ expect(fracContains(dot, { x: 0.26, y: 0.51 })).toBe(false);
58
+ expect(fracContains(dot, { x: 0.26, y: 0.51 }, 0.02)).toBe(true);
59
+ });
60
+
54
61
  it('encloses only a box that lies wholly inside', () => {
55
62
  expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
56
63
  // Overlapping is not containing: a marquee takes what it encloses.
@@ -99,6 +99,20 @@ describe('markCommands', () => {
99
99
  it('draws an untitled text mark as nothing rather than an empty run', () => {
100
100
  expect(markCommands(mark('text'), CONTENT)).toEqual([]);
101
101
  });
102
+
103
+ it('draws a point as a small unfilled ring centered on its one stored point', () => {
104
+ const cmds = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT);
105
+ expect(cmds).toHaveLength(1);
106
+ expect((cmds[0] as { fill?: unknown }).fill).toBeUndefined();
107
+ const pts = anchors(cmds);
108
+ const xs = pts.filter((_, i) => i % 2 === 0);
109
+ const ys = pts.filter((_, i) => i % 2 === 1);
110
+ // Centered on (50, 50) in world, radius 4.
111
+ expect(Math.min(...xs)).toBeCloseTo(46, 4);
112
+ expect(Math.max(...xs)).toBeCloseTo(54, 4);
113
+ expect(Math.min(...ys)).toBeCloseTo(46, 4);
114
+ expect(Math.max(...ys)).toBeCloseTo(54, 4);
115
+ });
102
116
  });
103
117
 
104
118
  describe('markCommands styling', () => {
@@ -117,6 +131,13 @@ describe('markCommands styling', () => {
117
131
  expect((stale as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
118
132
  });
119
133
 
134
+ it('dashes a stale point ring like any other mark', () => {
135
+ const [ring] = markCommands(mark('point', { points: [{ x: 0.25, y: 0.5 }] }), CONTENT, {
136
+ stale: true,
137
+ });
138
+ expect((ring as { stroke?: { dash?: number[] } }).stroke?.dash).toEqual([6, 4]);
139
+ });
140
+
120
141
  it("colors a text mark's glyphs too, not only the outlines", () => {
121
142
  const [cmd] = markCommands(mark('text', { title: 'x' }), CONTENT, { color: '#30a46c' });
122
143
  const runs = (cmd as { runs: { fill?: { color?: string } }[] }).runs;
@@ -17,6 +17,7 @@ const MARK_COLOR = '#e5484d';
17
17
  * marker geometry can be resolved here, which needs a resolved width. */
18
18
  const MARK_WIDTH = 2;
19
19
  const TEXT_SIZE = 14;
20
+ const POINT_RADIUS = 4;
20
21
 
21
22
  /** The subset of a mark's scene node this needs: where it is, and what it is. */
22
23
  export interface PaintableMark {
@@ -108,6 +109,17 @@ export function markCommands(
108
109
  }
109
110
  case 'stroke':
110
111
  return [{ kind: 'path', path: polyline(vertices(m, content)), stroke }];
112
+ case 'point': {
113
+ const stored = m.data.points?.[0];
114
+ const c = stored ? toWorld(stored, content) : { x: m.pose.x, y: m.pose.y };
115
+ const ring = {
116
+ x: c.x - POINT_RADIUS,
117
+ y: c.y - POINT_RADIUS,
118
+ width: 2 * POINT_RADIUS,
119
+ height: 2 * POINT_RADIUS,
120
+ };
121
+ return [{ kind: 'path', path: ellipsePath(ring), stroke }];
122
+ }
111
123
  case 'text': {
112
124
  const text = m.data.title;
113
125
  if (!text) return [];
@@ -245,6 +245,37 @@ describe('the annotation store', () => {
245
245
  expect(revived.query()).toHaveLength(1);
246
246
  });
247
247
 
248
+ it('round-trips a point mark with its one stored point and zero-size bounds', () => {
249
+ const store = makeStore();
250
+ const id = store.add({
251
+ target: 'naive',
252
+ kind: 'point',
253
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
254
+ points: [{ x: 0.25, y: 0.5 }],
255
+ });
256
+ expect(store.get(id)).toMatchObject({
257
+ kind: 'point',
258
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
259
+ points: [{ x: 0.25, y: 0.5 }],
260
+ });
261
+
262
+ const revived = annotationsFromJSON(JSON.parse(JSON.stringify(store.toJSON())), () => TARGETS);
263
+ expect(revived.get(id)).toEqual(store.get(id));
264
+ });
265
+
266
+ it('hit-tests a point mark through the tolerance, since its bounds have no area', () => {
267
+ const store = makeStore();
268
+ const id = store.add({
269
+ target: 'naive',
270
+ kind: 'point',
271
+ frac: { x: 0.25, y: 0.5, w: 0, h: 0 },
272
+ points: [{ x: 0.25, y: 0.5 }],
273
+ });
274
+ expect(store.hitTest('naive', { x: 0.26, y: 0.51 })).toHaveLength(0);
275
+ expect(store.hitTest('naive', { x: 0.26, y: 0.51 }, 0.02).map((a) => a.id)).toEqual([id]);
276
+ expect(store.hitTest('naive', { x: 0.3, y: 0.51 }, 0.02)).toHaveLength(0);
277
+ });
278
+
248
279
  it('reads a meaning passed as a thunk at each capture', async () => {
249
280
  let color = '#111111';
250
281
  const store = annotationsFromJSON(null, () => TARGETS, {
@@ -21,8 +21,9 @@ export interface FracRect {
21
21
  }
22
22
 
23
23
  /** What shape a mark is. The kinds map onto weasel's own tools; an arrow is a
24
- * line carrying an end marker, not a separate geometry. */
25
- export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text';
24
+ * line carrying an end marker, not a separate geometry. A point holds one
25
+ * entry in `points` and zero-size bounds at that position. */
26
+ export type AnnotationKind = 'stroke' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'point';
26
27
 
27
28
  /** One selectable status in an instrument's meaning tier. */
28
29
  export interface AnnotationStatus {
@@ -183,4 +183,22 @@ describe('an instrument that declares a stage', () => {
183
183
  render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
184
184
  expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull();
185
185
  });
186
+
187
+ it('draws its overlay on the stage, outside the camera', () => {
188
+ const overlaid = defineInstrument<{ n: number }, Record<string, never>>({
189
+ name: 'Overlaid',
190
+ defaultConfig: () => ({}),
191
+ initialState: () => ({ n: 7 }),
192
+ render: () => <div data-testid="art" />,
193
+ stage: {
194
+ size: { width: 240, height: 160 },
195
+ overlay: ({ state }) => <div data-testid="legend">{state.n}</div>,
196
+ },
197
+ });
198
+ const { container } = render(<Lab instruments={[overlaid]} defaultInstrument="Overlaid" />);
199
+ const legend = screen.getByTestId('legend');
200
+ expect(legend).toHaveTextContent('7');
201
+ expect(container.querySelector('.lk-stage')).toContainElement(legend);
202
+ expect(content(container)).not.toContainElement(legend);
203
+ });
186
204
  });
@@ -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,20 @@
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 = { collapsedSections: {}, setSectionCollapsed: () => {} } as unknown as TrialChromeContext;
7
+
8
+ describe('<SidebarRegion> stance and tone', () => {
9
+ it('puts a section’s stance and tone on the section', () => {
10
+ const contributions: RegionContribution<TrialChromeContext>[] = [
11
+ { id: 'a', region: 'sidebar', item: { title: 'Plain', body: 'x' } },
12
+ { id: 'b', region: 'sidebar', item: { title: 'Debug', body: 'y', stance: 'debug', tone: '#224a63' } },
13
+ ];
14
+ const { container } = render(<SidebarRegion contributions={contributions} ctx={ctx} />);
15
+ const [plain, debug] = [...container.querySelectorAll<HTMLElement>('.lk-sidebar-section')];
16
+ expect(plain.hasAttribute('data-stance')).toBe(false);
17
+ expect(debug.dataset.stance).toBe('debug');
18
+ expect(debug.style.getPropertyValue('--wzl-tone')).toBe('#224a63');
19
+ });
20
+ });
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import type { PrefLeaf, PrefNumberFormat, PrefNumberUnit } from '@weasel-js/ui';
2
+ import { type InDialogOptions, inDialog } from '../controls/inDialog';
2
3
  import { type Auto, isAuto } from './auto';
3
4
  import type {
4
5
  Annotations,
@@ -91,6 +92,12 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
91
92
  return this.opt({ render: renderer });
92
93
  }
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.render(inDialog(body, opts));
99
+ }
100
+
94
101
  /**
95
102
  * Compute this leaf's value while it is auto, instead of leaving it
96
103
  * `undefined`. The resolver is given the config with every other auto path
@@ -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,14 @@ 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) => (m[1] ?? m[2] ?? m[3])?.startsWith('@weasel-js/ui')),
43
+ );
44
+ expect(importers).toEqual([]);
45
+ });
46
+
39
47
  it('is published as an entry', async () => {
40
48
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
41
49
  expect(pkg.exports['./config']).toEqual({
@@ -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,
@@ -22,6 +23,7 @@ export {
22
23
  withValueAtPath,
23
24
  } from './path';
24
25
  export { resolveConfigSchema } from './resolve';
26
+ export { type SectionTree, sectionTree } from './sectionTree';
25
27
  export { applyRules, builtinRules, titleCase } from './rules';
26
28
  export type {
27
29
  Annotations,
@@ -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