@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
@@ -1,30 +1,23 @@
1
- import { isBuiltinToolPref } from '@weasel-js/core';
1
+ import { getAlpha01, isBuiltinToolPref, toHex8, withAlpha01 } from '@weasel-js/core';
2
2
  import {
3
- CheckboxRow,
4
- ColorRow,
3
+ DialogRow,
5
4
  isPrefLeaf,
6
- NumberRow,
5
+ ListEditor,
7
6
  type PrefGroup,
8
7
  type PrefLeaf,
9
- type PrefNumber,
10
- type PrefNumberFormat,
11
- type PrefNumberUnit,
12
8
  type PropertyAlign,
9
+ type PropertyControlProps,
10
+ PropertyControl,
13
11
  type PropertyDensity,
12
+ PropertyField,
14
13
  PropertyGroup,
15
14
  PropertyList,
16
15
  type PropertyListPack,
16
+ PropertyPanel,
17
17
  PropertyRow,
18
18
  type PropertyRowLayout,
19
- prefDisplayBounds,
20
- Select,
21
- SelectRow,
22
- SliderRow,
23
- Switch,
24
- SwitchRow,
25
- TextRow,
26
- ToggleBar,
27
- ToggleRow,
19
+ prefFieldProps,
20
+ type StanceProps,
28
21
  } from '@weasel-js/ui';
29
22
  import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
30
23
  import { auto as autoValue } from '../config/auto';
@@ -33,6 +26,7 @@ import { fromConfigFields } from '../config/fromConfigField';
33
26
  import { schemaNodeAtPath, valueAtPath } from '../config/path';
34
27
  import type { ControlRenderer, ResolvedConfig, SectionSpec } from '../config/types';
35
28
  import { isLeafVisible } from '../config/visible';
29
+ import { summarizeValue } from './inDialog';
36
30
  import type { ConfigField } from './types';
37
31
 
38
32
  /** How a panel packs its rows into the two-column property grid.
@@ -63,7 +57,7 @@ function sectionRows(section: SectionSpec, rows: Rows): Rows {
63
57
  };
64
58
  }
65
59
 
66
- export interface ControlPanelProps<TC extends Record<string, unknown>> {
60
+ export interface ControlPanelProps<TC extends Record<string, unknown>> extends StanceProps {
67
61
  /** The instrument's resolved config schema. */
68
62
  schema?: ResolvedConfig;
69
63
  /** @deprecated Pass `schema`. A field list is adapted into one internally. */
@@ -115,6 +109,9 @@ export interface ControlPanelProps<TC extends Record<string, unknown>> {
115
109
  auto?: ReadonlySet<string>;
116
110
  /** Draw leaves marked `hidden`. */
117
111
  showHidden?: boolean;
112
+ /** Heads the panel. With `title`, `stance` or `tone` given, the rows sit in a
113
+ * `<PropertyPanel>` carrying them; with none, they are the bare list. */
114
+ title?: ReactNode;
118
115
  className?: string;
119
116
  }
120
117
 
@@ -138,6 +135,9 @@ export function ControlPanel<TC extends Record<string, unknown>>({
138
135
  onCollapse,
139
136
  auto: given,
140
137
  showHidden = false,
138
+ title,
139
+ stance,
140
+ tone,
141
141
  className,
142
142
  }: ControlPanelProps<TC>) {
143
143
  const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
@@ -313,7 +313,7 @@ export function ControlPanel<TC extends Record<string, unknown>>({
313
313
  );
314
314
  };
315
315
 
316
- return (
316
+ const list = (
317
317
  <PropertyList
318
318
  pack={gridPack}
319
319
  density={density}
@@ -323,6 +323,12 @@ export function ControlPanel<TC extends Record<string, unknown>>({
323
323
  {body(resolved.group, '', { pack, layout, grid: gridPack })}
324
324
  </PropertyList>
325
325
  );
326
+ if (title === undefined && stance === undefined && tone === undefined) return list;
327
+ return (
328
+ <PropertyPanel title={title} stance={stance} tone={tone} density={density}>
329
+ {list}
330
+ </PropertyPanel>
331
+ );
326
332
  }
327
333
 
328
334
  /** How a section is keyed for folding: its label at the root, and its group's
@@ -407,142 +413,83 @@ function ControlRow<TC extends Record<string, unknown>>({
407
413
 
408
414
  const label = leaf.name;
409
415
  const description = leaf.description;
410
- const read = <T,>(): T => value as T;
411
416
  // `auto` gives the whole width to the controls that read badly at half of a
412
417
  // sidebar's: free text, a slider track, a segmented toggle. `pairs` doesn't.
413
418
  const wide = pack === 'auto';
419
+ const row = { label, description, layout, readout: autoReadout, ...autoProps };
414
420
 
415
- if (!isBuiltinToolPref(leaf))
416
- return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
417
-
418
- switch (leaf.kind) {
419
- case 'number': {
420
- const field = numberField(leaf, read<number>());
421
- const suffix = extra<string>(leaf, 'suffix') ?? field.unit?.suffix;
422
- const notation = extra<PrefNumberFormat>(leaf, 'format');
423
- if (isSliderLeaf(leaf)) {
424
- return (
425
- <SliderRow
426
- label={label}
427
- value={field.shown}
428
- min={field.min as number}
429
- max={field.max as number}
430
- step={field.step}
431
- notation={notation}
432
- unit={suffix}
433
- onChange={(n) => write(field.store(n))}
434
- readout={autoReadout}
435
- layout={layout}
436
- span={wide}
437
- description={description}
438
- {...autoProps}
421
+ if (leaf.kind === 'list') {
422
+ const entries = Array.isArray(value) ? (value as string[]) : [];
423
+ return (
424
+ <DialogRow
425
+ label={label}
426
+ summary={summarizeValue(entries)}
427
+ readout={autoReadout}
428
+ layout={layout}
429
+ description={description}
430
+ {...autoProps}
431
+ >
432
+ {() => (
433
+ <ListEditor
434
+ aria-label={label}
435
+ value={entries}
436
+ onChange={write}
437
+ placeholder={extra<string>(leaf, 'placeholder')}
439
438
  />
440
- );
441
- }
442
- // NumberRow commits every keystroke and does not clamp, so the bounds a
443
- // schema declares are enforced here — an instrument should never be
444
- // handed a config value outside the range it asked for.
445
- return (
446
- <NumberRow
447
- label={label}
448
- value={field.shown}
449
- min={field.min}
450
- max={field.max}
451
- step={field.step}
452
- unit={suffix}
453
- onChange={(n) => write(field.store(n))}
454
- readout={autoReadout}
455
- layout={layout}
456
- description={description}
457
- {...autoProps}
458
- />
459
- );
460
- }
461
- case 'boolean': {
462
- const Row = extra<string>(leaf, 'control') === 'switch' ? SwitchRow : CheckboxRow;
463
- return (
464
- <Row
465
- label={label}
466
- value={read<boolean>()}
467
- onChange={write}
468
- readout={autoReadout}
469
- layout={layout}
470
- description={description}
471
- {...autoProps}
472
- />
473
- );
474
- }
475
- case 'enum': {
476
- const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
477
- const segmented = extra<string>(leaf, 'control') === 'radio';
478
- const Row = segmented ? ToggleRow : SelectRow;
479
- return (
480
- <Row
481
- label={label}
482
- value={read<string>()}
483
- options={options}
484
- onChange={write}
485
- readout={autoReadout}
486
- layout={layout}
487
- span={segmented && wide}
488
- description={description}
489
- {...autoProps}
490
- />
491
- );
492
- }
439
+ )}
440
+ </DialogRow>
441
+ );
442
+ }
443
+
444
+ const field = labField(leaf, value, write);
445
+ if (field === null) return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
446
+ switch (field.kind) {
493
447
  case 'string':
494
- return (
495
- <DebouncedTextRow
496
- leaf={leaf}
497
- label={label}
498
- value={read<string>()}
499
- write={write}
500
- readout={autoReadout}
501
- layout={layout}
502
- span={wide}
503
- description={description}
504
- {...autoProps}
505
- />
506
- );
507
- case 'color': {
508
- // `ColorRow` takes alpha as a number beside a `#rrggbb` swatch, so an
509
- // `#rrggbbaa` value has to be split going in and rejoined coming out —
510
- // handed whole to the swatch, the browser cannot parse it and the first
511
- // edit writes back black.
512
- const common = {
513
- label,
514
- readout: autoReadout,
515
- layout,
516
- description,
517
- ...autoProps,
448
+ return <DebouncedTextRow leaf={leaf} field={field} span={wide} {...row} />;
449
+ case 'number':
450
+ return <PropertyField {...field} {...row} span={field.control === 'slider' && wide} />;
451
+ case 'enum':
452
+ return <PropertyField {...field} {...row} span={field.control === 'radio' && wide} />;
453
+ default:
454
+ return <PropertyField {...field} {...row} />;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * A leaf as the field it draws, with the labkit-only extras `prefFieldProps`
460
+ * knows nothing of. `null` for a kind the panel declines: a paint, which a
461
+ * hex swatch would flatten to a solid, and an object, which a flat row would
462
+ * write one field of. Override with `render` to edit either.
463
+ */
464
+ function labField(
465
+ leaf: PrefLeaf,
466
+ value: unknown,
467
+ write: (value: unknown) => void,
468
+ ): PropertyControlProps | null {
469
+ if (!isBuiltinToolPref(leaf) || leaf.kind === 'paint' || leaf.kind === 'object') return null;
470
+ const field = prefFieldProps(leaf, { value, setValue: write });
471
+ if (field === null) return null;
472
+ switch (field.kind) {
473
+ case 'number':
474
+ // `prefFieldProps` clamps what it stores, so an instrument is never
475
+ // handed a value outside the range it asked for.
476
+ return {
477
+ ...field,
478
+ control: isSliderLeaf(leaf) ? 'slider' : 'input',
479
+ unit: extra<string>(leaf, 'suffix') ?? field.unit,
518
480
  };
519
- if (extra<boolean>(leaf, 'alpha') !== true)
520
- return <ColorRow {...common} value={read<string>()} onChange={write} />;
521
- const { rgb, alpha } = splitHexAlpha(read<string>());
522
- return (
523
- <ColorRow
524
- {...common}
525
- value={rgb}
526
- onChange={(next) => write(joinHexAlpha(next, alpha))}
527
- alpha={alpha}
528
- onAlphaChange={(next) => write(joinHexAlpha(rgb, next))}
529
- />
530
- );
531
- }
532
- case 'paint':
533
- case 'object':
534
- // Declined: a hex swatch would write a solid over a gradient, and a flat
535
- // row would write one field into a half-built object. Override with
536
- // `render` to edit either.
537
- return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
538
- default: {
539
- // Not reachable while every built-in kind has an arm — and a new kind
540
- // that lacks one is a compile error here, never a blank row.
541
- const _exhaustive: never = leaf;
542
- throw new Error(
543
- `ControlPanel: no control for built-in pref kind "${(_exhaustive as { kind: string }).kind}"`,
544
- );
545
- }
481
+ case 'boolean':
482
+ return { ...field, control: extra<string>(leaf, 'control') === 'switch' ? 'switch' : 'checkbox' };
483
+ case 'enum':
484
+ return { ...field, control: extra<string>(leaf, 'control') === 'radio' ? 'radio' : 'select' };
485
+ case 'string':
486
+ return {
487
+ ...field,
488
+ placeholder: extra<string>(leaf, 'placeholder'),
489
+ maxLength: extra<number>(leaf, 'maxLength'),
490
+ };
491
+ default:
492
+ return field;
546
493
  }
547
494
  }
548
495
 
@@ -567,20 +514,12 @@ function UnwiredRow({
567
514
  /** A text row whose writes are debounced — see {@link useDebouncedText}. */
568
515
  function DebouncedTextRow({
569
516
  leaf,
570
- label,
571
- value,
572
- write,
573
- layout,
574
- span,
575
- description,
576
- readout,
577
- auto,
578
- onAutoChange,
517
+ field,
518
+ ...row
579
519
  }: {
580
520
  leaf: PrefLeaf;
521
+ field: PropertyControlProps & { kind: 'string' };
581
522
  label: string;
582
- value: string;
583
- write: (value: unknown) => void;
584
523
  layout?: PropertyRowLayout;
585
524
  span?: boolean;
586
525
  description?: string;
@@ -589,100 +528,11 @@ function DebouncedTextRow({
589
528
  onAutoChange?: (next: boolean) => void;
590
529
  }) {
591
530
  const text = useDebouncedText(
592
- value,
593
- write as (v: string) => void,
531
+ field.value ?? '',
532
+ field.onChange,
594
533
  extra<number>(leaf, 'debounceMs') ?? 150,
595
534
  );
596
-
597
- return (
598
- <TextRow
599
- label={label}
600
- readout={readout}
601
- layout={layout}
602
- span={span}
603
- description={description}
604
- auto={auto}
605
- onAutoChange={onAutoChange}
606
- value={text.local}
607
- placeholder={extra<string>(leaf, 'placeholder')}
608
- maxLength={extra<number>(leaf, 'maxLength')}
609
- onChange={text.type}
610
- />
611
- );
612
- }
613
-
614
- /**
615
- * A number leaf's field in the unit it is edited in: what the control shows,
616
- * the bounds it shows them against, and what a typed number stores once it is
617
- * clamped and converted back. Without a `unit` this is what the leaf declares,
618
- * unchanged.
619
- */
620
- function numberField(
621
- leaf: PrefLeaf,
622
- value: number,
623
- ): {
624
- unit: PrefNumberUnit | undefined;
625
- min: number | undefined;
626
- max: number | undefined;
627
- step: number | undefined;
628
- shown: number;
629
- store: (shown: number) => number;
630
- } {
631
- const unit = extra<PrefNumberUnit>(leaf, 'unit');
632
- // `min`, `max` and `step` are declared in the stored unit alongside the
633
- // value, so they convert with it — a leaf storing radians and showing
634
- // degrees would otherwise clamp typed degrees against 0..6.28.
635
- const bounds = unit
636
- ? prefDisplayBounds(leaf as PrefNumber)
637
- : {
638
- min: extra<number>(leaf, 'min'),
639
- max: extra<number>(leaf, 'max'),
640
- step: extra<number>(leaf, 'step'),
641
- };
642
- const lo = bounds.min ?? Number.NEGATIVE_INFINITY;
643
- const hi = bounds.max ?? Number.POSITIVE_INFINITY;
644
- return {
645
- unit,
646
- min: bounds.min,
647
- max: bounds.max,
648
- step: bounds.step,
649
- shown: unit ? unit.toDisplay(value) : value,
650
- store: (shown) => {
651
- const clamped = Math.min(hi, Math.max(lo, shown));
652
- return unit ? unit.fromDisplay(clamped) : clamped;
653
- },
654
- };
655
- }
656
-
657
- /** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` as the `#rrggbb` an
658
- * `<input type="color">` can hold and the 0..1 alpha beside it. Anything else
659
- * reads as opaque black, which is what the input would show for it anyway. */
660
- function splitHexAlpha(value: string): { rgb: string; alpha: number } {
661
- const digits = /^#([0-9a-f]{3,8})$/i.exec(value?.trim() ?? '')?.[1];
662
- const twice = (s: string): string =>
663
- s
664
- .split('')
665
- .map((c) => c + c)
666
- .join('');
667
- const byte = (s: string): number => Number.parseInt(s, 16) / 255;
668
- switch (digits?.length) {
669
- case 3:
670
- return { rgb: `#${twice(digits)}`, alpha: 1 };
671
- case 4:
672
- return { rgb: `#${twice(digits.slice(0, 3))}`, alpha: byte(twice(digits.slice(3))) };
673
- case 6:
674
- return { rgb: `#${digits}`, alpha: 1 };
675
- case 8:
676
- return { rgb: `#${digits.slice(0, 6)}`, alpha: byte(digits.slice(6)) };
677
- default:
678
- return { rgb: '#000000', alpha: 1 };
679
- }
680
- }
681
-
682
- /** A `#rrggbb` and a 0..1 alpha back into the `#rrggbbaa` the config holds. */
683
- function joinHexAlpha(rgb: string, alpha: number): string {
684
- const byte = Math.max(0, Math.min(255, Math.round(alpha * 255)));
685
- return `${rgb}${byte.toString(16).padStart(2, '0')}`;
535
+ return <PropertyField {...field} {...row} value={text.local} onChange={text.type} />;
686
536
  }
687
537
 
688
538
  /** One cell of a paired row, with its path and the leaf it draws. */
@@ -721,7 +571,7 @@ function PairedRow<TC extends Record<string, unknown>>({
721
571
  // The row is named by the pair, so a cell that shares it says which
722
572
  // knob it is — without a caption a paired switch reads only to a
723
573
  // screen reader. A cell whose leaf is deliberately unnamed keeps none.
724
- <span key={path} className="lk-pair-cell">
574
+ <span key={path} className="lk-pair-cell" title={leaf.description || undefined}>
725
575
  {leaf.name ? <span className="lk-pair-label">{leaf.name}</span> : null}
726
576
  <PairCell
727
577
  leaf={leaf}
@@ -745,106 +595,35 @@ function PairCell({
745
595
  value: unknown;
746
596
  write: (value: unknown) => void;
747
597
  }) {
748
- const name = leaf.name;
749
- const title = leaf.description;
750
598
  const text = useDebouncedText(
751
599
  typeof value === 'string' ? value : '',
752
600
  write,
753
601
  extra<number>(leaf, 'debounceMs') ?? 150,
754
602
  );
755
- switch (leaf.kind) {
756
- case 'number': {
757
- const field = numberField(leaf, typeof value === 'number' ? value : 0);
758
- return (
759
- <input
760
- type="number"
761
- aria-label={name}
762
- title={title}
763
- value={field.shown}
764
- min={field.min}
765
- max={field.max}
766
- step={field.step}
767
- onChange={(e) => {
768
- const n = Number(e.target.value);
769
- if (e.target.value !== '' && Number.isFinite(n)) write(field.store(n));
770
- }}
771
- />
772
- );
773
- }
774
- case 'boolean':
775
- return extra<string>(leaf, 'control') === 'switch' ? (
776
- <Switch aria-label={name} isSelected={value === true} onChange={write} />
777
- ) : (
778
- <input
779
- type="checkbox"
780
- aria-label={name}
781
- title={title}
782
- checked={value === true}
783
- onChange={(e) => write(e.target.checked)}
784
- />
785
- );
603
+ const field = labField(leaf, value, write);
604
+ // Unreachable for null: `pairable` admits only kinds with a field.
605
+ if (field === null) return null;
606
+ switch (field.kind) {
786
607
  case 'string':
787
- return (
788
- <input
789
- type="text"
790
- aria-label={name}
791
- title={title}
792
- value={text.local}
793
- placeholder={extra<string>(leaf, 'placeholder')}
794
- maxLength={extra<number>(leaf, 'maxLength')}
795
- onChange={(e) => text.type(e.target.value)}
796
- />
797
- );
608
+ return <PropertyControl {...field} name={leaf.name} value={text.local} onChange={text.type} />;
609
+ case 'number':
610
+ // The pair names the row, so a cell has no room for a unit.
611
+ return <PropertyControl {...field} name={leaf.name} control="input" unit={undefined} />;
798
612
  case 'color': {
799
613
  // The alpha track needs a row of its own to sit under, so a paired
800
614
  // swatch edits the color and carries the stored alpha through untouched.
801
- const { rgb, alpha } = splitHexAlpha(typeof value === 'string' ? value : '');
802
- const carriesAlpha = extra<boolean>(leaf, 'alpha') === true;
615
+ const stored = typeof value === 'string' ? toHex8(value) : '#000000';
803
616
  return (
804
- <input
805
- type="color"
806
- aria-label={name}
807
- title={title}
808
- value={rgb}
809
- onChange={(e) =>
810
- write(carriesAlpha ? joinHexAlpha(e.target.value, alpha) : e.target.value)
811
- }
617
+ <PropertyControl
618
+ {...field}
619
+ name={leaf.name}
620
+ alpha={undefined}
621
+ onChange={(rgb: string) => write(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb)}
812
622
  />
813
623
  );
814
624
  }
815
- case 'enum': {
816
- const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
817
- // A leaf that asked to be segmented stays segmented when it shares a row:
818
- // the choice is the control's shape, not a whole row's worth of chrome.
819
- if (extra<string>(leaf, 'control') === 'radio') {
820
- return (
821
- <span title={title}>
822
- <ToggleBar<string>
823
- ariaLabel={name}
824
- size="sm"
825
- value={typeof value === 'string' ? value : null}
826
- onChange={(next) => next !== null && write(next)}
827
- items={options.map((o) => ({ value: o.value, label: o.label }))}
828
- />
829
- </span>
830
- );
831
- }
832
- // `Select` takes no `title`, so the cell's help hangs off a wrapper.
833
- return (
834
- <span title={title}>
835
- <Select<string>
836
- variant="bare"
837
- aria-label={name}
838
- options={options}
839
- selectedKey={typeof value === 'string' ? value : null}
840
- onSelectionChange={write}
841
- />
842
- </span>
843
- );
844
- }
845
625
  default:
846
- // Unreachable: `pairable` admits only the kinds above.
847
- return null;
626
+ return <PropertyControl {...field} name={leaf.name} />;
848
627
  }
849
628
  }
850
629
 
@@ -0,0 +1,82 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { f } from '../config/builder';
5
+ import { withValueAtPath } from '../config/path';
6
+ import { resolveConfigSchema } from '../config/resolve';
7
+ import { ControlPanel } from './ControlPanel';
8
+ import { summarizeValue } from './inDialog';
9
+
10
+ function Panel({ schema }: { schema: Parameters<typeof resolveConfigSchema>[0] }) {
11
+ const resolved = resolveConfigSchema(schema);
12
+ const [config, setConfig] = useState<Record<string, unknown>>(
13
+ schema.defaults() as Record<string, unknown>,
14
+ );
15
+ return (
16
+ <>
17
+ <ControlPanel
18
+ schema={resolved}
19
+ config={config}
20
+ setConfig={(path, value) => setConfig((prev) => withValueAtPath(prev, path, value))}
21
+ />
22
+ <output data-testid="config">{JSON.stringify(config)}</output>
23
+ </>
24
+ );
25
+ }
26
+
27
+ describe('f.list', () => {
28
+ it('summarizes the list on the row and edits it in a dialog', () => {
29
+ const schema = f.schema({
30
+ icons: f.list(['**/node_modules/**', 'dist']).label('Draw as icons'),
31
+ });
32
+ render(<Panel schema={schema} />);
33
+ const trigger = screen.getByRole('button', { name: 'Edit Draw as icons' });
34
+ expect(trigger.textContent).toContain('**/node_modules/**, dist');
35
+
36
+ fireEvent.click(trigger);
37
+ fireEvent.change(screen.getByRole('textbox', { name: 'Draw as icons 2' }), {
38
+ target: { value: 'build' },
39
+ });
40
+ expect(screen.getByTestId('config').textContent).toBe(
41
+ JSON.stringify({ icons: ['**/node_modules/**', 'build'] }),
42
+ );
43
+ });
44
+
45
+ it('is the kind an f.value with a string-array default takes', () => {
46
+ const resolved = resolveConfigSchema(f.schema({ names: f.value(['a']) }));
47
+ expect(resolved.group.children.names).toMatchObject({ kind: 'list' });
48
+ });
49
+ });
50
+
51
+ describe('.dialog', () => {
52
+ it('hosts any renderer in the dialog, handed the row context', () => {
53
+ const schema = f.schema({
54
+ note: f.string('hi').dialog(({ value, setValue }) => (
55
+ <button type="button" onClick={() => setValue(`${value}!`)}>
56
+ shout
57
+ </button>
58
+ )),
59
+ });
60
+ render(<Panel schema={schema} />);
61
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Note' }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'shout' }));
63
+ expect(screen.getByTestId('config').textContent).toBe(JSON.stringify({ note: 'hi!' }));
64
+ });
65
+
66
+ it('takes a summary of its own', () => {
67
+ const summary = vi.fn((v: unknown) => `${(v as number[]).length} points`);
68
+ const schema = f.schema({ pts: f.custom('points', [1, 2, 3]).dialog(() => null, { summary }) });
69
+ render(<Panel schema={schema} />);
70
+ expect(screen.getByRole('button', { name: 'Edit Pts' }).textContent).toContain('3 points');
71
+ });
72
+ });
73
+
74
+ describe('summarizeValue', () => {
75
+ it('joins lists and names emptiness', () => {
76
+ expect(summarizeValue(['a', 'b'])).toBe('a, b');
77
+ expect(summarizeValue([])).toBe('None');
78
+ expect(summarizeValue('')).toBe('None');
79
+ expect(summarizeValue(3)).toBe('3');
80
+ expect(summarizeValue({ a: 1 })).toBe('{"a":1}');
81
+ });
82
+ });