@weasel-js/labkit 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.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-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.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-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  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 +177 -53
  51. package/dist/index.js +1333 -1683
  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 +26 -202
  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 +153 -40
  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/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. 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,46 @@ describe('<ControlPanel> format', () => {
460
482
  });
461
483
  });
462
484
 
485
+ describe('<ControlPanel> section layout', () => {
486
+ const schema = resolveConfigSchema(
487
+ f.schema({
488
+ seed: f.number(0).section('Advanced'),
489
+ mode: f
490
+ .enum('a', [
491
+ { value: 'a', label: 'A' },
492
+ { value: 'b', label: 'B' },
493
+ ])
494
+ .label('Mode')
495
+ .section('Globals', { layout: 'inline', pack: 'one-up' }),
496
+ font: f
497
+ .enum('x', [{ value: 'x', label: 'X' }])
498
+ .label('Font')
499
+ .section('Globals'),
500
+ }),
501
+ [],
502
+ );
503
+ const config = { seed: 0, mode: 'a', font: 'x' };
504
+ // The row class comes through the CSS-module proxy, so this proves which layout the row asked for, not a rule.
505
+ const rowOf = (label: string) => screen.getByText(label).closest('label') as HTMLElement;
506
+
507
+ it('lays out every row in a section the way the section asks, and leaves other sections alone', () => {
508
+ render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
509
+ expect(rowOf('Mode').className).toMatch(/rowInline/);
510
+ expect(rowOf('Font').className).toMatch(/rowInline/);
511
+ expect(rowOf('Seed').className).not.toMatch(/rowInline/);
512
+ });
513
+
514
+ it('carries a section’s layout into its resolved spec', () => {
515
+ expect(schema.sections.find((section) => section.label === 'Globals')).toMatchObject({
516
+ layout: 'inline',
517
+ pack: 'one-up',
518
+ });
519
+ expect(schema.sections.find((section) => section.label === 'Advanced')).not.toHaveProperty(
520
+ 'layout',
521
+ );
522
+ });
523
+ });
524
+
463
525
  describe('<ControlPanel> collapsible sections', () => {
464
526
  const schema = resolveConfigSchema(
465
527
  f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
@@ -611,3 +673,451 @@ describe('<ControlPanel> nested groups', () => {
611
673
  expect(screen.getByText('size:44')).toBeInTheDocument();
612
674
  });
613
675
  });
676
+
677
+ describe('<ControlPanel> auto', () => {
678
+ it('renders a pin dot whether or not it was given a set of auto paths', () => {
679
+ render(
680
+ <ControlPanel
681
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
682
+ config={{ gap: 12 }}
683
+ setConfig={() => {}}
684
+ />,
685
+ );
686
+ expect(screen.getByRole('button', { name: 'Pin Gap' })).toBeInTheDocument();
687
+ });
688
+
689
+ it('hands a custom renderer the auto state and a working toggle', async () => {
690
+ const setConfig = vi.fn();
691
+ render(
692
+ <ControlPanel
693
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
694
+ config={{ gap: 12 }}
695
+ auto={new Set(['gap'])}
696
+ setConfig={setConfig}
697
+ renderers={{
698
+ gap: (ctx) => (
699
+ <button type="button" onClick={() => ctx.setAuto(false)}>
700
+ {ctx.auto ? 'is auto' : 'is pinned'}
701
+ </button>
702
+ ),
703
+ }}
704
+ />,
705
+ );
706
+ const control = screen.getByRole('button', { name: 'is auto' });
707
+ await userEvent.click(control);
708
+ expect(setConfig).toHaveBeenCalledWith('gap', 12);
709
+ });
710
+
711
+ it("reads the resolver for an auto row's readout", () => {
712
+ render(
713
+ <ControlPanel
714
+ schema={resolveConfigSchema(
715
+ f.schema({
716
+ gap: f
717
+ .number(12)
718
+ .auto(() => 18)
719
+ .range(0, 48),
720
+ }),
721
+ [],
722
+ )}
723
+ config={{ gap: 12 }}
724
+ auto={new Set(['gap'])}
725
+ setConfig={() => {}}
726
+ />,
727
+ );
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
+ expect(screen.getByText('auto')).toBeInTheDocument();
741
+ });
742
+
743
+ it('writes the sentinel when the dot turns a row auto', async () => {
744
+ const setConfig = vi.fn();
745
+ render(
746
+ <ControlPanel
747
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
748
+ config={{ gap: 12 }}
749
+ auto={new Set()}
750
+ setConfig={setConfig}
751
+ />,
752
+ );
753
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
754
+ expect(setConfig).toHaveBeenCalledWith('gap', auto);
755
+ });
756
+
757
+ it('keeps the sentinel out of a config it is not given an auto set for', async () => {
758
+ const setConfig = vi.fn();
759
+ render(
760
+ <ControlPanel
761
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
762
+ config={{ gap: 12 }}
763
+ setConfig={setConfig}
764
+ />,
765
+ );
766
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
767
+ expect(setConfig).not.toHaveBeenCalled();
768
+ expect(screen.getByText('auto')).toBeInTheDocument();
769
+ });
770
+
771
+ it('writes the pinned value back when the dot un-autos a row', async () => {
772
+ const setConfig = vi.fn();
773
+ render(
774
+ <ControlPanel
775
+ schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
776
+ config={{ gap: 24 }}
777
+ auto={new Set(['gap'])}
778
+ setConfig={setConfig}
779
+ />,
780
+ );
781
+ await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
782
+ expect(setConfig).toHaveBeenCalledWith('gap', 24);
783
+ });
784
+
785
+ it('gives a manual row no dot', () => {
786
+ render(
787
+ <ControlPanel
788
+ schema={resolveConfigSchema(f.schema({ seed: f.number(1).manual().range(0, 8) }), [])}
789
+ config={{ seed: 1 }}
790
+ auto={new Set()}
791
+ setConfig={() => {}}
792
+ />,
793
+ );
794
+ expect(screen.queryByRole('button', { name: /Seed/ })).toBeNull();
795
+ });
796
+
797
+ it('reports what the instrument reads when a resolver depends on another auto path', () => {
798
+ // `width` is auto with no resolver, so the instrument sees `undefined` and
799
+ // `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(
802
+ <ControlPanel
803
+ schema={resolveConfigSchema(
804
+ f.schema({
805
+ width: f.number(640),
806
+ gap: f
807
+ .number(12)
808
+ .range(0, 48)
809
+ .auto((c) => (c.width === undefined ? -1 : (c.width as number) / 64)),
810
+ }),
811
+ [],
812
+ )}
813
+ config={{ width: 640, gap: 12 }}
814
+ auto={new Set(['width', 'gap'])}
815
+ setConfig={() => {}}
816
+ />,
817
+ );
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();
839
+ });
840
+
841
+ it('draws an auto control at the resolved value, not the value underneath it', () => {
842
+ const { container } = render(
843
+ <ControlPanel
844
+ schema={resolveConfigSchema(
845
+ f.schema({
846
+ gap: f
847
+ .number(12)
848
+ .range(0, 48)
849
+ .auto(() => 18),
850
+ }),
851
+ [],
852
+ )}
853
+ config={{ gap: 12 }}
854
+ auto={new Set(['gap'])}
855
+ setConfig={() => {}}
856
+ />,
857
+ );
858
+ expect(container.querySelector('input[type=range]')).toHaveValue('18');
859
+ });
860
+
861
+ it('pins at the value it was drawing, not the stale one underneath', async () => {
862
+ const setConfig = vi.fn();
863
+ render(
864
+ <ControlPanel
865
+ schema={resolveConfigSchema(
866
+ f.schema({
867
+ gap: f
868
+ .number(12)
869
+ .range(0, 48)
870
+ .auto(() => 18),
871
+ }),
872
+ [],
873
+ )}
874
+ config={{ gap: 12 }}
875
+ auto={new Set(['gap'])}
876
+ setConfig={setConfig}
877
+ />,
878
+ );
879
+ await userEvent.click(screen.getByRole('button', { name: 'Pin Gap' }));
880
+ expect(setConfig).toHaveBeenCalledWith('gap', 18);
881
+ });
882
+
883
+ // 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.
885
+ it.each([
886
+ ['number', f.number(3).range(0, 8)],
887
+ ['slider', f.number(3).range(0, 8).slider()],
888
+ ['boolean', f.boolean(true)],
889
+ ['enum', f.enum('a', ['a', 'b'])],
890
+ ['radio', f.enum('a', ['a', 'b']).radio()],
891
+ ['string', f.string('hi')],
892
+ ['color', f.color('#3a86ff')],
893
+ ])('marks a %s row auto and gives it a dot and a path', (_kind, node) => {
894
+ const { container } = render(
895
+ <ControlPanel
896
+ schema={resolveConfigSchema(f.schema({ thing: node.label('Thing') }), [])}
897
+ config={{ thing: node.default }}
898
+ auto={new Set(['thing'])}
899
+ setConfig={() => {}}
900
+ />,
901
+ );
902
+ const row = container.querySelector('[data-auto-path="thing"]');
903
+ expect(row).not.toBeNull();
904
+ expect(row?.className).toMatch(/rowAuto/);
905
+ expect(screen.getByRole('button', { name: 'Pin Thing' })).toBeInTheDocument();
906
+ });
907
+ });
908
+
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
+ describe('<ControlPanel> pair', () => {
973
+ const paired = () =>
974
+ resolveConfigSchema(
975
+ f.schema({
976
+ x: f.number(10).label('X').pair('Offset'),
977
+ y: f.number(20).label('Y').pair('Offset'),
978
+ scale: f.number(1).label('Scale'),
979
+ }),
980
+ [],
981
+ );
982
+ const config = { x: 10, y: 20, scale: 1 };
983
+
984
+ it('draws two leaves sharing a pair id as one row named by the pair', () => {
985
+ render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
986
+ const row = screen.getByText('Offset').closest('div') as HTMLElement;
987
+ expect(within(row).getByLabelText('X')).toHaveValue(10);
988
+ 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();
992
+ });
993
+
994
+ it('writes each cell of a paired row to its own path', () => {
995
+ const setConfig = vi.fn();
996
+ render(<ControlPanel schema={paired()} config={config} setConfig={setConfig} />);
997
+ fireEvent.change(screen.getByLabelText('Y'), { target: { value: '30' } });
998
+ expect(setConfig).toHaveBeenCalledWith('y', 30);
999
+ });
1000
+
1001
+ it('leaves an unpaired sibling on its own row', () => {
1002
+ render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
1003
+ const row = screen.getByText('Offset').closest('div') as HTMLElement;
1004
+ expect(within(row).queryByLabelText('Scale')).toBeNull();
1005
+ expect(screen.getByLabelText('Scale')).toHaveValue(1);
1006
+ });
1007
+ });
1008
+
1009
+ describe('<ControlPanel> unit', () => {
1010
+ const degrees = {
1011
+ toDisplay: (stored: number) => (stored * 180) / Math.PI,
1012
+ fromDisplay: (shown: number) => (shown * Math.PI) / 180,
1013
+ suffix: '°',
1014
+ };
1015
+ const angle = (node: ReturnType<typeof f.number>, stored: number, setConfig = vi.fn()) => {
1016
+ render(
1017
+ <ControlPanel
1018
+ schema={resolveConfigSchema(f.schema({ angle: node.label('Angle') }), [])}
1019
+ config={{ angle: stored }}
1020
+ setConfig={setConfig}
1021
+ />,
1022
+ );
1023
+ return setConfig;
1024
+ };
1025
+
1026
+ it('shows a radians-backed number in its display unit', () => {
1027
+ angle(f.number(0).input().unit(degrees), Math.PI / 2);
1028
+ expect((screen.getByLabelText('Angle') as HTMLInputElement).value).toBe('90');
1029
+ });
1030
+
1031
+ it('stores a typed display value back in the canonical unit', () => {
1032
+ const setConfig = angle(f.number(0).input().unit(degrees), Math.PI / 2);
1033
+ fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '45' } });
1034
+ expect(setConfig.mock.calls[0][0]).toBe('angle');
1035
+ expect(setConfig.mock.calls[0][1] as number).toBeCloseTo(Math.PI / 4, 10);
1036
+ });
1037
+
1038
+ it('round-trips a value edited away and back', () => {
1039
+ // Live config, because the corruption this guards against only shows on
1040
+ // the second edit: what the field stores has to read back as what it showed.
1041
+ function Live() {
1042
+ const [config, setConfig] = useState<Record<string, unknown>>({ angle: Math.PI / 2 });
1043
+ return (
1044
+ <ControlPanel
1045
+ schema={resolveConfigSchema(
1046
+ f.schema({ angle: f.number(0).input().unit(degrees).label('Angle') }),
1047
+ [],
1048
+ )}
1049
+ config={config}
1050
+ setConfig={(path, value) => setConfig((prev) => ({ ...prev, [path]: value }))}
1051
+ />
1052
+ );
1053
+ }
1054
+ render(<Live />);
1055
+ const field = () => screen.getByLabelText('Angle') as HTMLInputElement;
1056
+ expect(field().value).toBe('90');
1057
+ fireEvent.change(field(), { target: { value: '45' } });
1058
+ expect(field().value).toBe('45');
1059
+ fireEvent.change(field(), { target: { value: '90' } });
1060
+ expect(field().value).toBe('90');
1061
+ });
1062
+
1063
+ it('converts a slider’s bounds into the display unit', () => {
1064
+ angle(f.number(0).range(0, Math.PI).unit(degrees), Math.PI / 2);
1065
+ const slider = screen.getByRole('slider') as HTMLInputElement;
1066
+ expect(slider.min).toBe('0');
1067
+ expect(slider.max).toBe('180');
1068
+ expect(slider.value).toBe('90');
1069
+ });
1070
+
1071
+ it('clamps a typed value against the bounds in display space', () => {
1072
+ // 100 is inside 0..180 degrees and outside 0..π radians, so clamping
1073
+ // against the stored bounds would cut a legal angle down to π.
1074
+ const setConfig = angle(f.number(0).range(0, Math.PI).input().unit(degrees), Math.PI / 2);
1075
+ fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '100' } });
1076
+ expect(setConfig.mock.calls[0][1] as number).toBeCloseTo((100 * Math.PI) / 180, 10);
1077
+ });
1078
+ });
1079
+
1080
+ describe('<ControlPanel> color alpha', () => {
1081
+ const panel = (setConfig = vi.fn(), stored = '#336699cc') => {
1082
+ render(
1083
+ <ControlPanel
1084
+ schema={resolveConfigSchema(
1085
+ f.schema({ tint: f.color('#000000ff').alpha().label('Tint') }),
1086
+ [],
1087
+ )}
1088
+ config={{ tint: stored }}
1089
+ setConfig={setConfig}
1090
+ />,
1091
+ );
1092
+ return setConfig;
1093
+ };
1094
+
1095
+ it('splits the stored hex into a swatch and an alpha track', () => {
1096
+ panel();
1097
+ expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
1098
+ expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
1099
+ });
1100
+
1101
+ it('keeps the stored alpha when the swatch moves', () => {
1102
+ const setConfig = panel();
1103
+ fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
1104
+ expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
1105
+ });
1106
+
1107
+ it('keeps the stored color when the alpha track moves', () => {
1108
+ const setConfig = panel();
1109
+ fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
1110
+ expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
1111
+ });
1112
+
1113
+ it('leaves a color leaf without alpha as a bare swatch', () => {
1114
+ render(
1115
+ <ControlPanel
1116
+ schema={resolveConfigSchema(f.schema({ tint: f.color('#336699').label('Tint') }), [])}
1117
+ config={{ tint: '#336699' }}
1118
+ setConfig={vi.fn()}
1119
+ />,
1120
+ );
1121
+ expect(screen.queryByLabelText('Tint opacity')).toBeNull();
1122
+ });
1123
+ });