@weasel-js/labkit 1.5.1 → 1.5.2

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 (92) hide show
  1. package/dist/_dts/{CanvasStackContext-CJNMS6mz.d.ts → CanvasStackContext-CMMo180l.d.ts} +1 -1
  2. package/dist/_dts/{Select.d-Gwr8fLpK.d.ts → Select.d-ZTiwzYyT.d.ts} +27 -1
  3. package/dist/_dts/{frac-B4okC2lX.d.ts → frac-B4hFJMiN.d.ts} +16 -6
  4. package/dist/_dts/{index-BNdz-tNd.d.ts → index-8LiKe3A-.d.ts} +30 -43
  5. package/dist/_dts/{index-CSJoj4LC.d.ts → index-DlKolEIZ.d.ts} +5 -4
  6. package/dist/_dts/{types-DzoNY1pO.d.ts → types-Db-3NNk1.d.ts} +4 -4
  7. package/dist/_dts/{usePanZoom-Pzf9f2jh.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  8. package/dist/_dts/{useTrialState-uLlmaccn.d.ts → useTrialState-pnAE-UBD.d.ts} +1 -1
  9. package/dist/canvas/index.d.ts +4 -4
  10. package/dist/canvas/index.js +1 -1
  11. package/dist/chrome/index.d.ts +3 -3
  12. package/dist/chrome/index.js +6 -6
  13. package/dist/{chunk-PJ7WEQBJ.js → chunk-6BUQ77ZV.js} +4 -4
  14. package/dist/{chunk-PJ7WEQBJ.js.map → chunk-6BUQ77ZV.js.map} +1 -1
  15. package/dist/{chunk-UN4U4P63.js → chunk-753Y6HE6.js} +9 -9
  16. package/dist/chunk-753Y6HE6.js.map +1 -0
  17. package/dist/{chunk-ZDHY47EV.js → chunk-A2ACAGW6.js} +6 -7
  18. package/dist/chunk-A2ACAGW6.js.map +1 -0
  19. package/dist/{chunk-IHUVQ3TX.js → chunk-CKUKF72R.js} +10 -10
  20. package/dist/chunk-CKUKF72R.js.map +1 -0
  21. package/dist/{chunk-MDVTK5U3.js → chunk-D35PFTS7.js} +3 -3
  22. package/dist/{chunk-MDVTK5U3.js.map → chunk-D35PFTS7.js.map} +1 -1
  23. package/dist/{chunk-PHK4W562.js → chunk-EMXAIRUJ.js} +3 -3
  24. package/dist/{chunk-PHK4W562.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  25. package/dist/{chunk-EH7UEQV7.js → chunk-FKLT3ITC.js} +69 -69
  26. package/dist/chunk-FKLT3ITC.js.map +1 -0
  27. package/dist/{chunk-EHGVROVV.js → chunk-KJCUERQF.js} +3 -3
  28. package/dist/{chunk-EHGVROVV.js.map → chunk-KJCUERQF.js.map} +1 -1
  29. package/dist/chunk-LASCBWW7.js +13456 -0
  30. package/dist/chunk-LASCBWW7.js.map +1 -0
  31. package/dist/{chunk-IXTQI7JX.js → chunk-LRI3MBGR.js} +4 -4
  32. package/dist/{chunk-IXTQI7JX.js.map → chunk-LRI3MBGR.js.map} +1 -1
  33. package/dist/{chunk-7PFH3CNM.js → chunk-REOBGYMC.js} +4 -4
  34. package/dist/{chunk-7PFH3CNM.js.map → chunk-REOBGYMC.js.map} +1 -1
  35. package/dist/{chunk-CEIUBPPW.js → chunk-TT5C5S6R.js} +3 -86
  36. package/dist/chunk-TT5C5S6R.js.map +1 -0
  37. package/dist/config/index.d.ts +7 -8
  38. package/dist/config/index.js +5 -5
  39. package/dist/controls/index.d.ts +2 -2
  40. package/dist/controls/index.js +4 -4
  41. package/dist/dragdrop/index.d.ts +2 -2
  42. package/dist/index.d.ts +72 -28
  43. package/dist/index.js +142 -123
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +3 -3
  46. package/dist/layers/index.js +2 -2
  47. package/dist/loupe/index.d.ts +4 -4
  48. package/dist/passthrough/weasel-ui.d.ts +33 -30
  49. package/dist/passthrough/weasel-ui.js +1 -1
  50. package/dist/primitives/index.js +3 -3
  51. package/dist/state/index.d.ts +4 -4
  52. package/dist/state/index.js +5 -5
  53. package/dist/styles.css +114 -18
  54. package/dist/ui/layers/index.js +1 -1
  55. package/dist/undo/index.d.ts +2 -2
  56. package/package.json +9 -9
  57. package/src/canvas/index.ts +0 -9
  58. package/src/chrome/labTypes.ts +1 -1
  59. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  60. package/src/chrome/regions/PaletteRegion.tsx +5 -4
  61. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  62. package/src/chrome/types.ts +8 -2
  63. package/src/config/builder.ts +3 -4
  64. package/src/config/types.ts +4 -4
  65. package/src/controls/AutoControls.stories.tsx +6 -6
  66. package/src/controls/ControlPanel.less +23 -0
  67. package/src/controls/ControlPanel.stories.tsx +41 -0
  68. package/src/controls/ControlPanel.test.tsx +53 -110
  69. package/src/controls/ControlPanel.tsx +61 -77
  70. package/src/index.test.ts +0 -2
  71. package/src/index.ts +4 -10
  72. package/src/lab/Lab.tsx +21 -5
  73. package/src/layers/LayerList.less +1 -1
  74. package/src/layers/LayerList.stories.tsx +1 -1
  75. package/src/primitives/FpsMeter.less +6 -1
  76. package/src/primitives/FpsMeter.test.tsx +12 -4
  77. package/src/primitives/FpsMeter.tsx +6 -1
  78. package/src/primitives/JobProgress.stories.tsx +1 -1
  79. package/src/primitives/StatusBar.less +6 -0
  80. package/src/state/types.ts +6 -1
  81. package/src/state/view.test.ts +4 -4
  82. package/src/trial/Trial.canvas.test.tsx +8 -4
  83. package/src/trial/Trial.config.test.tsx +1 -1
  84. package/dist/chunk-CEIUBPPW.js.map +0 -1
  85. package/dist/chunk-EH7UEQV7.js.map +0 -1
  86. package/dist/chunk-IHUVQ3TX.js.map +0 -1
  87. package/dist/chunk-T3EC2DXD.js +0 -12628
  88. package/dist/chunk-T3EC2DXD.js.map +0 -1
  89. package/dist/chunk-UN4U4P63.js.map +0 -1
  90. package/dist/chunk-ZDHY47EV.js.map +0 -1
  91. package/src/canvas/useOrbit.test.ts +0 -167
  92. package/src/canvas/useOrbit.ts +0 -156
@@ -1,3 +1,3 @@
1
- export { c4 as LayerStack } from '../../chunk-T3EC2DXD.js';
1
+ export { c4 as LayerStack } from '../../chunk-LASCBWW7.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
- export { at as UndoCapability } from '../_dts/frac-B4okC2lX.js';
1
+ export { au as UndoCapability } from '../_dts/frac-B4hFJMiN.js';
2
2
  import 'react';
3
- import '../_dts/types-DzoNY1pO.js';
3
+ import '../_dts/types-Db-3NNk1.js';
4
4
  import '@weasel-js/core';
5
5
  import '../_dts/PrefsForm.d-DPy9I8It.js';
6
6
  import '../_dts/types-DJ79Tg5J.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weasel-js/labkit",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "React widgets for building self-contained interactive lab pages",
5
5
  "license": "MIT",
6
6
  "author": "orochi235",
@@ -115,17 +115,17 @@
115
115
  "dev:3d": "LABKIT_EXAMPLE=3d-lab vite"
116
116
  },
117
117
  "peerDependencies": {
118
- "@weasel-js/core": "1.5.1",
119
- "@weasel-js/theme": "1.5.1",
118
+ "@weasel-js/core": "1.5.2",
119
+ "@weasel-js/theme": "1.5.2",
120
120
  "react": "^19.0.0",
121
121
  "react-dom": "^19.0.0"
122
122
  },
123
123
  "dependencies": {
124
- "@weasel-js/geom": "1.5.1",
125
- "@weasel-js/kernel3d": "1.5.1",
126
- "@weasel-js/loupe": "1.5.1",
127
- "@weasel-js/svg": "1.5.1",
128
- "@weasel-js/ui": "1.5.1",
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",
129
129
  "earcut": "2.2.4",
130
130
  "polygon-clipping": "^0.15.7",
131
131
  "react-aria-components": "^1.5.0",
@@ -136,7 +136,7 @@
136
136
  "@biomejs/biome": "^2.4.12",
137
137
  "@rollup/plugin-alias": "^6.0.0",
138
138
  "@testing-library/jest-dom": "^6.6.0",
139
- "@weasel-js/modes": "1.5.1",
139
+ "@weasel-js/modes": "1.5.2",
140
140
  "less": "^4.2.0",
141
141
  "rollup-plugin-dts": "^6.4.1",
142
142
  "tsx": "^4.19.0",
@@ -10,15 +10,6 @@ export { screenToWorld, worldToScreen } from './canvasCoords';
10
10
  export type { StageProps } from './Stage';
11
11
  export { fitStage, Stage } from './Stage';
12
12
  export type { CanvasLayerDescriptor } from './useLayerScheduler';
13
- export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './useOrbit';
14
- export {
15
- clampPitch,
16
- orbitAfterDrag,
17
- orbitAfterWheel,
18
- PITCH_LIMIT,
19
- useOrbit,
20
- wrapYaw,
21
- } from './useOrbit';
22
13
  export type { PanZoomHandlers, UsePanZoomOptions } from './usePanZoom';
23
14
  export { usePanZoom } from './usePanZoom';
24
15
  export type { ViewportSize, WorldFrame, WorldSpec } from './worldSpec';
@@ -40,7 +40,7 @@ export type LabContribution<TCtx = LabChromeContext> =
40
40
  item: ToolbarItem<TCtx>;
41
41
  render?: never;
42
42
  })
43
- | (ContributionBase & { region: 'palette'; item: ToolItem; render?: never })
43
+ | (ContributionBase & { region: 'palette'; item: ToolItem<TCtx>; render?: never })
44
44
  | (ContributionBase & { region: 'sidebar' | 'aside'; item: SidebarSection; render?: never })
45
45
  | (ContributionBase & { region: 'footer'; item: StatusReadout; render?: never })
46
46
  | (ContributionBase & {
@@ -36,6 +36,24 @@ describe('PaletteRegion', () => {
36
36
  expect(setActiveTool).toHaveBeenCalledWith('brush');
37
37
  });
38
38
 
39
+ it('runs a command tool instead of selecting it, and never marks it current', () => {
40
+ const onActivate = vi.fn();
41
+ const setActiveTool = vi.fn();
42
+ const ctx = ctxWith('info', setActiveTool);
43
+ const info: RegionContribution<ToolSlotContext> = {
44
+ id: 'info',
45
+ region: 'palette',
46
+ item: { icon: Glyph, label: 'Info', onActivate },
47
+ };
48
+ render(<PaletteRegion contributions={[info]} ctx={ctx} />);
49
+ const button = screen.getByRole('button', { name: 'Info' });
50
+ button.click();
51
+ expect(onActivate).toHaveBeenCalledWith(ctx);
52
+ expect(setActiveTool).not.toHaveBeenCalled();
53
+ // The slot names this id, and a command still must not read as held down.
54
+ expect(button).not.toHaveAttribute('aria-current');
55
+ });
56
+
39
57
  it('puts exactly one tool in the tab order', () => {
40
58
  render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
41
59
  expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('tabindex', '0');
@@ -12,7 +12,8 @@ export interface PaletteRegionProps<TCtx extends ToolSlotContext = TrialChromeCo
12
12
  }
13
13
 
14
14
  /** A tool strip: a trial's, or the lab's. Selection lives in whichever tool
15
- * slot the context carries; this region only reflects it. */
15
+ * slot the context carries; this region only reflects it. An item carrying
16
+ * `onActivate` is a command instead — it presses, and never latches. */
16
17
  export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>({
17
18
  contributions,
18
19
  ctx,
@@ -33,16 +34,16 @@ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>
33
34
  {contributions.map((c) => {
34
35
  if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
35
36
  if (c.region !== region || !c.item) return null;
36
- const { icon: Icon, label, shortcut, disabled } = c.item as ToolItem;
37
+ const { icon: Icon, label, shortcut, disabled, onActivate } = c.item as ToolItem<TCtx>;
37
38
  return (
38
39
  <ToolButton
39
40
  key={c.id}
40
41
  icon={<Icon size={16} />}
41
42
  label={label}
42
43
  shortcut={shortcut}
43
- active={ctx.activeToolId === c.id}
44
+ active={onActivate ? false : ctx.activeToolId === c.id}
44
45
  disabled={disabled}
45
- onClick={() => ctx.setActiveTool(c.id)}
46
+ onClick={onActivate ? () => onActivate(ctx) : () => ctx.setActiveTool(c.id)}
46
47
  />
47
48
  );
48
49
  })}
@@ -32,7 +32,7 @@ function renderEntry(c: TrialContribution, ctx: TrialChromeContext): ReactNode {
32
32
  onPointerDown={(e) => e.stopPropagation()}
33
33
  onMouseDown={(e) => e.stopPropagation()}
34
34
  >
35
- <Icon size={14} />
35
+ <Icon size={16} />
36
36
  </button>
37
37
  );
38
38
  }
@@ -31,12 +31,18 @@ export interface ToolbarItem<TCtx = TrialChromeContext> {
31
31
  onActivate: (ctx: TCtx) => void;
32
32
  }
33
33
 
34
- /** A selectable tool in the palette region. */
35
- export interface ToolItem {
34
+ /** A tool in the palette region: a mode by default, a command when it carries
35
+ * an `onActivate`. `TCtx` is the chrome context that handler is handed — a
36
+ * trial's, or the lab's for a lab-level rail. */
37
+ export interface ToolItem<TCtx = TrialChromeContext> {
36
38
  icon: IconComponent;
37
39
  label: string;
40
+ /** Shown in the tooltip. Not bound here — the trial owns its keymap. */
38
41
  shortcut?: string;
39
42
  disabled?: boolean;
43
+ /** Runs on press instead of selecting. An item with one is a command: it
44
+ * never writes the tool slot, and never reads as the current tool. */
45
+ onActivate?: (ctx: TCtx) => void;
40
46
  }
41
47
 
42
48
  /** A titled block in the sidebar. */
@@ -110,7 +110,7 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
110
110
  return this.opt({ unpinned: true });
111
111
  }
112
112
 
113
- /** Never auto. The row takes no pin dot and ignores the gesture. */
113
+ /** Never auto. The row's label does not toggle. */
114
114
  manual(): this {
115
115
  return this.opt({ manual: true });
116
116
  }
@@ -254,9 +254,8 @@ export class GroupNode<S extends ConfigShape> implements ConfigBranch<S> {
254
254
  return this.with({ ...this.annotations, name }, this.options);
255
255
  }
256
256
 
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. */
257
+ /** Longer help text. Carried on the resolved `PrefGroup`; both weasel-ui's
258
+ * `PrefsForm` and `ControlPanel` draw it under the heading. */
260
259
  describe(description: string): GroupNode<S> {
261
260
  return this.with({ ...this.annotations, description }, this.options);
262
261
  }
@@ -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
  };
@@ -5,3 +5,26 @@
5
5
  .lk-control-panel {
6
6
  padding: 0;
7
7
  }
8
+
9
+ // A cell in a paired row, with the caption naming the knob it holds. Case,
10
+ // tracking, alignment and width come from the --wzl-params-label-* properties,
11
+ // which inherit across the CSS-module boundary that keeps PropertyPanel's row
12
+ // label class out of reach from here.
13
+ .lk-pair-cell {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: var(--wzl-space-3, 6px);
17
+ min-width: 0;
18
+ }
19
+
20
+ .lk-pair-label {
21
+ font-family: var(--wzl-font-display);
22
+ font-weight: var(--wzl-font-weight-light);
23
+ font-size: var(--wzl-font-size-sm);
24
+ color: var(--wzl-fg-muted);
25
+ text-transform: var(--wzl-params-label-case, uppercase);
26
+ letter-spacing: var(--wzl-params-label-tracking, var(--wzl-tracking-wide));
27
+ text-align: var(--wzl-params-label-align, start);
28
+ inline-size: var(--wzl-params-label-width, auto);
29
+ white-space: nowrap;
30
+ }
@@ -275,3 +275,44 @@ 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.string('**/node_modules/**').label('Draw as icons'),
289
+ detail: f.number(1200).range(0, 4000).suffix('nodes'),
290
+ showCameraRoute: f.boolean(false).toggle().label('Show camera route'),
291
+ routeColor: f.enum('sweep', ['sweep', 'depth']).label('Route color'),
292
+ minimap: f.boolean(true).toggle(),
293
+ minimapSize: f.number(280).range(80, 600).label('Minimap size'),
294
+ pickHud: f.boolean(true).toggle().label('Pick HUD'),
295
+ pickOutlines: f.boolean(true).toggle().label('Pick outlines'),
296
+ markOverlay: f.boolean(false).toggle().label('Mark overlay'),
297
+ });
298
+
299
+ /** The shape a lab's sidebar draws: `layout="inline"` paired two to a row, in a
300
+ * narrow column. Every row kind meets here — slider, select, switch, text —
301
+ * and they have to share one base height, or the column reads as ragged. */
302
+ export const Inline: Story = {
303
+ render: () => {
304
+ const schema = resolveConfigSchema(inline, []);
305
+ const [config, setConfig] = useState<Record<string, unknown>>(inline.defaults());
306
+ return (
307
+ <div style={{ width: 360 }}>
308
+ <ControlPanel
309
+ schema={schema}
310
+ config={config}
311
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
312
+ layout="inline"
313
+ align="center"
314
+ />
315
+ </div>
316
+ );
317
+ },
318
+ };
@@ -482,6 +482,24 @@ describe('<ControlPanel> format', () => {
482
482
  });
483
483
  });
484
484
 
485
+ describe('<ControlPanel> group descriptions', () => {
486
+ it('draws a named group’s describe() above its rows', () => {
487
+ const schema = resolveConfigSchema(
488
+ f.schema({
489
+ grid: f
490
+ .group({ cellSize: f.number(20) })
491
+ .label('Grid')
492
+ .describe('How the background lattice is spaced.'),
493
+ }),
494
+ [],
495
+ );
496
+ render(<ControlPanel schema={schema} config={{ grid: { cellSize: 20 } }} setConfig={vi.fn()} />);
497
+ const note = screen.getByText('How the background lattice is spaced.');
498
+ const field = screen.getByLabelText('Cell size');
499
+ expect(note.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
500
+ });
501
+ });
502
+
485
503
  describe('<ControlPanel> section layout', () => {
486
504
  const schema = resolveConfigSchema(
487
505
  f.schema({
@@ -675,7 +693,7 @@ describe('<ControlPanel> nested groups', () => {
675
693
  });
676
694
 
677
695
  describe('<ControlPanel> auto', () => {
678
- it('renders a pin dot whether or not it was given a set of auto paths', () => {
696
+ it('renders a label toggle whether or not it was given a set of auto paths', () => {
679
697
  render(
680
698
  <ControlPanel
681
699
  schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
@@ -708,7 +726,7 @@ describe('<ControlPanel> auto', () => {
708
726
  expect(setConfig).toHaveBeenCalledWith('gap', 12);
709
727
  });
710
728
 
711
- it("reads the resolver for an auto row's readout", () => {
729
+ it('reads out the bare word, resolver or not', () => {
712
730
  render(
713
731
  <ControlPanel
714
732
  schema={resolveConfigSchema(
@@ -725,19 +743,8 @@ describe('<ControlPanel> auto', () => {
725
743
  setConfig={() => {}}
726
744
  />,
727
745
  );
728
- expect(screen.getByText('auto · 18')).toBeInTheDocument();
729
- });
730
-
731
- it('reads "auto" alone for a path with no resolver', () => {
732
- render(
733
- <ControlPanel
734
- schema={resolveConfigSchema(f.schema({ cols: f.number(3).range(0, 8) }), [])}
735
- config={{ cols: 3 }}
736
- auto={new Set(['cols'])}
737
- setConfig={() => {}}
738
- />,
739
- );
740
746
  expect(screen.getByText('auto')).toBeInTheDocument();
747
+ expect(screen.queryByText(/auto · /)).toBeNull();
741
748
  });
742
749
 
743
750
  it('writes the sentinel when the dot turns a row auto', async () => {
@@ -794,11 +801,11 @@ describe('<ControlPanel> auto', () => {
794
801
  expect(screen.queryByRole('button', { name: /Seed/ })).toBeNull();
795
802
  });
796
803
 
797
- it('reports what the instrument reads when a resolver depends on another auto path', () => {
804
+ it('draws what the instrument reads when a resolver depends on another auto path', () => {
798
805
  // `width` is auto with no resolver, so the instrument sees `undefined` and
799
806
  // `gap` resolves from that. Re-running gap's resolver against the raw
800
- // config would instead read the pinned 640 and report a number nobody got.
801
- render(
807
+ // config would instead read the pinned 640 and draw a number nobody got.
808
+ const { container } = render(
802
809
  <ControlPanel
803
810
  schema={resolveConfigSchema(
804
811
  f.schema({
@@ -806,7 +813,7 @@ describe('<ControlPanel> auto', () => {
806
813
  gap: f
807
814
  .number(12)
808
815
  .range(0, 48)
809
- .auto((c) => (c.width === undefined ? -1 : (c.width as number) / 64)),
816
+ .auto((c) => (c.width === undefined ? 5 : (c.width as number) / 64)),
810
817
  }),
811
818
  [],
812
819
  )}
@@ -815,27 +822,7 @@ describe('<ControlPanel> auto', () => {
815
822
  setConfig={() => {}}
816
823
  />,
817
824
  );
818
- expect(screen.getByText('auto · -1')).toBeInTheDocument();
819
- expect(screen.queryByText('auto · 10')).toBeNull();
820
- });
821
-
822
- it('does not repeat a resolved value the control itself already shows', () => {
823
- render(
824
- <ControlPanel
825
- schema={resolveConfigSchema(
826
- f.schema({ caption: f.string('typed').auto(() => 'eight across') }),
827
- [],
828
- )}
829
- config={{ caption: 'typed' }}
830
- auto={new Set(['caption'])}
831
- setConfig={() => {}}
832
- />,
833
- );
834
- // The field below the readout is already showing it, and the readout slot
835
- // is one narrow column — a long string wraps it onto two lines.
836
- expect(screen.getByText('auto')).toBeInTheDocument();
837
- expect(screen.queryByText(/auto · /)).toBeNull();
838
- expect(screen.getByDisplayValue('eight across')).toBeInTheDocument();
825
+ expect(container.querySelector('input[type=range]')).toHaveValue('5');
839
826
  });
840
827
 
841
828
  it('draws an auto control at the resolved value, not the value underneath it', () => {
@@ -881,7 +868,7 @@ describe('<ControlPanel> auto', () => {
881
868
  });
882
869
 
883
870
  // Every kind, because the panel wraps some rows before rendering them and a
884
- // wrapper that drops the auto props leaves that kind silently unghosted.
871
+ // wrapper that drops the auto props leaves that kind silently pinned.
885
872
  it.each([
886
873
  ['number', f.number(3).range(0, 8)],
887
874
  ['slider', f.number(3).range(0, 8).slider()],
@@ -890,7 +877,7 @@ describe('<ControlPanel> auto', () => {
890
877
  ['radio', f.enum('a', ['a', 'b']).radio()],
891
878
  ['string', f.string('hi')],
892
879
  ['color', f.color('#3a86ff')],
893
- ])('marks a %s row auto and gives it a dot and a path', (_kind, node) => {
880
+ ])('marks a %s row auto and gives it a label toggle', (_kind, node) => {
894
881
  const { container } = render(
895
882
  <ControlPanel
896
883
  schema={resolveConfigSchema(f.schema({ thing: node.label('Thing') }), [])}
@@ -899,76 +886,11 @@ describe('<ControlPanel> auto', () => {
899
886
  setConfig={() => {}}
900
887
  />,
901
888
  );
902
- const row = container.querySelector('[data-auto-path="thing"]');
903
- expect(row).not.toBeNull();
904
- expect(row?.className).toMatch(/rowAuto/);
889
+ expect(container.querySelector('[class*=rowAuto]')).not.toBeNull();
905
890
  expect(screen.getByRole('button', { name: 'Pin Thing' })).toBeInTheDocument();
906
891
  });
907
892
  });
908
893
 
909
- describe('<ControlPanel> shift-click', () => {
910
- const panel = (props = {}) =>
911
- render(
912
- <ControlPanel
913
- schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
914
- config={{ gap: 12 }}
915
- auto={new Set()}
916
- setConfig={() => {}}
917
- {...props}
918
- />,
919
- );
920
-
921
- it('toggles a row on shift-pointerdown and suppresses the control it landed on', () => {
922
- const setConfig = vi.fn();
923
- const { container } = panel({ setConfig });
924
- const track = container.querySelector('input[type=range]') as HTMLInputElement;
925
- const ev = new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true });
926
- track.dispatchEvent(ev);
927
-
928
- expect(setConfig).toHaveBeenCalledWith('gap', auto);
929
- // A PROXY, not the real claim. jsdom synthesizes neither a label's click
930
- // retargeting nor a range input's drag, so "the slider did not move" cannot
931
- // fail here. What is assertable is that the default was suppressed; the
932
- // browser half is covered by the story.
933
- expect(ev.defaultPrevented).toBe(true);
934
- });
935
-
936
- it('leaves an ordinary pointerdown alone', () => {
937
- const setConfig = vi.fn();
938
- const { container } = panel({ setConfig });
939
- container
940
- .querySelector('input[type=range]')
941
- ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
942
- expect(setConfig).not.toHaveBeenCalled();
943
- });
944
-
945
- it('ignores shift-pointerdown on a manual row', () => {
946
- const setConfig = vi.fn();
947
- const { container } = panel({
948
- schema: resolveConfigSchema(f.schema({ seed: f.number(1).manual().range(0, 8) }), []),
949
- config: { seed: 1 },
950
- setConfig,
951
- });
952
- container
953
- .querySelector('input')
954
- ?.dispatchEvent(
955
- new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true }),
956
- );
957
- expect(setConfig).not.toHaveBeenCalled();
958
- });
959
-
960
- it('pins a shift-clicked auto row back at its stored value', () => {
961
- const setConfig = vi.fn();
962
- const { container } = panel({ config: { gap: 24 }, auto: new Set(['gap']), setConfig });
963
- container
964
- .querySelector('input[type=range]')
965
- ?.dispatchEvent(
966
- new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true }),
967
- );
968
- expect(setConfig).toHaveBeenCalledWith('gap', 24);
969
- });
970
- });
971
-
972
894
  describe('<ControlPanel> pair', () => {
973
895
  const paired = () =>
974
896
  resolveConfigSchema(
@@ -986,9 +908,13 @@ describe('<ControlPanel> pair', () => {
986
908
  const row = screen.getByText('Offset').closest('div') as HTMLElement;
987
909
  expect(within(row).getByLabelText('X')).toHaveValue(10);
988
910
  expect(within(row).getByLabelText('Y')).toHaveValue(20);
989
- // The pair names the row, so neither leaf's own name is drawn beside it.
990
- expect(screen.queryByText('X')).toBeNull();
991
- expect(screen.queryByText('Y')).toBeNull();
911
+ });
912
+
913
+ it('captions each cell of a pair with its own name, so a paired switch is not left unnamed', () => {
914
+ render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
915
+ const row = screen.getByText('Offset').closest('div') as HTMLElement;
916
+ expect(within(row).getByText('X')).toHaveClass('lk-pair-label');
917
+ expect(within(row).getByText('Y')).toHaveClass('lk-pair-label');
992
918
  });
993
919
 
994
920
  it('writes each cell of a paired row to its own path', () => {
@@ -998,6 +924,23 @@ describe('<ControlPanel> pair', () => {
998
924
  expect(setConfig).toHaveBeenCalledWith('y', 30);
999
925
  });
1000
926
 
927
+ it('keeps a segmented leaf segmented when it shares a row, and gives that row the width', () => {
928
+ const schema = resolveConfigSchema(
929
+ f.schema({
930
+ camera: f.enum<'2d' | '3d'>('3d', ['2d', '3d']).radio().label('').pair('Camera'),
931
+ hold: f.boolean(true).toggle().label('Hold front').pair('Camera'),
932
+ }),
933
+ [],
934
+ );
935
+ render(
936
+ <ControlPanel schema={schema} config={{ camera: '3d', hold: true }} setConfig={vi.fn()} />,
937
+ );
938
+ const row = screen.getByText('Camera').closest('div') as HTMLElement;
939
+ expect(within(row).getByRole('radio', { name: '2d' })).toBeInTheDocument();
940
+ expect(within(row).queryByRole('combobox')).toBeNull();
941
+ expect(row.className).toMatch(/span/);
942
+ });
943
+
1001
944
  it('leaves an unpaired sibling on its own row', () => {
1002
945
  render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
1003
946
  const row = screen.getByText('Offset').closest('div') as HTMLElement;