@weasel-js/labkit 1.5.1 → 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 (160) hide show
  1. package/README.md +3 -7
  2. package/dist/_dts/{CanvasStackContext-CJNMS6mz.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-Gwr8fLpK.d.ts → Select.d-BchGMC2Y.d.ts} +129 -12
  6. package/dist/_dts/{frac-B4okC2lX.d.ts → frac-DTRq7Xpb.d.ts} +31 -13
  7. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +23 -0
  8. package/dist/_dts/{index-CSJoj4LC.d.ts → index-B0--8PEM.d.ts} +5 -4
  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-DzoNY1pO.d.ts → types-B7y8hX1D.d.ts} +6 -6
  12. package/dist/_dts/{usePanZoom-Pzf9f2jh.d.ts → usePanZoom-Bq73pwFe.d.ts} +4 -47
  13. package/dist/_dts/{useTrialState-uLlmaccn.d.ts → useTrialState-CP4jfuKl.d.ts} +1 -1
  14. package/dist/canvas/index.d.ts +6 -5
  15. package/dist/canvas/index.js +1 -1
  16. package/dist/chrome/index.d.ts +5 -4
  17. package/dist/chrome/index.js +7 -7
  18. package/dist/chunk-2RVCB2PT.js +3 -0
  19. package/dist/chunk-2RVCB2PT.js.map +1 -0
  20. package/dist/{chunk-UN4U4P63.js → chunk-3FI7Y4R4.js} +9 -9
  21. package/dist/chunk-3FI7Y4R4.js.map +1 -0
  22. package/dist/{chunk-7PFH3CNM.js → chunk-AWH3SRPW.js} +31 -5
  23. package/dist/chunk-AWH3SRPW.js.map +1 -0
  24. package/dist/{chunk-ZDHY47EV.js → chunk-BVL7Y574.js} +25 -9
  25. package/dist/chunk-BVL7Y574.js.map +1 -0
  26. package/dist/{chunk-IHUVQ3TX.js → chunk-H5VIV7MQ.js} +31 -27
  27. package/dist/chunk-H5VIV7MQ.js.map +1 -0
  28. package/dist/chunk-HFRE4QQV.js +400 -0
  29. package/dist/chunk-HFRE4QQV.js.map +1 -0
  30. package/dist/{chunk-IXTQI7JX.js → chunk-MOLIUNVH.js} +4 -4
  31. package/dist/{chunk-IXTQI7JX.js.map → chunk-MOLIUNVH.js.map} +1 -1
  32. package/dist/{chunk-CEIUBPPW.js → chunk-TT5C5S6R.js} +3 -86
  33. package/dist/chunk-TT5C5S6R.js.map +1 -0
  34. package/dist/chunk-USO6TERP.js +90 -0
  35. package/dist/chunk-USO6TERP.js.map +1 -0
  36. package/dist/{chunk-PHK4W562.js → chunk-VQ47KWD5.js} +3 -3
  37. package/dist/{chunk-PHK4W562.js.map → chunk-VQ47KWD5.js.map} +1 -1
  38. package/dist/chunk-WUJXJC5O.js +14568 -0
  39. package/dist/chunk-WUJXJC5O.js.map +1 -0
  40. package/dist/{chunk-EHGVROVV.js → chunk-ZORVNOKX.js} +6 -25
  41. package/dist/chunk-ZORVNOKX.js.map +1 -0
  42. package/dist/config/index.d.ts +53 -11
  43. package/dist/config/index.js +5 -5
  44. package/dist/controls/index.d.ts +6 -3
  45. package/dist/controls/index.js +4 -4
  46. package/dist/dragdrop/index.d.ts +4 -3
  47. package/dist/index.d.ts +139 -114
  48. package/dist/index.js +470 -479
  49. package/dist/index.js.map +1 -1
  50. package/dist/layers/index.d.ts +6 -35
  51. package/dist/layers/index.js +2 -3
  52. package/dist/loupe/index.d.ts +6 -5
  53. package/dist/passthrough/weasel-ui.d.ts +421 -215
  54. package/dist/passthrough/weasel-ui.js +1 -1
  55. package/dist/primitives/index.d.ts +2 -13
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +6 -5
  58. package/dist/state/index.js +5 -5
  59. package/dist/styles.css +410 -190
  60. package/dist/undo/index.d.ts +4 -3
  61. package/package.json +9 -13
  62. package/src/annotations/frac.test.ts +7 -0
  63. package/src/annotations/paint.test.ts +21 -0
  64. package/src/annotations/paint.ts +12 -0
  65. package/src/annotations/store.test.ts +31 -0
  66. package/src/annotations/types.ts +3 -2
  67. package/src/canvas/Stage.test.tsx +18 -0
  68. package/src/canvas/index.ts +0 -9
  69. package/src/chrome/labTypes.ts +1 -1
  70. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  71. package/src/chrome/regions/PaletteRegion.tsx +5 -4
  72. package/src/chrome/regions/SidebarRegion.less +197 -0
  73. package/src/chrome/regions/SidebarRegion.test.tsx +20 -0
  74. package/src/chrome/regions/SidebarRegion.tsx +7 -2
  75. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  76. package/src/chrome/types.ts +13 -4
  77. package/src/config/autoConfig.ts +2 -2
  78. package/src/config/builder.ts +23 -5
  79. package/src/config/entry.test.ts +8 -0
  80. package/src/config/index.ts +2 -0
  81. package/src/config/rules.ts +2 -0
  82. package/src/config/sectionTree.test.ts +146 -0
  83. package/src/config/sectionTree.ts +124 -0
  84. package/src/config/types.ts +4 -4
  85. package/src/controls/AutoControls.stories.tsx +6 -6
  86. package/src/controls/ControlPanel.less +27 -0
  87. package/src/controls/ControlPanel.stories.tsx +95 -1
  88. package/src/controls/ControlPanel.test.tsx +82 -112
  89. package/src/controls/ControlPanel.tsx +159 -396
  90. package/src/controls/inDialog.test.tsx +82 -0
  91. package/src/controls/inDialog.tsx +46 -0
  92. package/src/controls/index.ts +1 -0
  93. package/src/index.test.ts +0 -2
  94. package/src/index.ts +14 -35
  95. package/src/instrument/types.ts +5 -2
  96. package/src/lab/Lab.nebula.test.tsx +31 -0
  97. package/src/lab/Lab.tsx +57 -18
  98. package/src/lab/LabBoundary.test.tsx +66 -0
  99. package/src/lab/LabBoundary.tsx +56 -0
  100. package/src/lab/LabHeader.tsx +4 -23
  101. package/src/lab/LabRoot.tsx +2 -3
  102. package/src/lab/LabShell.less +0 -1
  103. package/src/lab/LabShell.stories.tsx +15 -0
  104. package/src/lab/LabShell.test.tsx +18 -0
  105. package/src/lab/LabShell.tsx +14 -1
  106. package/src/lab/LabSwitcher.less +1 -1
  107. package/src/lab/LabSwitcher.test.tsx +41 -0
  108. package/src/lab/LabSwitcher.tsx +30 -8
  109. package/src/lab/index.ts +1 -0
  110. package/src/layers/AGENTS.md +18 -74
  111. package/src/layers/index.ts +4 -2
  112. package/src/passthrough/weasel-ui.ts +25 -10
  113. package/src/primitives/FpsMeter.less +6 -1
  114. package/src/primitives/FpsMeter.test.tsx +12 -4
  115. package/src/primitives/FpsMeter.tsx +6 -1
  116. package/src/primitives/JobProgress.stories.tsx +1 -1
  117. package/src/primitives/StatusBar.less +6 -0
  118. package/src/primitives/index.ts +0 -2
  119. package/src/specimen/Specimen.tsx +42 -56
  120. package/src/state/types.ts +8 -2
  121. package/src/state/view.test.ts +4 -4
  122. package/src/styles.less +4 -2
  123. package/src/theme/Interstellar.stories.tsx +8 -9
  124. package/src/theme/base.less +3 -20
  125. package/src/theme/interstellar.test.ts +6 -3
  126. package/src/theme/interstellar.theme.json +0 -56
  127. package/src/theme/interstellar.ts +2 -2
  128. package/src/trial/Trial.canvas.test.tsx +9 -5
  129. package/src/trial/Trial.config.test.tsx +1 -1
  130. package/src/trial/Trial.tsx +30 -8
  131. package/dist/_dts/index-BNdz-tNd.d.ts +0 -423
  132. package/dist/chunk-7PFH3CNM.js.map +0 -1
  133. package/dist/chunk-CEIUBPPW.js.map +0 -1
  134. package/dist/chunk-EH7UEQV7.js +0 -590
  135. package/dist/chunk-EH7UEQV7.js.map +0 -1
  136. package/dist/chunk-EHGVROVV.js.map +0 -1
  137. package/dist/chunk-IHUVQ3TX.js.map +0 -1
  138. package/dist/chunk-MDVTK5U3.js +0 -170
  139. package/dist/chunk-MDVTK5U3.js.map +0 -1
  140. package/dist/chunk-PJ7WEQBJ.js +0 -18
  141. package/dist/chunk-PJ7WEQBJ.js.map +0 -1
  142. package/dist/chunk-T3EC2DXD.js +0 -12628
  143. package/dist/chunk-T3EC2DXD.js.map +0 -1
  144. package/dist/chunk-UN4U4P63.js.map +0 -1
  145. package/dist/chunk-ZDHY47EV.js.map +0 -1
  146. package/dist/ui/layers/index.d.ts +0 -66
  147. package/dist/ui/layers/index.js +0 -3
  148. package/dist/ui/layers/index.js.map +0 -1
  149. package/src/canvas/useOrbit.test.ts +0 -167
  150. package/src/canvas/useOrbit.ts +0 -156
  151. package/src/lab/useSystemMode.ts +0 -30
  152. package/src/layers/LayerList.less +0 -93
  153. package/src/layers/LayerList.stories.tsx +0 -66
  154. package/src/layers/LayerList.test.tsx +0 -232
  155. package/src/layers/LayerList.tsx +0 -268
  156. package/src/primitives/Sidebar.less +0 -112
  157. package/src/primitives/Sidebar.stories.tsx +0 -38
  158. package/src/primitives/Sidebar.test.tsx +0 -74
  159. package/src/primitives/Sidebar.tsx +0 -35
  160. package/src/ui/layers/index.ts +0 -5
@@ -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
@@ -110,7 +117,7 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
110
117
  return this.opt({ unpinned: true });
111
118
  }
112
119
 
113
- /** Never auto. The row takes no pin dot and ignores the gesture. */
120
+ /** Never auto. The row's label does not toggle. */
114
121
  manual(): this {
115
122
  return this.opt({ manual: true });
116
123
  }
@@ -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
 
@@ -254,9 +270,8 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
254
270
  return this.with({ ...this.annotations, name }, this.options);
255
271
  }
256
272
 
257
- /** Longer help text. Carried on the resolved `PrefGroup`, where weasel-ui's
258
- * `PrefsForm` draws it under the heading; `ControlPanel` has no place for
259
- * it yet and shows the heading alone. */
273
+ /** Longer help text. Carried on the resolved `PrefGroup`; both weasel-ui's
274
+ * `PrefsForm` and `ControlPanel` draw it under the heading. */
260
275
  describe(description: string): GroupNode<S> {
261
276
  return this.with({ ...this.annotations, description }, this.options);
262
277
  }
@@ -305,6 +320,9 @@ export const f = {
305
320
  boolean: (def: boolean): BooleanNode => new BooleanNode(def),
306
321
  string: (def: string): StringNode => new StringNode(def),
307
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]),
308
326
 
309
327
  /** A fixed set of choices. The default's literal type flows into the config
310
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
 
@@ -0,0 +1,146 @@
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(['weight']);
74
+ });
75
+
76
+ it('drops a section nothing in it is showing, rather than opening it onto nothing', () => {
77
+ const resolved = resolveConfigSchema(
78
+ f.schema({
79
+ mode: f.enum<string>('a', ['a', 'b']).section('View'),
80
+ glow: f.number(1).section('Diff').showIf((c) => c.mode === 'b'),
81
+ }),
82
+ );
83
+ expect(Object.keys(sectionTree(resolved, { mode: 'b', glow: 1 }).group.children)).toEqual([
84
+ 'view',
85
+ 'diff',
86
+ ]);
87
+ expect(Object.keys(sectionTree(resolved, { mode: 'a', glow: 1 }).group.children)).toEqual([
88
+ 'view',
89
+ ]);
90
+ });
91
+
92
+ it('keeps a hidden leaf out unless asked for it', () => {
93
+ const resolved = resolveConfigSchema(
94
+ f.schema({ seed: f.number(1).hidden().section('Debug'), route: f.boolean(true).section('Debug') }),
95
+ );
96
+ expect(Object.keys(groupAt(sectionTree(resolved, {}).group, 'debug').children)).toEqual([
97
+ 'route',
98
+ ]);
99
+ expect(Object.keys(groupAt(sectionTree(resolved, {}, true).group, 'debug').children)).toEqual([
100
+ 'seed',
101
+ 'route',
102
+ ]);
103
+ });
104
+
105
+ it('leaves a node no section claimed at the top level', () => {
106
+ const resolved = resolveConfigSchema(
107
+ f.schema({ lift: f.number(1).section('View'), loose: f.number(2) }),
108
+ );
109
+ const { group, values } = sectionTree(resolved, { lift: 1, loose: 2 });
110
+ expect(Object.keys(group.children)).toEqual(['view', 'loose']);
111
+ expect(values.loose).toBe(2);
112
+ });
113
+
114
+ it('pathAt gives back the config path a rail path stands for', () => {
115
+ const resolved = resolveConfigSchema(
116
+ f.schema({
117
+ cards: f.group({ floor: f.number(1) }).section('View'),
118
+ loose: f.number(2),
119
+ }),
120
+ );
121
+ const { pathAt } = sectionTree(resolved, { cards: { floor: 1 }, loose: 2 });
122
+ expect(pathAt('view.cards.floor')).toBe('cards.floor');
123
+ expect(pathAt('loose')).toBe('loose');
124
+ });
125
+
126
+ it('keys two sections whose labels slug the same apart', () => {
127
+ const resolved = resolveConfigSchema(
128
+ f.schema({ a: f.number(1).section('Hold on'), b: f.number(2).section('Hold-on') }),
129
+ );
130
+ const { group } = sectionTree(resolved, { a: 1, b: 2 });
131
+ expect(Object.keys(group.children)).toEqual(['hold-on', 'hold-on-2']);
132
+ });
133
+
134
+ it('ignores a section nested under a group, which buckets rows in a pane', () => {
135
+ const resolved = resolveConfigSchema(
136
+ f.schema({
137
+ cards: f
138
+ .group({ floor: f.number(1).section('Size') })
139
+ .section('View'),
140
+ }),
141
+ );
142
+ const { group } = sectionTree(resolved, { cards: { floor: 1 } });
143
+ expect(Object.keys(group.children)).toEqual(['view']);
144
+ expect(Object.keys(groupAt(groupAt(group, 'view'), 'cards').children)).toEqual(['floor']);
145
+ });
146
+ });
@@ -0,0 +1,124 @@
1
+ import { isPrefLeaf, type PrefGroup } 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
+ /** A section label as an object key: no dots, since a dotted path is how every
26
+ * caller addresses a node, and a label is free to hold one. */
27
+ function slugOf(label: string, taken: ReadonlySet<string>): string {
28
+ const base = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section';
29
+ if (!taken.has(base)) return base;
30
+ for (let n = 2; ; n += 1) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
31
+ }
32
+
33
+ /** A subtree with everything `showIf` or `hidden` rules out cut away, or null
34
+ * when that is all of it. A group whose children all went is not a rail item
35
+ * the reader can open onto nothing. */
36
+ function prune(
37
+ resolved: ResolvedConfig,
38
+ group: PrefGroup,
39
+ at: string,
40
+ config: Record<string, unknown>,
41
+ showHidden: boolean,
42
+ ): PrefGroup | null {
43
+ const children: PrefGroup['children'] = {};
44
+ for (const [key, child] of Object.entries(group.children)) {
45
+ const path = at === '' ? key : `${at}.${key}`;
46
+ if (!isLeafVisible(resolved, path, config, showHidden)) continue;
47
+ if (isPrefLeaf(child)) {
48
+ children[key] = child;
49
+ continue;
50
+ }
51
+ const kept = prune(resolved, child, path, config, showHidden);
52
+ if (kept) children[key] = kept;
53
+ }
54
+ return Object.keys(children).length === 0 ? null : { ...group, children };
55
+ }
56
+
57
+ /**
58
+ * Turn a resolved schema's root sections into the top level of a `PrefGroup`,
59
+ * for `PrefsForm`'s rail layout.
60
+ *
61
+ * A section naming a group brings that group in whole, so it lands as an
62
+ * indented rail item; a section naming a leaf drops it loose into the
63
+ * section's own pane. Sections nested under a group are left alone — they
64
+ * bucket rows inside a pane, which is the panel's job and not the rail's.
65
+ */
66
+ export function sectionTree(
67
+ resolved: ResolvedConfig,
68
+ config: Record<string, unknown>,
69
+ showHidden = false,
70
+ ): SectionTree {
71
+ const children: PrefGroup['children'] = {};
72
+ const values: Record<string, unknown> = {};
73
+ const slugs = new Set<string>();
74
+ const claimed = new Set<string>();
75
+
76
+ for (const section of resolved.sections) {
77
+ if (section.at !== '') continue;
78
+ const kept: PrefGroup['children'] = {};
79
+ const held: Record<string, unknown> = {};
80
+ for (const path of section.paths) {
81
+ claimed.add(path);
82
+ const child = resolved.group.children[path];
83
+ if (!child || !isLeafVisible(resolved, path, config, showHidden)) continue;
84
+ if (isPrefLeaf(child)) {
85
+ kept[path] = child;
86
+ } else {
87
+ const subtree = prune(resolved, child, path, config, showHidden);
88
+ if (!subtree) continue;
89
+ kept[path] = subtree;
90
+ }
91
+ held[path] = valueAtPath(config, path);
92
+ }
93
+ if (Object.keys(kept).length === 0) continue;
94
+ const slug = slugOf(section.label, slugs);
95
+ slugs.add(slug);
96
+ children[slug] = { name: section.label, children: kept };
97
+ values[slug] = held;
98
+ }
99
+
100
+ // Whatever no section claimed stays at the top level, where `prefRailItems`
101
+ // gathers loose leaves into one item named for the root.
102
+ for (const [key, child] of Object.entries(resolved.group.children)) {
103
+ if (claimed.has(key) || !isLeafVisible(resolved, key, config, showHidden)) continue;
104
+ if (isPrefLeaf(child)) {
105
+ children[key] = child;
106
+ } else {
107
+ const subtree = prune(resolved, child, key, config, showHidden);
108
+ if (!subtree) continue;
109
+ children[key] = subtree;
110
+ }
111
+ values[key] = valueAtPath(config, key);
112
+ }
113
+
114
+ return {
115
+ group: { name: resolved.group.name, children },
116
+ values,
117
+ pathAt: (railPath) => {
118
+ const cut = railPath.indexOf('.');
119
+ const head = cut === -1 ? railPath : railPath.slice(0, cut);
120
+ if (!slugs.has(head)) return railPath;
121
+ return cut === -1 ? '' : railPath.slice(cut + 1);
122
+ },
123
+ };
124
+ }
@@ -123,15 +123,15 @@ export interface NodeOptions extends BranchOptions {
123
123
  render?: ControlRenderer;
124
124
  /** Errors for this leaf under `config`, the instrument's whole config. */
125
125
  validate?: (leaf: PrefLeaf, config: Record<string, unknown>) => string[];
126
- /** Computes this leaf's value while it is auto. Attaching one is what lets
127
- * the ghosted control draw a real value and keeps the instrument off
126
+ /** Computes this leaf's value while it is auto. Attaching one is what gives
127
+ * the row a real value to pin back to and keeps the instrument off
128
128
  * `?? compute()`. */
129
129
  autoResolve?: (config: Record<string, unknown>) => unknown;
130
130
  /** The leaf starts auto rather than pinned at its default. Set by
131
131
  * `.initial(auto)`. */
132
132
  unpinned?: boolean;
133
- /** The leaf can never be auto: the row takes no pin dot and ignores the
134
- * gesture. For a value the instrument cannot receive as `undefined`. */
133
+ /** The leaf can never be auto: the row's label does not toggle. For a value
134
+ * the instrument cannot receive as `undefined`. */
135
135
  manual?: boolean;
136
136
  }
137
137
 
@@ -37,7 +37,7 @@ const schema = f.schema({
37
37
 
38
38
  const resolved = resolveConfigSchema(schema, []);
39
39
 
40
- /** Every path the panel will draw ghosted, except the `.manual()` one. */
40
+ /** Every path the panel will draw as auto, except the `.manual()` one. */
41
41
  const EVERY_AUTO = ['gap', 'cols', 'wave', 'mode', 'showGrid', 'title', 'tint'];
42
42
 
43
43
  function Panel({ start }: { start: readonly string[] }) {
@@ -73,16 +73,16 @@ function Pair() {
73
73
  }
74
74
 
75
75
  /**
76
- * Both states of every row kind, side by side. What to look at: an auto row's
77
- * control keeps no accent color, its readout reads `auto · 18 px` rather than a
78
- * number, and the pin dot appears only under the pointer — except on an auto
79
- * row, where it stays. `Seed` is `.manual()` and takes no dot at all.
76
+ * Both states of every row kind, side by side. What to look at: an auto row
77
+ * shows no control at all — only its label and the word `auto` — and still
78
+ * takes exactly the room it took while pinned, so nothing below it moves on
79
+ * the toggle. `Seed` is `.manual()` and does not toggle.
80
80
  */
81
81
  export const BothStates: Story = {
82
82
  render: () => <Pair />,
83
83
  };
84
84
 
85
- /** One panel to shift-click around in: the handle must not move as it toggles. */
85
+ /** One panel to click labels around in: the row must not resize as it toggles. */
86
86
  export const Interactive: Story = {
87
87
  render: () => <Panel start={['gap']} />,
88
88
  };
@@ -2,6 +2,33 @@
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;
11
+ }
12
+
13
+ // A cell in a paired row, with the caption naming the knob it holds. Case,
14
+ // tracking, alignment and width come from the --wzl-params-label-* properties,
15
+ // which inherit across the CSS-module boundary that keeps PropertyPanel's row
16
+ // label class out of reach from here.
17
+ .lk-pair-cell {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ gap: var(--wzl-space-3, 6px);
21
+ min-width: 0;
22
+ }
23
+
24
+ .lk-pair-label {
25
+ font-family: var(--wzl-font-display);
26
+ font-weight: var(--wzl-font-weight-light);
27
+ font-size: var(--wzl-font-size-sm);
28
+ color: var(--wzl-fg-muted);
29
+ text-transform: var(--wzl-params-label-case, uppercase);
30
+ letter-spacing: var(--wzl-params-label-tracking, var(--wzl-tracking-wide));
31
+ text-align: var(--wzl-params-label-align, start);
32
+ inline-size: var(--wzl-params-label-width, auto);
33
+ white-space: nowrap;
7
34
  }
@@ -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';
@@ -275,3 +275,97 @@ export const Presentation: Story = {
275
275
  );
276
276
  },
277
277
  };
278
+
279
+ const inline = f.schema({
280
+ folderPad: f.number(1.1).range(0, 4).step(0.1).label('Folder pad'),
281
+ glyphs: f.number(1_000_000).range(0, 2_000_000).format('compact'),
282
+ names: f.number(60).range(0, 100),
283
+ labels: f.number(0).range(0, 100),
284
+ placement: f.enum('spread', ['spread', 'stack', 'ring']),
285
+ lift: f.number(0.2).range(0, 1).step(0.05),
286
+ opacity: f.number(0.45).range(0, 1).step(0.05),
287
+ iconSet: f.enum('small', ['small', 'large']).label('Icon set'),
288
+ drawAsIcons: f
289
+ .list(['**/node_modules/**', '**/dist/**', 'package-lock.json'])
290
+ .label('Draw as icons'),
291
+ detail: f.number(1200).range(0, 4000).suffix('nodes'),
292
+ showCameraRoute: f.boolean(false).toggle().label('Show camera route'),
293
+ routeColor: f.enum('sweep', ['sweep', 'depth']).label('Route color'),
294
+ minimap: f.boolean(true).toggle(),
295
+ minimapSize: f.number(280).range(80, 600).label('Minimap size'),
296
+ pickHud: f.boolean(true).toggle().label('Pick HUD'),
297
+ pickOutlines: f.boolean(true).toggle().label('Pick outlines'),
298
+ markOverlay: f.boolean(false).toggle().label('Mark overlay'),
299
+ });
300
+
301
+ /** The shape a lab's sidebar draws: `layout="inline"` paired two to a row, in a
302
+ * narrow column. Every row kind meets here — slider, select, switch, text —
303
+ * and they have to share one base height, or the column reads as ragged. */
304
+ export const Inline: Story = {
305
+ render: () => {
306
+ const schema = resolveConfigSchema(inline, []);
307
+ const [config, setConfig] = useState<Record<string, unknown>>(inline.defaults());
308
+ return (
309
+ <div style={{ width: 360 }}>
310
+ <ControlPanel
311
+ schema={schema}
312
+ config={config}
313
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
314
+ layout="inline"
315
+ align="center"
316
+ />
317
+ </div>
318
+ );
319
+ },
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
+ };