@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
@@ -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
+ }
@@ -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' });
@@ -478,7 +482,7 @@ describe('<ControlPanel> format', () => {
478
482
  setConfig={vi.fn()}
479
483
  />,
480
484
  );
481
- expect(screen.getByDisplayValue('2.0M')).toBeInTheDocument();
485
+ expect(screen.getByDisplayValue('2.00M')).toBeInTheDocument();
482
486
  });
483
487
  });
484
488
 
@@ -493,7 +497,9 @@ describe('<ControlPanel> group descriptions', () => {
493
497
  }),
494
498
  [],
495
499
  );
496
- render(<ControlPanel schema={schema} config={{ grid: { cellSize: 20 } }} setConfig={vi.fn()} />);
500
+ render(
501
+ <ControlPanel schema={schema} config={{ grid: { cellSize: 20 } }} setConfig={vi.fn()} />,
502
+ );
497
503
  const note = screen.getByText('How the background lattice is spaced.');
498
504
  const field = screen.getByLabelText('Cell size');
499
505
  expect(note.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -1064,3 +1070,24 @@ describe('<ControlPanel> color alpha', () => {
1064
1070
  expect(screen.queryByLabelText('Tint opacity')).toBeNull();
1065
1071
  });
1066
1072
  });
1073
+
1074
+ describe('<ControlPanel> title, stance and tone', () => {
1075
+ const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: true }];
1076
+
1077
+ it('renders the bare list when given none of them', () => {
1078
+ const { container } = render(<ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />);
1079
+ expect(container.firstElementChild?.classList.contains('lk-control-panel')).toBe(true);
1080
+ expect(container.querySelector('[data-stance], h2')).toBeNull();
1081
+ });
1082
+
1083
+ it('wraps its rows in a panel carrying them when asked', () => {
1084
+ const { container } = render(
1085
+ <ControlPanel title="View" stance="scope" tone={1} fields={fields} config={{ on: true }} setConfig={vi.fn()} />,
1086
+ );
1087
+ const panel = container.firstElementChild as HTMLElement;
1088
+ expect(panel.dataset.stance).toBe('scope');
1089
+ expect(panel.dataset.tone).toBe('1');
1090
+ expect(screen.getByRole('heading', { name: 'View' })).toBeTruthy();
1091
+ expect(panel.querySelector('.lk-control-panel')).not.toBeNull();
1092
+ });
1093
+ });