@weasel-js/labkit 1.5.0 → 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 (206) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CMMo180l.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-ZTiwzYyT.d.ts +479 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
  6. package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-6BUQ77ZV.js +18 -0
  16. package/dist/chunk-6BUQ77ZV.js.map +1 -0
  17. package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
  18. package/dist/chunk-753Y6HE6.js.map +1 -0
  19. package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
  20. package/dist/chunk-A2ACAGW6.js.map +1 -0
  21. package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
  22. package/dist/chunk-CKUKF72R.js.map +1 -0
  23. package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
  24. package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
  25. package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
  26. package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  27. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  28. package/dist/chunk-ERFMC6WU.js.map +1 -0
  29. package/dist/chunk-FKLT3ITC.js +590 -0
  30. package/dist/chunk-FKLT3ITC.js.map +1 -0
  31. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  32. package/dist/chunk-IJJUK4FU.js.map +1 -0
  33. package/dist/{chunk-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
  34. package/dist/chunk-KJCUERQF.js.map +1 -0
  35. package/dist/chunk-LASCBWW7.js +13456 -0
  36. package/dist/chunk-LASCBWW7.js.map +1 -0
  37. package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
  38. package/dist/chunk-LRI3MBGR.js.map +1 -0
  39. package/dist/chunk-REOBGYMC.js +150 -0
  40. package/dist/chunk-REOBGYMC.js.map +1 -0
  41. package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
  42. package/dist/chunk-TT5C5S6R.js.map +1 -0
  43. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  44. package/dist/chunk-Z76KEZAX.js.map +1 -0
  45. package/dist/config/index.d.ts +62 -19
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +230 -62
  51. package/dist/index.js +1367 -1698
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +57 -230
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +266 -57
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/index.ts +0 -9
  77. package/src/canvas/usePanZoom.test.ts +28 -0
  78. package/src/canvas/usePanZoom.ts +11 -4
  79. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  80. package/src/chrome/LabChrome.tsx +21 -5
  81. package/src/chrome/builtins.test.ts +1 -0
  82. package/src/chrome/builtins.tsx +1 -0
  83. package/src/chrome/index.ts +1 -0
  84. package/src/chrome/labTypes.ts +3 -3
  85. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  86. package/src/chrome/regions/PaletteRegion.tsx +6 -10
  87. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  88. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  89. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  90. package/src/chrome/types.ts +11 -2
  91. package/src/config/auto.test.ts +12 -0
  92. package/src/config/auto.ts +15 -0
  93. package/src/config/autoConfig.test.ts +107 -0
  94. package/src/config/autoConfig.ts +153 -0
  95. package/src/config/builder.test.ts +38 -0
  96. package/src/config/builder.ts +55 -11
  97. package/src/config/index.ts +3 -0
  98. package/src/config/path.ts +7 -2
  99. package/src/config/resolve.test.ts +25 -0
  100. package/src/config/resolve.ts +17 -1
  101. package/src/config/types.ts +32 -2
  102. package/src/config/useResolvedConfig.ts +26 -0
  103. package/src/controls/AutoControls.stories.tsx +88 -0
  104. package/src/controls/ControlPanel.less +23 -0
  105. package/src/controls/ControlPanel.stories.tsx +83 -1
  106. package/src/controls/ControlPanel.test.tsx +457 -4
  107. package/src/controls/ControlPanel.tsx +534 -73
  108. package/src/index.test.ts +0 -2
  109. package/src/index.ts +33 -11
  110. package/src/instrument/serializers.ts +29 -1
  111. package/src/lab/Lab.chrome.test.tsx +16 -0
  112. package/src/lab/Lab.stories.tsx +1 -1
  113. package/src/lab/Lab.surface.test.tsx +7 -2
  114. package/src/lab/Lab.test.tsx +79 -1
  115. package/src/lab/Lab.tsx +124 -16
  116. package/src/lab/LabContext.ts +8 -0
  117. package/src/lab/LabFit.stories.tsx +4 -4
  118. package/src/lab/LabFullChrome.stories.tsx +1 -1
  119. package/src/lab/LabHeader.tsx +0 -1
  120. package/src/lab/LabRoot.test.tsx +56 -0
  121. package/src/lab/LabRoot.tsx +47 -0
  122. package/src/lab/LabShell.less +24 -3
  123. package/src/lab/LabShell.stories.tsx +50 -1
  124. package/src/lab/LabShell.tsx +18 -27
  125. package/src/lab/LabSwitcher.less +10 -10
  126. package/src/lab/LabSwitcher.test.tsx +14 -9
  127. package/src/lab/LabSwitcher.tsx +8 -4
  128. package/src/lab/Workspace.less +1 -1
  129. package/src/lab/Workspace.stories.tsx +1 -1
  130. package/src/lab/Workspace.surface.test.tsx +1 -1
  131. package/src/lab/index.ts +2 -0
  132. package/src/layers/LayerList.less +1 -1
  133. package/src/layers/LayerList.stories.tsx +2 -2
  134. package/src/loupe/AGENTS.md +16 -4
  135. package/src/loupe/LoupeGestures.tsx +67 -0
  136. package/src/loupe/TrialLoupe.tsx +43 -28
  137. package/src/loupe/index.ts +4 -0
  138. package/src/loupe/loupeActions.ts +98 -0
  139. package/src/loupe/useLoupe.test.tsx +59 -2
  140. package/src/loupe/useLoupe.ts +28 -48
  141. package/src/passthrough/weasel-ui.ts +3 -3
  142. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  143. package/src/primitives/FpsMeter.less +6 -1
  144. package/src/primitives/FpsMeter.stories.tsx +1 -1
  145. package/src/primitives/FpsMeter.test.tsx +12 -4
  146. package/src/primitives/FpsMeter.tsx +6 -1
  147. package/src/primitives/JobProgress.stories.tsx +2 -2
  148. package/src/primitives/Legend.stories.tsx +1 -1
  149. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  150. package/src/primitives/Sidebar.less +7 -0
  151. package/src/primitives/Sidebar.stories.tsx +1 -1
  152. package/src/primitives/Split.test.tsx +25 -0
  153. package/src/primitives/Split.tsx +36 -20
  154. package/src/primitives/StatusBar.less +6 -0
  155. package/src/primitives/StatusBar.stories.tsx +1 -1
  156. package/src/primitives/Toolbar.stories.tsx +1 -1
  157. package/src/specimen/Specimen.less +95 -0
  158. package/src/specimen/Specimen.stories.tsx +13 -0
  159. package/src/specimen/Specimen.test.tsx +13 -0
  160. package/src/specimen/Specimen.tsx +888 -0
  161. package/src/state/Persistence.stories.tsx +4 -4
  162. package/src/state/store.test.ts +65 -0
  163. package/src/state/store.ts +32 -8
  164. package/src/state/types.ts +31 -2
  165. package/src/state/useTrialState.test.tsx +85 -0
  166. package/src/state/useTrialState.ts +9 -1
  167. package/src/state/view.test.ts +4 -4
  168. package/src/styles.less +1 -0
  169. package/src/surface/index.ts +1 -1
  170. package/src/surface/useSurfaceTile.test.tsx +1 -1
  171. package/src/surface/useTiledSurface.test.tsx +65 -10
  172. package/src/surface/useTiledSurface.ts +23 -11
  173. package/src/theme/Interstellar.stories.tsx +2 -2
  174. package/src/theme/interstellar.test.ts +16 -5
  175. package/src/theme/interstellar.theme.json +180 -0
  176. package/src/theme/interstellar.ts +8 -5
  177. package/src/tools/labTool.ts +3 -3
  178. package/src/trial/Trial.annotations.test.tsx +13 -4
  179. package/src/trial/Trial.canvas.test.tsx +36 -0
  180. package/src/trial/Trial.config.test.tsx +75 -2
  181. package/src/trial/Trial.less +4 -0
  182. package/src/trial/Trial.stories.tsx +4 -1
  183. package/src/trial/Trial.test.tsx +3 -0
  184. package/src/trial/Trial.tsx +21 -14
  185. package/src/trial/TrialChrome.tsx +17 -3
  186. package/src/trial/trialOps.test.ts +101 -1
  187. package/src/trial/trialOps.ts +62 -4
  188. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  189. package/dist/chunk-67SJLMC7.js.map +0 -1
  190. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  191. package/dist/chunk-E44SU6XS.js +0 -50
  192. package/dist/chunk-E44SU6XS.js.map +0 -1
  193. package/dist/chunk-EKIICY6X.js.map +0 -1
  194. package/dist/chunk-KJALCDWE.js.map +0 -1
  195. package/dist/chunk-NDRYLVOW.js.map +0 -1
  196. package/dist/chunk-NS64DXMN.js.map +0 -1
  197. package/dist/chunk-PGETSEDK.js.map +0 -1
  198. package/dist/chunk-QLSV2N3G.js.map +0 -1
  199. package/dist/chunk-S2SHZRD7.js +0 -7681
  200. package/dist/chunk-S2SHZRD7.js.map +0 -1
  201. package/dist/chunk-TQBBYTAF.js +0 -280
  202. package/dist/chunk-TQBBYTAF.js.map +0 -1
  203. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  204. package/src/canvas/useOrbit.test.ts +0 -167
  205. package/src/canvas/useOrbit.ts +0 -156
  206. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,7 +1,10 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
3
4
  import type { PrefLeaf } from '@weasel-js/ui';
5
+ import { useState } from 'react';
4
6
  import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import { auto } from '../config/auto';
5
8
  import { f } from '../config/builder';
6
9
  import { resolveConfigSchema } from '../config/resolve';
7
10
  import type { ResolvedConfig } from '../config/types';
@@ -68,9 +71,12 @@ describe('<ControlPanel> select', () => {
68
71
  },
69
72
  ];
70
73
  render(<ControlPanel fields={fields} config={{ wave: 'sine' }} setConfig={setConfig} />);
71
- const sel = screen.getByLabelText('Wave') as HTMLSelectElement;
72
- expect(sel.tagName).toBe('SELECT');
73
- fireEvent.change(sel, { target: { value: 'square' } });
74
+ const trigger = screen.getByRole('button', { name: 'Sine Wave' });
75
+ expect(trigger).toHaveTextContent('Sine');
76
+ act(() => {
77
+ fireEvent.click(trigger);
78
+ });
79
+ fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: 'Square' }));
74
80
  expect(setConfig).toHaveBeenCalledWith('wave', 'square');
75
81
  });
76
82
  });
@@ -174,6 +180,22 @@ describe('<ControlPanel> schema', () => {
174
180
  expect(setConfig).toHaveBeenCalledWith('showGrid', false);
175
181
  });
176
182
 
183
+ it('draws a switch for a toggle() leaf and a checkbox for a plain one', () => {
184
+ const setConfig = vi.fn();
185
+ const schema = resolveConfigSchema(
186
+ f.schema({ showGrid: f.boolean(true), snap: f.boolean(true).toggle() }),
187
+ [],
188
+ );
189
+ render(
190
+ <ControlPanel schema={schema} config={{ showGrid: true, snap: false }} setConfig={setConfig} />,
191
+ );
192
+ expect(screen.getByRole('checkbox', { name: 'Show grid' })).toBeInTheDocument();
193
+ const sw = screen.getByRole('switch', { name: 'Snap' });
194
+ expect(sw).toBeInTheDocument();
195
+ fireEvent.click(sw);
196
+ expect(setConfig).toHaveBeenCalledWith('snap', true);
197
+ });
198
+
177
199
  it('picks a slider for a bounded number and an input for an open one', () => {
178
200
  const schema = resolveConfigSchema(
179
201
  f.schema({ a: f.number(5).range(0, 10), b: f.number(5) }),
@@ -460,6 +482,64 @@ describe('<ControlPanel> format', () => {
460
482
  });
461
483
  });
462
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
+
503
+ describe('<ControlPanel> section layout', () => {
504
+ const schema = resolveConfigSchema(
505
+ f.schema({
506
+ seed: f.number(0).section('Advanced'),
507
+ mode: f
508
+ .enum('a', [
509
+ { value: 'a', label: 'A' },
510
+ { value: 'b', label: 'B' },
511
+ ])
512
+ .label('Mode')
513
+ .section('Globals', { layout: 'inline', pack: 'one-up' }),
514
+ font: f
515
+ .enum('x', [{ value: 'x', label: 'X' }])
516
+ .label('Font')
517
+ .section('Globals'),
518
+ }),
519
+ [],
520
+ );
521
+ const config = { seed: 0, mode: 'a', font: 'x' };
522
+ // The row class comes through the CSS-module proxy, so this proves which layout the row asked for, not a rule.
523
+ const rowOf = (label: string) => screen.getByText(label).closest('label') as HTMLElement;
524
+
525
+ it('lays out every row in a section the way the section asks, and leaves other sections alone', () => {
526
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
527
+ expect(rowOf('Mode').className).toMatch(/rowInline/);
528
+ expect(rowOf('Font').className).toMatch(/rowInline/);
529
+ expect(rowOf('Seed').className).not.toMatch(/rowInline/);
530
+ });
531
+
532
+ it('carries a section’s layout into its resolved spec', () => {
533
+ expect(schema.sections.find((section) => section.label === 'Globals')).toMatchObject({
534
+ layout: 'inline',
535
+ pack: 'one-up',
536
+ });
537
+ expect(schema.sections.find((section) => section.label === 'Advanced')).not.toHaveProperty(
538
+ 'layout',
539
+ );
540
+ });
541
+ });
542
+
463
543
  describe('<ControlPanel> collapsible sections', () => {
464
544
  const schema = resolveConfigSchema(
465
545
  f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
@@ -611,3 +691,376 @@ describe('<ControlPanel> nested groups', () => {
611
691
  expect(screen.getByText('size:44')).toBeInTheDocument();
612
692
  });
613
693
  });
694
+
695
+ describe('<ControlPanel> auto', () => {
696
+ it('renders a label toggle whether or not it was given a set of auto paths', () => {
697
+ render(
698
+ <ControlPanel
699
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
700
+ config={{ gap: 12 }}
701
+ setConfig={() => {}}
702
+ />,
703
+ );
704
+ expect(screen.getByRole('button', { name: 'Pin Gap' })).toBeInTheDocument();
705
+ });
706
+
707
+ it('hands a custom renderer the auto state and a working toggle', async () => {
708
+ const setConfig = vi.fn();
709
+ render(
710
+ <ControlPanel
711
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
712
+ config={{ gap: 12 }}
713
+ auto={new Set(['gap'])}
714
+ setConfig={setConfig}
715
+ renderers={{
716
+ gap: (ctx) => (
717
+ <button type="button" onClick={() => ctx.setAuto(false)}>
718
+ {ctx.auto ? 'is auto' : 'is pinned'}
719
+ </button>
720
+ ),
721
+ }}
722
+ />,
723
+ );
724
+ const control = screen.getByRole('button', { name: 'is auto' });
725
+ await userEvent.click(control);
726
+ expect(setConfig).toHaveBeenCalledWith('gap', 12);
727
+ });
728
+
729
+ it('reads out the bare word, resolver or not', () => {
730
+ render(
731
+ <ControlPanel
732
+ schema={resolveConfigSchema(
733
+ f.schema({
734
+ gap: f
735
+ .number(12)
736
+ .auto(() => 18)
737
+ .range(0, 48),
738
+ }),
739
+ [],
740
+ )}
741
+ config={{ gap: 12 }}
742
+ auto={new Set(['gap'])}
743
+ setConfig={() => {}}
744
+ />,
745
+ );
746
+ expect(screen.getByText('auto')).toBeInTheDocument();
747
+ expect(screen.queryByText(/auto · /)).toBeNull();
748
+ });
749
+
750
+ it('writes the sentinel when the dot turns a row auto', async () => {
751
+ const setConfig = vi.fn();
752
+ render(
753
+ <ControlPanel
754
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
755
+ config={{ gap: 12 }}
756
+ auto={new Set()}
757
+ setConfig={setConfig}
758
+ />,
759
+ );
760
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
761
+ expect(setConfig).toHaveBeenCalledWith('gap', auto);
762
+ });
763
+
764
+ it('keeps the sentinel out of a config it is not given an auto set for', async () => {
765
+ const setConfig = vi.fn();
766
+ render(
767
+ <ControlPanel
768
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
769
+ config={{ gap: 12 }}
770
+ setConfig={setConfig}
771
+ />,
772
+ );
773
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
774
+ expect(setConfig).not.toHaveBeenCalled();
775
+ expect(screen.getByText('auto')).toBeInTheDocument();
776
+ });
777
+
778
+ it('writes the pinned value back when the dot un-autos a row', async () => {
779
+ const setConfig = vi.fn();
780
+ render(
781
+ <ControlPanel
782
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
783
+ config={{ gap: 24 }}
784
+ auto={new Set(['gap'])}
785
+ setConfig={setConfig}
786
+ />,
787
+ );
788
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
789
+ expect(setConfig).toHaveBeenCalledWith('gap', 24);
790
+ });
791
+
792
+ it('gives a manual row no dot', () => {
793
+ render(
794
+ <ControlPanel
795
+ schema={resolveConfigSchema(f.schema({ seed: f.number(1).manual().range(0, 8) }), [])}
796
+ config={{ seed: 1 }}
797
+ auto={new Set()}
798
+ setConfig={() => {}}
799
+ />,
800
+ );
801
+ expect(screen.queryByRole('button', { name: /Seed/ })).toBeNull();
802
+ });
803
+
804
+ it('draws what the instrument reads when a resolver depends on another auto path', () => {
805
+ // `width` is auto with no resolver, so the instrument sees `undefined` and
806
+ // `gap` resolves from that. Re-running gap's resolver against the raw
807
+ // config would instead read the pinned 640 and draw a number nobody got.
808
+ const { container } = render(
809
+ <ControlPanel
810
+ schema={resolveConfigSchema(
811
+ f.schema({
812
+ width: f.number(640),
813
+ gap: f
814
+ .number(12)
815
+ .range(0, 48)
816
+ .auto((c) => (c.width === undefined ? 5 : (c.width as number) / 64)),
817
+ }),
818
+ [],
819
+ )}
820
+ config={{ width: 640, gap: 12 }}
821
+ auto={new Set(['width', 'gap'])}
822
+ setConfig={() => {}}
823
+ />,
824
+ );
825
+ expect(container.querySelector('input[type=range]')).toHaveValue('5');
826
+ });
827
+
828
+ it('draws an auto control at the resolved value, not the value underneath it', () => {
829
+ const { container } = render(
830
+ <ControlPanel
831
+ schema={resolveConfigSchema(
832
+ f.schema({
833
+ gap: f
834
+ .number(12)
835
+ .range(0, 48)
836
+ .auto(() => 18),
837
+ }),
838
+ [],
839
+ )}
840
+ config={{ gap: 12 }}
841
+ auto={new Set(['gap'])}
842
+ setConfig={() => {}}
843
+ />,
844
+ );
845
+ expect(container.querySelector('input[type=range]')).toHaveValue('18');
846
+ });
847
+
848
+ it('pins at the value it was drawing, not the stale one underneath', async () => {
849
+ const setConfig = vi.fn();
850
+ render(
851
+ <ControlPanel
852
+ schema={resolveConfigSchema(
853
+ f.schema({
854
+ gap: f
855
+ .number(12)
856
+ .range(0, 48)
857
+ .auto(() => 18),
858
+ }),
859
+ [],
860
+ )}
861
+ config={{ gap: 12 }}
862
+ auto={new Set(['gap'])}
863
+ setConfig={setConfig}
864
+ />,
865
+ );
866
+ await userEvent.click(screen.getByRole('button', { name: 'Pin Gap' }));
867
+ expect(setConfig).toHaveBeenCalledWith('gap', 18);
868
+ });
869
+
870
+ // Every kind, because the panel wraps some rows before rendering them and a
871
+ // wrapper that drops the auto props leaves that kind silently pinned.
872
+ it.each([
873
+ ['number', f.number(3).range(0, 8)],
874
+ ['slider', f.number(3).range(0, 8).slider()],
875
+ ['boolean', f.boolean(true)],
876
+ ['enum', f.enum('a', ['a', 'b'])],
877
+ ['radio', f.enum('a', ['a', 'b']).radio()],
878
+ ['string', f.string('hi')],
879
+ ['color', f.color('#3a86ff')],
880
+ ])('marks a %s row auto and gives it a label toggle', (_kind, node) => {
881
+ const { container } = render(
882
+ <ControlPanel
883
+ schema={resolveConfigSchema(f.schema({ thing: node.label('Thing') }), [])}
884
+ config={{ thing: node.default }}
885
+ auto={new Set(['thing'])}
886
+ setConfig={() => {}}
887
+ />,
888
+ );
889
+ expect(container.querySelector('[class*=rowAuto]')).not.toBeNull();
890
+ expect(screen.getByRole('button', { name: 'Pin Thing' })).toBeInTheDocument();
891
+ });
892
+ });
893
+
894
+ describe('<ControlPanel> pair', () => {
895
+ const paired = () =>
896
+ resolveConfigSchema(
897
+ f.schema({
898
+ x: f.number(10).label('X').pair('Offset'),
899
+ y: f.number(20).label('Y').pair('Offset'),
900
+ scale: f.number(1).label('Scale'),
901
+ }),
902
+ [],
903
+ );
904
+ const config = { x: 10, y: 20, scale: 1 };
905
+
906
+ it('draws two leaves sharing a pair id as one row named by the pair', () => {
907
+ render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
908
+ const row = screen.getByText('Offset').closest('div') as HTMLElement;
909
+ expect(within(row).getByLabelText('X')).toHaveValue(10);
910
+ expect(within(row).getByLabelText('Y')).toHaveValue(20);
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');
918
+ });
919
+
920
+ it('writes each cell of a paired row to its own path', () => {
921
+ const setConfig = vi.fn();
922
+ render(<ControlPanel schema={paired()} config={config} setConfig={setConfig} />);
923
+ fireEvent.change(screen.getByLabelText('Y'), { target: { value: '30' } });
924
+ expect(setConfig).toHaveBeenCalledWith('y', 30);
925
+ });
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
+
944
+ it('leaves an unpaired sibling on its own row', () => {
945
+ render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
946
+ const row = screen.getByText('Offset').closest('div') as HTMLElement;
947
+ expect(within(row).queryByLabelText('Scale')).toBeNull();
948
+ expect(screen.getByLabelText('Scale')).toHaveValue(1);
949
+ });
950
+ });
951
+
952
+ describe('<ControlPanel> unit', () => {
953
+ const degrees = {
954
+ toDisplay: (stored: number) => (stored * 180) / Math.PI,
955
+ fromDisplay: (shown: number) => (shown * Math.PI) / 180,
956
+ suffix: '°',
957
+ };
958
+ const angle = (node: ReturnType<typeof f.number>, stored: number, setConfig = vi.fn()) => {
959
+ render(
960
+ <ControlPanel
961
+ schema={resolveConfigSchema(f.schema({ angle: node.label('Angle') }), [])}
962
+ config={{ angle: stored }}
963
+ setConfig={setConfig}
964
+ />,
965
+ );
966
+ return setConfig;
967
+ };
968
+
969
+ it('shows a radians-backed number in its display unit', () => {
970
+ angle(f.number(0).input().unit(degrees), Math.PI / 2);
971
+ expect((screen.getByLabelText('Angle') as HTMLInputElement).value).toBe('90');
972
+ });
973
+
974
+ it('stores a typed display value back in the canonical unit', () => {
975
+ const setConfig = angle(f.number(0).input().unit(degrees), Math.PI / 2);
976
+ fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '45' } });
977
+ expect(setConfig.mock.calls[0][0]).toBe('angle');
978
+ expect(setConfig.mock.calls[0][1] as number).toBeCloseTo(Math.PI / 4, 10);
979
+ });
980
+
981
+ it('round-trips a value edited away and back', () => {
982
+ // Live config, because the corruption this guards against only shows on
983
+ // the second edit: what the field stores has to read back as what it showed.
984
+ function Live() {
985
+ const [config, setConfig] = useState<Record<string, unknown>>({ angle: Math.PI / 2 });
986
+ return (
987
+ <ControlPanel
988
+ schema={resolveConfigSchema(
989
+ f.schema({ angle: f.number(0).input().unit(degrees).label('Angle') }),
990
+ [],
991
+ )}
992
+ config={config}
993
+ setConfig={(path, value) => setConfig((prev) => ({ ...prev, [path]: value }))}
994
+ />
995
+ );
996
+ }
997
+ render(<Live />);
998
+ const field = () => screen.getByLabelText('Angle') as HTMLInputElement;
999
+ expect(field().value).toBe('90');
1000
+ fireEvent.change(field(), { target: { value: '45' } });
1001
+ expect(field().value).toBe('45');
1002
+ fireEvent.change(field(), { target: { value: '90' } });
1003
+ expect(field().value).toBe('90');
1004
+ });
1005
+
1006
+ it('converts a slider’s bounds into the display unit', () => {
1007
+ angle(f.number(0).range(0, Math.PI).unit(degrees), Math.PI / 2);
1008
+ const slider = screen.getByRole('slider') as HTMLInputElement;
1009
+ expect(slider.min).toBe('0');
1010
+ expect(slider.max).toBe('180');
1011
+ expect(slider.value).toBe('90');
1012
+ });
1013
+
1014
+ it('clamps a typed value against the bounds in display space', () => {
1015
+ // 100 is inside 0..180 degrees and outside 0..π radians, so clamping
1016
+ // against the stored bounds would cut a legal angle down to π.
1017
+ const setConfig = angle(f.number(0).range(0, Math.PI).input().unit(degrees), Math.PI / 2);
1018
+ fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '100' } });
1019
+ expect(setConfig.mock.calls[0][1] as number).toBeCloseTo((100 * Math.PI) / 180, 10);
1020
+ });
1021
+ });
1022
+
1023
+ describe('<ControlPanel> color alpha', () => {
1024
+ const panel = (setConfig = vi.fn(), stored = '#336699cc') => {
1025
+ render(
1026
+ <ControlPanel
1027
+ schema={resolveConfigSchema(
1028
+ f.schema({ tint: f.color('#000000ff').alpha().label('Tint') }),
1029
+ [],
1030
+ )}
1031
+ config={{ tint: stored }}
1032
+ setConfig={setConfig}
1033
+ />,
1034
+ );
1035
+ return setConfig;
1036
+ };
1037
+
1038
+ it('splits the stored hex into a swatch and an alpha track', () => {
1039
+ panel();
1040
+ expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
1041
+ expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
1042
+ });
1043
+
1044
+ it('keeps the stored alpha when the swatch moves', () => {
1045
+ const setConfig = panel();
1046
+ fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1047
+ expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
1048
+ });
1049
+
1050
+ it('keeps the stored color when the alpha track moves', () => {
1051
+ const setConfig = panel();
1052
+ fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
1053
+ expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
1054
+ });
1055
+
1056
+ it('leaves a color leaf without alpha as a bare swatch', () => {
1057
+ render(
1058
+ <ControlPanel
1059
+ schema={resolveConfigSchema(f.schema({ tint: f.color('#336699').label('Tint') }), [])}
1060
+ config={{ tint: '#336699' }}
1061
+ setConfig={vi.fn()}
1062
+ />,
1063
+ );
1064
+ expect(screen.queryByLabelText('Tint opacity')).toBeNull();
1065
+ });
1066
+ });