@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,152 @@
1
+ import { isPrefLeaf, type PrefGroup } from '@weasel-js/ui';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { f } from './builder';
4
+ import { resolveConfigSchema } from './resolve';
5
+ import { sectionTree } from './sectionTree';
6
+
7
+ const groupAt = (g: PrefGroup, key: string) => {
8
+ const child = g.children[key];
9
+ if (!child || isPrefLeaf(child)) throw new Error(`${key} is not a group`);
10
+ return child;
11
+ };
12
+
13
+ describe('sectionTree', () => {
14
+ it('makes each root section a top-level group, in schema order', () => {
15
+ const resolved = resolveConfigSchema(
16
+ f.schema({
17
+ lift: f.number(1).section('View'),
18
+ beat: f.number(2).section('Playback'),
19
+ spread: f.number(3).section('View'),
20
+ }),
21
+ );
22
+ const { group } = sectionTree(resolved, { lift: 1, beat: 2, spread: 3 });
23
+ expect(Object.keys(group.children)).toEqual(['view', 'playback']);
24
+ expect(groupAt(group, 'view').name).toBe('View');
25
+ expect(Object.keys(groupAt(group, 'view').children)).toEqual(['lift', 'spread']);
26
+ expect(Object.keys(groupAt(group, 'playback').children)).toEqual(['beat']);
27
+ });
28
+
29
+ it('brings a group a section names in whole, so the rail can indent it', () => {
30
+ const resolved = resolveConfigSchema(
31
+ f.schema({
32
+ cards: f
33
+ .group({ floor: f.number(1), cap: f.number(2) })
34
+ .section('View')
35
+ .label('Cards'),
36
+ }),
37
+ );
38
+ const { group } = sectionTree(resolved, { cards: { floor: 1, cap: 2 } });
39
+ const cards = groupAt(groupAt(group, 'view'), 'cards');
40
+ expect(cards.name).toBe('Cards');
41
+ expect(Object.keys(cards.children)).toEqual(['floor', 'cap']);
42
+ });
43
+
44
+ it('renests the config to mirror the tree', () => {
45
+ const resolved = resolveConfigSchema(
46
+ f.schema({
47
+ cards: f.group({ floor: f.number(1) }).section('View'),
48
+ beat: f.number(2).section('Playback'),
49
+ }),
50
+ );
51
+ const { values } = sectionTree(resolved, { cards: { floor: 7 }, beat: 9 });
52
+ expect(values).toEqual({ view: { cards: { floor: 7 } }, playback: { beat: 9 } });
53
+ });
54
+
55
+ it('cuts what showIf rules out, at any depth', () => {
56
+ const resolved = resolveConfigSchema(
57
+ f.schema({
58
+ lineNumbers: f.boolean(true).section('View'),
59
+ listing: f
60
+ .group({
61
+ dim: f.number(1).showIf((c) => c.lineNumbers === true),
62
+ weight: f.number(0),
63
+ })
64
+ .section('View'),
65
+ }),
66
+ );
67
+ const on = sectionTree(resolved, { lineNumbers: true, listing: { dim: 1, weight: 0 } });
68
+ expect(Object.keys(groupAt(groupAt(on.group, 'view'), 'listing').children)).toEqual([
69
+ 'dim',
70
+ 'weight',
71
+ ]);
72
+ const off = sectionTree(resolved, { lineNumbers: false, listing: { dim: 1, weight: 0 } });
73
+ expect(Object.keys(groupAt(groupAt(off.group, 'view'), 'listing').children)).toEqual([
74
+ 'weight',
75
+ ]);
76
+ });
77
+
78
+ it('drops a section nothing in it is showing, rather than opening it onto nothing', () => {
79
+ const resolved = resolveConfigSchema(
80
+ f.schema({
81
+ mode: f.enum<string>('a', ['a', 'b']).section('View'),
82
+ glow: f
83
+ .number(1)
84
+ .section('Diff')
85
+ .showIf((c) => c.mode === 'b'),
86
+ }),
87
+ );
88
+ expect(Object.keys(sectionTree(resolved, { mode: 'b', glow: 1 }).group.children)).toEqual([
89
+ 'view',
90
+ 'diff',
91
+ ]);
92
+ expect(Object.keys(sectionTree(resolved, { mode: 'a', glow: 1 }).group.children)).toEqual([
93
+ 'view',
94
+ ]);
95
+ });
96
+
97
+ it('keeps a hidden leaf out unless asked for it', () => {
98
+ const resolved = resolveConfigSchema(
99
+ f.schema({
100
+ seed: f.number(1).hidden().section('Debug'),
101
+ route: f.boolean(true).section('Debug'),
102
+ }),
103
+ );
104
+ expect(Object.keys(groupAt(sectionTree(resolved, {}).group, 'debug').children)).toEqual([
105
+ 'route',
106
+ ]);
107
+ expect(Object.keys(groupAt(sectionTree(resolved, {}, true).group, 'debug').children)).toEqual([
108
+ 'seed',
109
+ 'route',
110
+ ]);
111
+ });
112
+
113
+ it('leaves a node no section claimed at the top level', () => {
114
+ const resolved = resolveConfigSchema(
115
+ f.schema({ lift: f.number(1).section('View'), loose: f.number(2) }),
116
+ );
117
+ const { group, values } = sectionTree(resolved, { lift: 1, loose: 2 });
118
+ expect(Object.keys(group.children)).toEqual(['view', 'loose']);
119
+ expect(values.loose).toBe(2);
120
+ });
121
+
122
+ it('pathAt gives back the config path a rail path stands for', () => {
123
+ const resolved = resolveConfigSchema(
124
+ f.schema({
125
+ cards: f.group({ floor: f.number(1) }).section('View'),
126
+ loose: f.number(2),
127
+ }),
128
+ );
129
+ const { pathAt } = sectionTree(resolved, { cards: { floor: 1 }, loose: 2 });
130
+ expect(pathAt('view.cards.floor')).toBe('cards.floor');
131
+ expect(pathAt('loose')).toBe('loose');
132
+ });
133
+
134
+ it('keys two sections whose labels slug the same apart', () => {
135
+ const resolved = resolveConfigSchema(
136
+ f.schema({ a: f.number(1).section('Hold on'), b: f.number(2).section('Hold-on') }),
137
+ );
138
+ const { group } = sectionTree(resolved, { a: 1, b: 2 });
139
+ expect(Object.keys(group.children)).toEqual(['hold-on', 'hold-on-2']);
140
+ });
141
+
142
+ it('ignores a section nested under a group, which buckets rows in a pane', () => {
143
+ const resolved = resolveConfigSchema(
144
+ f.schema({
145
+ cards: f.group({ floor: f.number(1).section('Size') }).section('View'),
146
+ }),
147
+ );
148
+ const { group } = sectionTree(resolved, { cards: { floor: 1 } });
149
+ expect(Object.keys(group.children)).toEqual(['view']);
150
+ expect(Object.keys(groupAt(groupAt(group, 'view'), 'cards').children)).toEqual(['floor']);
151
+ });
152
+ });
@@ -0,0 +1,134 @@
1
+ import type { PrefGroup, PrefLeaf } from '@weasel-js/ui';
2
+ import { valueAtPath } from './path';
3
+ import type { ResolvedConfig } from './types';
4
+ import { isLeafVisible } from './visible';
5
+
6
+ /**
7
+ * A resolved schema rearranged so its sections are groups.
8
+ *
9
+ * `PrefsForm`'s rail navigates the schema tree — top-level groups are its
10
+ * items, their group children indent under them. A resolved schema keeps its
11
+ * sections beside the tree instead, so a flat schema's rail comes out as one
12
+ * unnamed item however many headings the panel draws. This puts the sections
13
+ * where the rail looks for them.
14
+ */
15
+ export interface SectionTree {
16
+ /** The rail's schema. Top-level children are the schema's root sections,
17
+ * followed by any root node no section claimed. */
18
+ group: PrefGroup;
19
+ /** `config` renested to mirror `group` — the value tree `PrefsForm` takes. */
20
+ values: Record<string, unknown>;
21
+ /** The config path a path within `group` stands for. */
22
+ pathAt: (railPath: string) => string;
23
+ }
24
+
25
+ /** Structural rather than `isPrefLeaf`, so the config entry stays free of a
26
+ * runtime import from ui. */
27
+ function isLeaf(node: PrefLeaf | PrefGroup): node is PrefLeaf {
28
+ return 'kind' in node;
29
+ }
30
+
31
+ /** A section label as an object key: no dots, since a dotted path is how every
32
+ * caller addresses a node, and a label is free to hold one. */
33
+ function slugOf(label: string, taken: ReadonlySet<string>): string {
34
+ const base =
35
+ label
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '-')
38
+ .replace(/^-|-$/g, '') || 'section';
39
+ if (!taken.has(base)) return base;
40
+ for (let n = 2; ; n += 1) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
41
+ }
42
+
43
+ /** A subtree with everything `showIf` or `hidden` rules out cut away, or null
44
+ * when that is all of it. A group whose children all went is not a rail item
45
+ * the reader can open onto nothing. */
46
+ function prune(
47
+ resolved: ResolvedConfig,
48
+ group: PrefGroup,
49
+ at: string,
50
+ config: Record<string, unknown>,
51
+ showHidden: boolean,
52
+ ): PrefGroup | null {
53
+ const children: PrefGroup['children'] = {};
54
+ for (const [key, child] of Object.entries(group.children)) {
55
+ const path = at === '' ? key : `${at}.${key}`;
56
+ if (!isLeafVisible(resolved, path, config, showHidden)) continue;
57
+ if (isLeaf(child)) {
58
+ children[key] = child;
59
+ continue;
60
+ }
61
+ const kept = prune(resolved, child, path, config, showHidden);
62
+ if (kept) children[key] = kept;
63
+ }
64
+ return Object.keys(children).length === 0 ? null : { ...group, children };
65
+ }
66
+
67
+ /**
68
+ * Turn a resolved schema's root sections into the top level of a `PrefGroup`,
69
+ * for `PrefsForm`'s rail layout.
70
+ *
71
+ * A section naming a group brings that group in whole, so it lands as an
72
+ * indented rail item; a section naming a leaf drops it loose into the
73
+ * section's own pane. Sections nested under a group are left alone — they
74
+ * bucket rows inside a pane, which is the panel's job and not the rail's.
75
+ */
76
+ export function sectionTree(
77
+ resolved: ResolvedConfig,
78
+ config: Record<string, unknown>,
79
+ showHidden = false,
80
+ ): SectionTree {
81
+ const children: PrefGroup['children'] = {};
82
+ const values: Record<string, unknown> = {};
83
+ const slugs = new Set<string>();
84
+ const claimed = new Set<string>();
85
+
86
+ for (const section of resolved.sections) {
87
+ if (section.at !== '') continue;
88
+ const kept: PrefGroup['children'] = {};
89
+ const held: Record<string, unknown> = {};
90
+ for (const path of section.paths) {
91
+ claimed.add(path);
92
+ const child = resolved.group.children[path];
93
+ if (!child || !isLeafVisible(resolved, path, config, showHidden)) continue;
94
+ if (isLeaf(child)) {
95
+ kept[path] = child;
96
+ } else {
97
+ const subtree = prune(resolved, child, path, config, showHidden);
98
+ if (!subtree) continue;
99
+ kept[path] = subtree;
100
+ }
101
+ held[path] = valueAtPath(config, path);
102
+ }
103
+ if (Object.keys(kept).length === 0) continue;
104
+ const slug = slugOf(section.label, slugs);
105
+ slugs.add(slug);
106
+ children[slug] = { name: section.label, children: kept };
107
+ values[slug] = held;
108
+ }
109
+
110
+ // Whatever no section claimed stays at the top level, where `prefRailItems`
111
+ // gathers loose leaves into one item named for the root.
112
+ for (const [key, child] of Object.entries(resolved.group.children)) {
113
+ if (claimed.has(key) || !isLeafVisible(resolved, key, config, showHidden)) continue;
114
+ if (isLeaf(child)) {
115
+ children[key] = child;
116
+ } else {
117
+ const subtree = prune(resolved, child, key, config, showHidden);
118
+ if (!subtree) continue;
119
+ children[key] = subtree;
120
+ }
121
+ values[key] = valueAtPath(config, key);
122
+ }
123
+
124
+ return {
125
+ group: { name: resolved.group.name, children },
126
+ values,
127
+ pathAt: (railPath) => {
128
+ const cut = railPath.indexOf('.');
129
+ const head = cut === -1 ? railPath : railPath.slice(0, cut);
130
+ if (!slugs.has(head)) return railPath;
131
+ return cut === -1 ? '' : railPath.slice(cut + 1);
132
+ },
133
+ };
134
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  PrefNumberUnit,
6
6
  PrefRenderer,
7
7
  } from '@weasel-js/ui';
8
+ import type { ReactNode } from 'react';
8
9
 
9
10
  /**
10
11
  * Renders the control cell for one config leaf. Identical to weasel-ui's
@@ -104,6 +105,23 @@ export interface ResolvedConfig {
104
105
  showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
105
106
  /** Node-level `.render` overrides, keyed by path. */
106
107
  renderers: Readonly<Record<string, ControlRenderer>>;
108
+ /** Node-level `.dialog` rows, keyed by path. */
109
+ dialogs: Readonly<Record<string, DialogSpec>>;
110
+ }
111
+
112
+ /** Options for a row drawn as a button that opens a dialog. */
113
+ export interface InDialogOptions {
114
+ /** What the row's button shows of the value. Defaults to a list joined with
115
+ * commas, or the value itself. */
116
+ summary?: (value: unknown) => ReactNode;
117
+ /** The dialog's heading. Defaults to the row's label. */
118
+ title?: ReactNode;
119
+ }
120
+
121
+ /** A `.dialog` row: `body` is the dialog's content, handed the context a row
122
+ * renderer gets. */
123
+ export interface DialogSpec extends InDialogOptions {
124
+ body: ControlRenderer;
107
125
  }
108
126
 
109
127
  /** How a node names the section it belongs to: the heading, and how that
@@ -121,6 +139,9 @@ export interface SectionOption {
121
139
  /** Per-node extras that do not belong on a `PrefLeaf`. */
122
140
  export interface NodeOptions extends BranchOptions {
123
141
  render?: ControlRenderer;
142
+ /** Draw the row as a button that opens a dialog. Set by `.dialog`; the
143
+ * config entry records it, and `ControlPanel` draws it. */
144
+ dialog?: DialogSpec;
124
145
  /** Errors for this leaf under `config`, the instrument's whole config. */
125
146
  validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[];
126
147
  /** Computes this leaf's value while it is auto. Attaching one is what gives
@@ -2,8 +2,12 @@
2
2
  // from PropertyPanel.less. This class remains as the consumer-facing hook.
3
3
  // It carries no padding: whatever mounts the panel insets it, and a second
4
4
  // inset here doubled the gutter inside an already narrow sidebar.
5
+ // A select row aligns to its value's baseline by default, which sits a label
6
+ // lower than the centered rows around it — here those neighbors are sliders and
7
+ // checkboxes, so a column of labels stepped at every dropdown.
5
8
  .lk-control-panel {
6
9
  padding: 0;
10
+ --wzl-prop-row-align-text: center;
7
11
  }
8
12
 
9
13
  // A cell in a paired row, with the caption naming the knob it holds. Case,
@@ -1,6 +1,6 @@
1
1
  import { ANGLE_RADIANS, prefUnit } from '@weasel-js/core';
2
2
  import type { Meta, StoryObj } from '@weasel-js/forge';
3
- import { PropertyRow } from '@weasel-js/ui';
3
+ import { type PrefNumberUnit, PropertyRow } from '@weasel-js/ui';
4
4
  import { useState } from 'react';
5
5
  import { f } from '../config/builder';
6
6
  import { withValueAtPath } from '../config/path';
@@ -285,7 +285,9 @@ const inline = f.schema({
285
285
  lift: f.number(0.2).range(0, 1).step(0.05),
286
286
  opacity: f.number(0.45).range(0, 1).step(0.05),
287
287
  iconSet: f.enum('small', ['small', 'large']).label('Icon set'),
288
- drawAsIcons: f.string('**/node_modules/**').label('Draw as icons'),
288
+ drawAsIcons: f
289
+ .list(['**/node_modules/**', '**/dist/**', 'package-lock.json'])
290
+ .label('Draw as icons'),
289
291
  detail: f.number(1200).range(0, 4000).suffix('nodes'),
290
292
  showCameraRoute: f.boolean(false).toggle().label('Show camera route'),
291
293
  routeColor: f.enum('sweep', ['sweep', 'depth']).label('Route color'),
@@ -316,3 +318,54 @@ export const Inline: Story = {
316
318
  );
317
319
  },
318
320
  };
321
+
322
+ const shownIn = (suffix: string): PrefNumberUnit => ({
323
+ toDisplay: (v) => v,
324
+ fromDisplay: (v) => v,
325
+ suffix,
326
+ });
327
+
328
+ /** Every slider in astv's right sidebar, for sizing readouts against real ranges, steps and units. */
329
+ const sidebarSliders = f.schema({
330
+ aimNoise: f.number(0).range(0, 0.6).step(0.02).label('Aim noise'),
331
+ transition: f.number(600).range(0, 1200).step(50).unit(shownIn('ms')).label('Transition'),
332
+ sweep: f.number(12).range(0, 40).step(0.5).label('Text motion'),
333
+ beat: f.number(1600).range(400, 6000).step(100).unit(shownIn('ms')).label('Beat'),
334
+ tourSeed: f.number(1).range(1, 99).step(1).label('Tour seed'),
335
+ hold: f.number(1200).range(0, 4000).step(10).unit(shownIn('ms')).label('Demo hold'),
336
+ scrollRows: f.number(3).range(1, 12).step(1).label('Lines'),
337
+ pad: f.number(0.5).range(0, 4).step(0.25).label('Window pad'),
338
+ folderPad: f.number(1).range(0, 4).step(0.1).label('Folder pad'),
339
+ 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
+ names: f.number(40).range(0, 200).step(5).label('Names'),
342
+ labels: f.number(0).range(0, 30).step(1).label('Labels'),
343
+ text: f.number(100).range(50, 250).step(5).unit(shownIn('%')).label('Text'),
344
+ lift: f.number(0.8).range(0, 4).step(0.1).label('Lift'),
345
+ opacity: f.number(0.6).range(0.1, 1).step(0.05).label('Opacity'),
346
+ detail: f.number(300).range(20, 4000).step(20).label('Nodes'),
347
+ reachStep: f.number(600).range(200, 2000).step(100).unit(shownIn('ms')).label('Reach step'),
348
+ width: f.number(2).range(1, 24).step(0.5).unit(shownIn('px')).label('Width'),
349
+ minimapSize: f.number(280).range(120, 600).step(20).label('Minimap size'),
350
+ });
351
+
352
+ /** astv's sidebar sliders paired two to a row, at the narrowest width a two-up sidebar should support:
353
+ * 300px at compact and comfortable. Roomy needs 352px; at 300 its longest labels push readouts out of the row. */
354
+ export const SidebarSliders: Story = {
355
+ render: () => {
356
+ const schema = resolveConfigSchema(sidebarSliders, []);
357
+ const [config, setConfig] = useState<Record<string, unknown>>(sidebarSliders.defaults());
358
+ return (
359
+ <div style={{ width: 300 }}>
360
+ <ControlPanel
361
+ schema={schema}
362
+ config={config}
363
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
364
+ layout="inline"
365
+ align="center"
366
+ pack="pairs"
367
+ />
368
+ </div>
369
+ );
370
+ },
371
+ };
@@ -187,7 +187,11 @@ describe('<ControlPanel> schema', () => {
187
187
  [],
188
188
  );
189
189
  render(
190
- <ControlPanel schema={schema} config={{ showGrid: true, snap: false }} setConfig={setConfig} />,
190
+ <ControlPanel
191
+ schema={schema}
192
+ config={{ showGrid: true, snap: false }}
193
+ setConfig={setConfig}
194
+ />,
191
195
  );
192
196
  expect(screen.getByRole('checkbox', { name: 'Show grid' })).toBeInTheDocument();
193
197
  const sw = screen.getByRole('switch', { name: 'Snap' });
@@ -368,6 +372,7 @@ describe('<ControlPanel> built-in kind coverage', () => {
368
372
  sections: [],
369
373
  showIf: new Map(),
370
374
  renderers: {},
375
+ dialogs: {},
371
376
  });
372
377
 
373
378
  it('declines paint and object with a named row rather than a blank one', () => {
@@ -478,7 +483,7 @@ describe('<ControlPanel> format', () => {
478
483
  setConfig={vi.fn()}
479
484
  />,
480
485
  );
481
- expect(screen.getByDisplayValue('2.0M')).toBeInTheDocument();
486
+ expect(screen.getByDisplayValue('2.00M')).toBeInTheDocument();
482
487
  });
483
488
  });
484
489
 
@@ -493,7 +498,9 @@ describe('<ControlPanel> group descriptions', () => {
493
498
  }),
494
499
  [],
495
500
  );
496
- render(<ControlPanel schema={schema} config={{ grid: { cellSize: 20 } }} setConfig={vi.fn()} />);
501
+ render(
502
+ <ControlPanel schema={schema} config={{ grid: { cellSize: 20 } }} setConfig={vi.fn()} />,
503
+ );
497
504
  const note = screen.getByText('How the background lattice is spaced.');
498
505
  const field = screen.getByLabelText('Cell size');
499
506
  expect(note.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -1064,3 +1071,33 @@ describe('<ControlPanel> color alpha', () => {
1064
1071
  expect(screen.queryByLabelText('Tint opacity')).toBeNull();
1065
1072
  });
1066
1073
  });
1074
+
1075
+ describe('<ControlPanel> title, stance and tone', () => {
1076
+ const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: true }];
1077
+
1078
+ it('renders the bare list when given none of them', () => {
1079
+ const { container } = render(
1080
+ <ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1081
+ );
1082
+ expect(container.firstElementChild?.classList.contains('lk-control-panel')).toBe(true);
1083
+ expect(container.querySelector('[data-stance], h2')).toBeNull();
1084
+ });
1085
+
1086
+ it('wraps its rows in a panel carrying them when asked', () => {
1087
+ const { container } = render(
1088
+ <ControlPanel
1089
+ title="View"
1090
+ stance="scope"
1091
+ tone={1}
1092
+ fields={fields}
1093
+ config={{ on: true }}
1094
+ setConfig={vi.fn()}
1095
+ />,
1096
+ );
1097
+ const panel = container.firstElementChild as HTMLElement;
1098
+ expect(panel.dataset.stance).toBe('scope');
1099
+ expect(panel.dataset.tone).toBe('1');
1100
+ expect(screen.getByRole('heading', { name: 'View' })).toBeTruthy();
1101
+ expect(panel.querySelector('.lk-control-panel')).not.toBeNull();
1102
+ });
1103
+ });