@weasel-js/labkit 1.7.0 → 1.7.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 (242) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +7 -5
  11. package/dist/chrome/index.js +10 -9
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  17. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  18. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  19. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  20. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  21. package/dist/chunk-EESJUSYR.js.map +1 -0
  22. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  23. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  24. package/dist/chunk-F4ACIK7Z.js +210 -0
  25. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  26. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  27. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  28. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  29. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  30. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  31. package/dist/chunk-PMLZP7RT.js.map +1 -0
  32. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  33. package/dist/chunk-READTDQX.js.map +1 -0
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-RODIY7HI.js} +75 -72
  35. package/dist/chunk-RODIY7HI.js.map +1 -0
  36. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  37. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  38. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  39. package/dist/chunk-T6DLGEJB.js.map +1 -0
  40. package/dist/chunk-U6UQVGFP.js +15583 -0
  41. package/dist/chunk-U6UQVGFP.js.map +1 -0
  42. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  43. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  44. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  45. package/dist/chunk-VYVGVBGY.js.map +1 -0
  46. package/dist/chunk-WFALF2X2.js +302 -0
  47. package/dist/chunk-WFALF2X2.js.map +1 -0
  48. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  49. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  50. package/dist/{chunk-T5IBSPAM.js → chunk-WWJDPNZZ.js} +43 -33
  51. package/dist/chunk-WWJDPNZZ.js.map +1 -0
  52. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  53. package/dist/chunk-XE5HJOPL.js.map +1 -0
  54. package/dist/{chunk-7BO7HNGF.js → chunk-XRX5S5NT.js} +8 -61
  55. package/dist/chunk-XRX5S5NT.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FMK7UQ6V.js → chunk-XZDCEMEL.js} +64 -47
  59. package/dist/chunk-XZDCEMEL.js.map +1 -0
  60. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +18 -10
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +58 -9
  67. package/dist/controls/index.js +7 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +61 -715
  72. package/dist/index.js +456 -432
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +15 -14
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2871
  86. package/dist/passthrough/weasel-ui.js +3 -2
  87. package/dist/primitives/index.d.ts +213 -3
  88. package/dist/primitives/index.js +8 -7
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +284 -51
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +11 -11
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +50 -31
  98. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  99. package/src/annotations/capture.browser.test.ts +47 -0
  100. package/src/annotations/capture.ts +14 -7
  101. package/src/annotations/drawOne.ts +2 -2
  102. package/src/annotations/frac.test.ts +44 -1
  103. package/src/annotations/frac.ts +46 -18
  104. package/src/annotations/index.ts +1 -0
  105. package/src/annotations/paint.test.ts +74 -33
  106. package/src/annotations/paint.ts +70 -32
  107. package/src/annotations/store.test.ts +212 -2
  108. package/src/annotations/store.ts +130 -27
  109. package/src/annotations/svgNodes.test.ts +22 -10
  110. package/src/annotations/svgNodes.ts +3 -3
  111. package/src/annotations/types.ts +29 -10
  112. package/src/canvas/CameraInput.test.tsx +30 -0
  113. package/src/canvas/CameraInput.tsx +16 -15
  114. package/src/canvas/CanvasStack.tsx +5 -5
  115. package/src/canvas/CanvasStackContext.ts +4 -1
  116. package/src/canvas/Stage.test.tsx +34 -25
  117. package/src/canvas/Stage.tsx +3 -3
  118. package/src/canvas/cameraRegistry.ts +1 -0
  119. package/src/canvas/worldSpec.ts +3 -1
  120. package/src/chrome/builtins.test.ts +7 -6
  121. package/src/chrome/builtins.tsx +5 -1
  122. package/src/chrome/index.ts +1 -1
  123. package/src/chrome/labRegions.test.tsx +37 -26
  124. package/src/chrome/merge.test.ts +5 -0
  125. package/src/chrome/merge.ts +4 -2
  126. package/src/chrome/regions/PaletteRegion.test.tsx +4 -5
  127. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  128. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  129. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  130. package/src/chrome/regions/ViewportRegion.less +6 -0
  131. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  132. package/src/chrome/types.ts +4 -2
  133. package/src/config/auto.ts +1 -0
  134. package/src/config/builder.ts +6 -0
  135. package/src/config/types.ts +2 -1
  136. package/src/controls/ControlMatrix.less +245 -0
  137. package/src/controls/ControlMatrix.stories.less +13 -0
  138. package/src/controls/ControlMatrix.stories.tsx +135 -0
  139. package/src/controls/ControlMatrix.test.tsx +206 -0
  140. package/src/controls/ControlMatrix.tsx +446 -0
  141. package/src/controls/ControlPanel.test.tsx +37 -3
  142. package/src/controls/ControlPanel.tsx +16 -5
  143. package/src/controls/index.ts +6 -0
  144. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  145. package/src/dragdrop/DragGhost.tsx +3 -0
  146. package/src/dragdrop/Palette.tsx +2 -0
  147. package/src/dragdrop/dragDrop.test.tsx +34 -33
  148. package/src/index.test.ts +5 -0
  149. package/src/index.ts +7 -1
  150. package/src/instrument/types.ts +0 -6
  151. package/src/job/useJob.ts +14 -9
  152. package/src/lab/Lab.chrome.test.tsx +15 -10
  153. package/src/lab/Lab.nebula.test.tsx +5 -5
  154. package/src/lab/Lab.surface.test.tsx +30 -21
  155. package/src/lab/Lab.test.tsx +75 -74
  156. package/src/lab/Lab.tsx +5 -1
  157. package/src/lab/Lab.undock.test.tsx +15 -10
  158. package/src/lab/LabHeader.test.tsx +15 -14
  159. package/src/lab/LabPalette.test.tsx +12 -7
  160. package/src/lab/LabShell.less +1 -1
  161. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  162. package/src/lab/LabSwitcher.less +2 -0
  163. package/src/lab/LabZoom.test.tsx +55 -44
  164. package/src/lab/LabZoom.tsx +4 -4
  165. package/src/lab/Workspace.surface.test.tsx +16 -13
  166. package/src/lab/Workspace.test.tsx +32 -27
  167. package/src/lab/Workspace.tsx +48 -52
  168. package/src/loupe/AGENTS.md +28 -15
  169. package/src/loupe/CanvasLoupe.tsx +2 -3
  170. package/src/loupe/DomLoupe.tsx +1 -7
  171. package/src/loupe/Loupe.less +16 -0
  172. package/src/loupe/TrialLoupe.tsx +120 -57
  173. package/src/loupe/index.ts +1 -6
  174. package/src/loupe/types.test.ts +3 -3
  175. package/src/loupe/types.ts +24 -27
  176. package/src/loupe/useLoupe.test.tsx +29 -4
  177. package/src/loupe/useLoupe.ts +20 -17
  178. package/src/overview/OverviewMarks.test.tsx +9 -8
  179. package/src/overview/OverviewMarks.tsx +12 -4
  180. package/src/overview/TrialOverview.test.tsx +18 -17
  181. package/src/overview/TrialOverview.tsx +3 -3
  182. package/src/passthrough/weasel-ui.test.ts +1 -1
  183. package/src/passthrough/weasel-ui.ts +1 -1
  184. package/src/primitives/FloatingPanel.tsx +7 -3
  185. package/src/primitives/FpsMeter.less +1 -2
  186. package/src/primitives/JobProgress.less +1 -1
  187. package/src/primitives/Readout.test.tsx +2 -2
  188. package/src/primitives/ScaleIndicator.less +1 -1
  189. package/src/primitives/Split.tsx +69 -41
  190. package/src/primitives/StatusBar.less +1 -1
  191. package/src/primitives/Toolbar.tsx +3 -3
  192. package/src/primitives/ZoomControl.less +1 -2
  193. package/src/specimen/Specimen.less +1 -0
  194. package/src/specimen/Specimen.tsx +0 -1
  195. package/src/state/undock.ts +3 -0
  196. package/src/state/useOpenOnce.ts +20 -16
  197. package/src/state/view.ts +1 -0
  198. package/src/styles.less +3 -0
  199. package/src/surface/index.ts +1 -1
  200. package/src/surface/useTiledSurface.test.tsx +14 -0
  201. package/src/surface/useTiledSurface.ts +11 -3
  202. package/src/trial/Trial.annotations.persist.test.tsx +20 -9
  203. package/src/trial/Trial.annotations.test.tsx +105 -27
  204. package/src/trial/Trial.canvas.test.tsx +21 -17
  205. package/src/trial/Trial.config.test.tsx +34 -27
  206. package/src/trial/Trial.less +0 -16
  207. package/src/trial/Trial.loupe.test.tsx +51 -25
  208. package/src/trial/Trial.targets.test.tsx +9 -8
  209. package/src/trial/Trial.test.tsx +51 -46
  210. package/src/trial/Trial.trialId.test.tsx +7 -6
  211. package/src/trial/Trial.tsx +74 -62
  212. package/src/trial/TrialChrome.tsx +6 -3
  213. package/src/trial/index.ts +2 -0
  214. package/src/trial/loupeSwitch.ts +15 -0
  215. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  216. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  217. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  218. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  219. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  220. package/dist/chunk-275JRRVD.js.map +0 -1
  221. package/dist/chunk-4LJSC7MB.js +0 -143
  222. package/dist/chunk-4LJSC7MB.js.map +0 -1
  223. package/dist/chunk-7BO7HNGF.js.map +0 -1
  224. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  225. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  226. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  227. package/dist/chunk-HXXTULDN.js.map +0 -1
  228. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  229. package/dist/chunk-IJJUK4FU.js.map +0 -1
  230. package/dist/chunk-J7OWWTCW.js.map +0 -1
  231. package/dist/chunk-M7HP3FTK.js +0 -549
  232. package/dist/chunk-M7HP3FTK.js.map +0 -1
  233. package/dist/chunk-MIM5R4ZT.js +0 -14972
  234. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  235. package/dist/chunk-RWNDDKOH.js.map +0 -1
  236. package/dist/chunk-T5IBSPAM.js.map +0 -1
  237. package/dist/chunk-UNAZSLWE.js.map +0 -1
  238. package/dist/chunk-WPLAANH4.js.map +0 -1
  239. package/dist/chunk-Y23G2VZL.js.map +0 -1
  240. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  241. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  242. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -0,0 +1,446 @@
1
+ import { getAlpha01, isBuiltinToolPref, toHex8, withAlpha01 } from '@weasel-js/core';
2
+ import {
3
+ Button,
4
+ Focusable,
5
+ isPrefLeaf,
6
+ type PrefLeaf,
7
+ type PropertyControlProps,
8
+ PropertyHelp,
9
+ PropertyList,
10
+ PropertyPanel,
11
+ prefFieldProps,
12
+ type StanceProps,
13
+ Tooltip,
14
+ TooltipTrigger,
15
+ useOverlayPortal,
16
+ } from '@weasel-js/ui';
17
+ import {
18
+ type CSSProperties,
19
+ type DOMAttributes,
20
+ type MouseEvent,
21
+ type ReactElement,
22
+ type ReactNode,
23
+ useRef,
24
+ useState,
25
+ } from 'react';
26
+ import { Dialog as RACDialog, Popover as RACPopover } from 'react-aria-components';
27
+ import { auto as autoValue } from '../config/auto';
28
+ import { schemaNodeAtPath, valueAtPath } from '../config/path';
29
+ import type { ResolvedConfig } from '../config/types';
30
+ import { ControlRow } from './ControlPanel';
31
+ import { summarizeValue } from './inDialog';
32
+
33
+ /** One column of a {@link ControlMatrix}: a config group, every one the same shape. */
34
+ export interface ControlMatrixColumn {
35
+ /** Config path prefix of this column's group, e.g. `'looks.window'`. */
36
+ key: string;
37
+ /** Short header shown at narrow width, e.g. `'Win'`. */
38
+ label: string;
39
+ /** Full name, for the header's tooltip and accessible name, e.g. `'Window'`. */
40
+ title?: string;
41
+ }
42
+
43
+ /** One row of a {@link ControlMatrix}: a setting every column's group may hold. */
44
+ export interface ControlMatrixRow {
45
+ /** Path of the setting inside each column's group, e.g. `'background'` or `'fill.colorBy'`. */
46
+ key: string;
47
+ /** Row label. Defaults to the leaf's own name from the schema. */
48
+ label?: string;
49
+ }
50
+
51
+ export interface ControlMatrixProps extends StanceProps {
52
+ /** Heads the matrix. With `title`, `stance` or `tone` given it sits in a
53
+ * `<PropertyPanel>`, as a `ControlPanel` does. */
54
+ title?: ReactNode;
55
+ /** The whole resolved schema. A cell's leaf is looked up at
56
+ * `${column.key}.${row.key}`; a cell with none draws a dash. */
57
+ schema: ResolvedConfig;
58
+ /** The scopes. The first is the fallback the others inherit from: its cells
59
+ * never inherit, and its header is emphasized. */
60
+ columns: readonly ControlMatrixColumn[];
61
+ rows: readonly ControlMatrixRow[];
62
+ /** Values to show, already resolved: an inherited cell's value is the one it
63
+ * inherits. */
64
+ config: Record<string, unknown>;
65
+ /** Full dotted paths whose cells inherit, drawn ghosted. */
66
+ auto: ReadonlySet<string>;
67
+ /** Writes a value, which pins the cell, or labkit's `auto` sentinel, which
68
+ * unpins it. */
69
+ setConfig: (path: string, value: unknown) => void;
70
+ /** Clicking a column header. Without it the headers are plain text. */
71
+ onColumnClick?: (column: ControlMatrixColumn) => void;
72
+ /** What an inherited cell's tooltip says. Defaults to `from <first column>`. */
73
+ inheritHint?: (column: ControlMatrixColumn) => string;
74
+ className?: string;
75
+ }
76
+
77
+ const NO_AUTO: ReadonlySet<string> = new Set();
78
+ const noop = (): void => {};
79
+
80
+ const nameOf = (column: ControlMatrixColumn): string => column.title ?? column.label;
81
+
82
+ /** The open popover: which cell, and the element it hangs off. */
83
+ interface OpenCell {
84
+ path: string;
85
+ leaf: PrefLeaf;
86
+ column: ControlMatrixColumn;
87
+ first: boolean;
88
+ label: string;
89
+ }
90
+
91
+ /**
92
+ * One setting across several config groups of the same shape: settings down
93
+ * the side, scopes across the top, a compact read-only cell at each crossing.
94
+ * A cell inherits from the first column until it is pinned; inherited cells
95
+ * draw ghosted. Click a cell to edit it with the control a `ControlPanel` would
96
+ * draw (a boolean flips and a color opens its picker in place); editing pins,
97
+ * and the popover's Inherit button — or Option/Alt-click on the cell — unpins.
98
+ */
99
+ export function ControlMatrix({
100
+ title,
101
+ stance,
102
+ tone,
103
+ schema,
104
+ columns,
105
+ rows,
106
+ config,
107
+ auto,
108
+ setConfig,
109
+ onColumnClick,
110
+ inheritHint,
111
+ className,
112
+ }: ControlMatrixProps): ReactElement {
113
+ const fallback = columns[0];
114
+ const hintFor =
115
+ inheritHint ?? ((_: ControlMatrixColumn) => (fallback ? `from ${nameOf(fallback)}` : ''));
116
+ const [open, setOpen] = useState<OpenCell | null>(null);
117
+ const triggerRef = useRef<HTMLElement | null>(null);
118
+ const { anchor, portalProps } = useOverlayPortal();
119
+
120
+ const leafAt = (path: string): PrefLeaf | undefined => {
121
+ const node = schemaNodeAtPath(schema.group, path);
122
+ return node && isPrefLeaf(node) ? node : undefined;
123
+ };
124
+
125
+ const rowSpecs = rows.map((row) => {
126
+ const leaves = columns.map((column) => leafAt(`${column.key}.${row.key}`));
127
+ const named = leaves.find((leaf) => leaf !== undefined);
128
+ return { row, leaves, label: row.label ?? named?.name ?? row.key, about: named?.description };
129
+ });
130
+
131
+ const table = (
132
+ <div className={className ? `lk-control-matrix ${className}` : 'lk-control-matrix'}>
133
+ {anchor}
134
+ <table className="lk-control-matrix__table">
135
+ <thead>
136
+ <tr>
137
+ <td className="lk-control-matrix__corner" />
138
+ {columns.map((column, i) => (
139
+ <th
140
+ key={column.key}
141
+ scope="col"
142
+ className={i === 0 ? 'lk-control-matrix__head is-first' : 'lk-control-matrix__head'}
143
+ >
144
+ <ColumnHeader column={column} onClick={onColumnClick} />
145
+ </th>
146
+ ))}
147
+ </tr>
148
+ </thead>
149
+ <tbody>
150
+ {rowSpecs.map(({ row, leaves, label, about }) => (
151
+ <tr key={row.key}>
152
+ <th scope="row" className="lk-control-matrix__label">
153
+ <span className="lk-control-matrix__label-text">{label}</span>
154
+ {about ? <PropertyHelp label={label} description={about} /> : null}
155
+ </th>
156
+ {columns.map((column, i) => {
157
+ const path = `${column.key}.${row.key}`;
158
+ const leaf = leaves[i];
159
+ if (!leaf) {
160
+ return (
161
+ <td key={column.key} className="lk-control-matrix__cell is-missing">
162
+ <span role="img" aria-label={`${nameOf(column)} has no ${label}`}>
163
+ —
164
+ </span>
165
+ </td>
166
+ );
167
+ }
168
+ const first = i === 0;
169
+ const inherited = !first && auto.has(path);
170
+ return (
171
+ <td
172
+ key={column.key}
173
+ className={
174
+ inherited ? 'lk-control-matrix__cell is-inherited' : 'lk-control-matrix__cell'
175
+ }
176
+ >
177
+ <Cell
178
+ path={path}
179
+ leaf={leaf}
180
+ value={valueAtPath(config, path)}
181
+ name={`${nameOf(column)} ${label}`}
182
+ hint={inherited ? hintFor(column) : undefined}
183
+ canUnpin={!first && !inherited}
184
+ setConfig={setConfig}
185
+ onOpen={(el) => {
186
+ triggerRef.current = el;
187
+ setOpen({ path, leaf, column, first, label });
188
+ }}
189
+ />
190
+ </td>
191
+ );
192
+ })}
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ <RACPopover
198
+ triggerRef={triggerRef}
199
+ isOpen={open !== null}
200
+ onOpenChange={(next) => {
201
+ if (!next) setOpen(null);
202
+ }}
203
+ placement="bottom"
204
+ className="lk-control-matrix__popover"
205
+ data-weasel-overlay=""
206
+ {...portalProps}
207
+ >
208
+ {open ? (
209
+ <RACDialog
210
+ className="lk-control-matrix__editor"
211
+ aria-label={`${nameOf(open.column)} ${open.label}`}
212
+ >
213
+ <PropertyList pack="one-up">
214
+ <ControlRow
215
+ path={open.path}
216
+ leaf={open.leaf}
217
+ resolved={schema}
218
+ config={config}
219
+ shown={config}
220
+ setConfig={setConfig}
221
+ pack="one-up"
222
+ auto={NO_AUTO}
223
+ setRowAuto={noop}
224
+ />
225
+ </PropertyList>
226
+ {clearable(open.leaf) ? (
227
+ <div className="lk-control-matrix__editor-actions">
228
+ <Button
229
+ size="sm"
230
+ variant="ghost"
231
+ disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
232
+ onClick={() => setConfig(open.path, null)}
233
+ >
234
+ Clear
235
+ </Button>
236
+ </div>
237
+ ) : null}
238
+ {open.first ? null : (
239
+ <div className="lk-control-matrix__editor-actions">
240
+ <Button
241
+ size="sm"
242
+ variant="ghost"
243
+ disabled={auto.has(open.path)}
244
+ tooltip={`Inherit ${hintFor(open.column)}`}
245
+ onClick={() => setConfig(open.path, autoValue)}
246
+ >
247
+ Inherit
248
+ </Button>
249
+ </div>
250
+ )}
251
+ </RACDialog>
252
+ ) : null}
253
+ </RACPopover>
254
+ </div>
255
+ );
256
+
257
+ if (title === undefined && stance === undefined && tone === undefined) return table;
258
+ return (
259
+ <PropertyPanel title={title} stance={stance} tone={tone}>
260
+ {table}
261
+ </PropertyPanel>
262
+ );
263
+ }
264
+
265
+ function ColumnHeader({
266
+ column,
267
+ onClick,
268
+ }: {
269
+ column: ControlMatrixColumn;
270
+ onClick?: (column: ControlMatrixColumn) => void;
271
+ }) {
272
+ const name = nameOf(column);
273
+ if (!onClick) {
274
+ return column.title && column.title !== column.label ? (
275
+ <abbr title={column.title} className="lk-control-matrix__head-text">
276
+ {column.label}
277
+ </abbr>
278
+ ) : (
279
+ <span className="lk-control-matrix__head-text">{column.label}</span>
280
+ );
281
+ }
282
+ return (
283
+ <TooltipTrigger>
284
+ <Focusable>
285
+ <button
286
+ type="button"
287
+ className="lk-control-matrix__head-button"
288
+ aria-label={name}
289
+ onClick={() => onClick(column)}
290
+ >
291
+ {column.label}
292
+ </button>
293
+ </Focusable>
294
+ <Tooltip>{name}</Tooltip>
295
+ </TooltipTrigger>
296
+ );
297
+ }
298
+
299
+ /** A color leaf whose default is empty: unset is a value of its own, not black. */
300
+ const clearable = (leaf: PrefLeaf): boolean => leaf.kind === 'color' && leaf.default === '';
301
+
302
+ /** The number of decimals a step implies: `0.05` shows two, `1` none. */
303
+ function decimalsOf(step: number | undefined): number {
304
+ if (step === undefined || !Number.isFinite(step) || step <= 0) return 0;
305
+ const text = String(step);
306
+ const exp = /e-(\d+)$/.exec(text);
307
+ if (exp) return Number(exp[1]);
308
+ const dot = text.indexOf('.');
309
+ return dot === -1 ? 0 : text.length - dot - 1;
310
+ }
311
+
312
+ /** What a cell shows of a value it opens a popover to edit. */
313
+ function summaryOf(field: PropertyControlProps | null, value: unknown): ReactNode {
314
+ if (field?.kind === 'number') {
315
+ return typeof field.value === 'number' ? field.value.toFixed(decimalsOf(field.step)) : '—';
316
+ }
317
+ if (field?.kind === 'enum') {
318
+ const option = field.options.find((o) => o.value === field.value);
319
+ if (!option) return '—';
320
+ return typeof option.glyph === 'string' ? option.glyph : option.label;
321
+ }
322
+ if (field?.kind === 'string') return field.value || '—';
323
+ return summarizeValue(value);
324
+ }
325
+
326
+ function Cell({
327
+ path,
328
+ leaf,
329
+ value,
330
+ name,
331
+ hint,
332
+ canUnpin,
333
+ setConfig,
334
+ onOpen,
335
+ }: {
336
+ path: string;
337
+ leaf: PrefLeaf;
338
+ value: unknown;
339
+ /** The cell's accessible name: column and row. */
340
+ name: string;
341
+ /** Present while the cell inherits: where from. */
342
+ hint?: string;
343
+ canUnpin: boolean;
344
+ setConfig: (path: string, value: unknown) => void;
345
+ onOpen: (el: HTMLElement) => void;
346
+ }) {
347
+ const shown = value ?? leaf.default;
348
+ const write = (next: unknown): void => setConfig(path, next);
349
+ const field = isBuiltinToolPref(leaf)
350
+ ? prefFieldProps(leaf, { value: shown, setValue: write })
351
+ : null;
352
+ const label = hint ? `${name}, ${hint}` : name;
353
+
354
+ // Option/Alt-click hands a pinned cell back to the fallback, and does nothing
355
+ // else on any cell — it must not also flip or open what it lands on.
356
+ const unpinned = (e: MouseEvent): boolean => {
357
+ if (!e.altKey) return false;
358
+ e.preventDefault();
359
+ if (canUnpin) setConfig(path, autoValue);
360
+ return true;
361
+ };
362
+
363
+ // The shape `Focusable` asks of the element it makes a tooltip trigger.
364
+ let control: ReactElement<DOMAttributes<HTMLElement>, string>;
365
+ if (field?.kind === 'boolean') {
366
+ const on = field.value === true;
367
+ control = (
368
+ <button
369
+ type="button"
370
+ role="switch"
371
+ aria-checked={on}
372
+ aria-label={label}
373
+ className="lk-control-matrix__switch"
374
+ onClick={(e) => {
375
+ if (!unpinned(e)) field.onChange(!on);
376
+ }}
377
+ >
378
+ <span className="lk-control-matrix__track" aria-hidden="true">
379
+ <span className="lk-control-matrix__thumb" />
380
+ </span>
381
+ </button>
382
+ );
383
+ } else if (field?.kind === 'color' && !clearable(leaf)) {
384
+ const stored =
385
+ typeof field.value === 'string' && field.value !== '' ? toHex8(field.value) : '#000000ff';
386
+ control = (
387
+ <input
388
+ type="color"
389
+ aria-label={label}
390
+ className="lk-control-matrix__swatch"
391
+ value={stored.slice(0, 7)}
392
+ onClick={(e) => {
393
+ unpinned(e);
394
+ }}
395
+ onChange={(e) => {
396
+ const rgb = e.currentTarget.value;
397
+ field.onChange(field.alpha ? withAlpha01(rgb, getAlpha01(stored)) : rgb);
398
+ }}
399
+ />
400
+ );
401
+ } else if (field?.kind === 'color') {
402
+ // A color that may be unset opens the popover, which is the only place it can be cleared.
403
+ const set = typeof field.value === 'string' && field.value !== '';
404
+ control = (
405
+ <button
406
+ type="button"
407
+ aria-label={set ? label : `${label}, unset`}
408
+ aria-haspopup="dialog"
409
+ className="lk-control-matrix__value"
410
+ onClick={(e) => {
411
+ if (!unpinned(e)) onOpen(e.currentTarget);
412
+ }}
413
+ >
414
+ <span
415
+ className={set ? 'lk-control-matrix__chip' : 'lk-control-matrix__chip is-unset'}
416
+ style={set ? ({ '--lk-chip': field.value } as CSSProperties) : undefined}
417
+ aria-hidden="true"
418
+ />
419
+ </button>
420
+ );
421
+ } else {
422
+ const valueClass =
423
+ field?.kind === 'number' ? 'lk-control-matrix__value is-number' : 'lk-control-matrix__value';
424
+ control = (
425
+ <button
426
+ type="button"
427
+ aria-label={label}
428
+ aria-haspopup="dialog"
429
+ className={valueClass}
430
+ onClick={(e) => {
431
+ if (!unpinned(e)) onOpen(e.currentTarget);
432
+ }}
433
+ >
434
+ {summaryOf(field, shown)}
435
+ </button>
436
+ );
437
+ }
438
+
439
+ if (!hint) return control;
440
+ return (
441
+ <TooltipTrigger>
442
+ <Focusable>{control}</Focusable>
443
+ <Tooltip>{hint}</Tooltip>
444
+ </TooltipTrigger>
445
+ );
446
+ }
@@ -919,6 +919,30 @@ describe('<ControlPanel> option icons', () => {
919
919
  expect(ok.textContent).toBe('');
920
920
  expect(screen.getByRole('radio', { name: 'bad' }).textContent).toBe('bad');
921
921
  });
922
+
923
+ it("draws an option's icon beside its label in a select's trigger and list", () => {
924
+ const status = f.enum('ok', [
925
+ { value: 'ok', label: 'ok', icon: 'statusSuccess' },
926
+ { value: 'bad', label: 'bad' },
927
+ ]);
928
+ render(
929
+ <ControlPanel
930
+ schema={resolveConfigSchema(f.schema({ status }), [])}
931
+ config={{ status: 'ok' }}
932
+ setConfig={() => {}}
933
+ />,
934
+ );
935
+ const trigger = screen.getByRole('button', { name: /ok/ });
936
+ expect(trigger.querySelector('[aria-hidden="true"] > svg')).not.toBeNull();
937
+ expect(trigger).toHaveTextContent('ok');
938
+ act(() => {
939
+ fireEvent.click(trigger);
940
+ });
941
+ expect(
942
+ screen.getByRole('option', { name: 'ok' }).querySelector('[aria-hidden="true"] > svg'),
943
+ ).not.toBeNull();
944
+ expect(screen.getByRole('option', { name: 'bad' }).querySelectorAll('svg')).toHaveLength(1);
945
+ });
922
946
  });
923
947
 
924
948
  describe('<ControlPanel> pair', () => {
@@ -1129,14 +1153,20 @@ describe('<ControlPanel> resettable group', () => {
1129
1153
  const schema = resolveConfigSchema(
1130
1154
  f.schema({
1131
1155
  name: f.string('a'),
1132
- modes: f.group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) }).resettable(),
1156
+ modes: f
1157
+ .group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) })
1158
+ .resettable(),
1133
1159
  plain: f.group({ four: f.number(0) }),
1134
1160
  }),
1135
1161
  );
1136
1162
 
1137
1163
  it('puts a reset button on the group heading, disabled while nothing has changed', () => {
1138
1164
  render(
1139
- <ControlPanel schema={schema} config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }} setConfig={vi.fn()} />,
1165
+ <ControlPanel
1166
+ schema={schema}
1167
+ config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }}
1168
+ setConfig={vi.fn()}
1169
+ />,
1140
1170
  );
1141
1171
  expect(screen.getByRole('button', { name: 'Reset Modes' })).toBeDisabled();
1142
1172
  expect(screen.queryByRole('button', { name: 'Reset Plain' })).toBeNull();
@@ -1145,7 +1175,11 @@ describe('<ControlPanel> resettable group', () => {
1145
1175
  it('writes the default of every changed value beneath the group and nothing else', () => {
1146
1176
  const setConfig = vi.fn();
1147
1177
  render(
1148
- <ControlPanel schema={schema} config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }} setConfig={setConfig} />,
1178
+ <ControlPanel
1179
+ schema={schema}
1180
+ config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }}
1181
+ setConfig={setConfig}
1182
+ />,
1149
1183
  );
1150
1184
  fireEvent.click(screen.getByRole('button', { name: 'Reset Modes' }));
1151
1185
  expect(setConfig.mock.calls).toEqual([
@@ -123,7 +123,9 @@ const NO_AUTO: ReadonlySet<string> = new Set();
123
123
  function leavesUnder(group: PrefGroup, at: string): [string, unknown][] {
124
124
  return Object.entries(group.children).flatMap(([key, child]) => {
125
125
  const path = at === '' ? key : `${at}.${key}`;
126
- return isPrefLeaf(child) ? [[path, child.default] as [string, unknown]] : leavesUnder(child, path);
126
+ return isPrefLeaf(child)
127
+ ? [[path, child.default] as [string, unknown]]
128
+ : leavesUnder(child, path);
127
129
  });
128
130
  }
129
131
 
@@ -140,7 +142,9 @@ function ResetGroup({
140
142
  config: Record<string, unknown>;
141
143
  setConfig: (path: string, value: unknown) => void;
142
144
  }) {
143
- const changed = leavesUnder(group, path).filter(([p, d]) => !Object.is(valueAtPath(config, p), d));
145
+ const changed = leavesUnder(group, path).filter(
146
+ ([p, d]) => !Object.is(valueAtPath(config, p), d),
147
+ );
144
148
  return (
145
149
  <Button
146
150
  iconOnly
@@ -267,7 +271,12 @@ export function ControlPanel<TC extends Record<string, unknown>>({
267
271
  span
268
272
  actions={
269
273
  resolved.resettable?.has(path) ? (
270
- <ResetGroup group={found} path={path} config={config as Record<string, unknown>} setConfig={setConfig} />
274
+ <ResetGroup
275
+ group={found}
276
+ path={path}
277
+ config={config as Record<string, unknown>}
278
+ setConfig={setConfig}
279
+ />
271
280
  ) : undefined
272
281
  }
273
282
  {...fold(path, undefined, rows.grid)}
@@ -390,7 +399,7 @@ function sectionKey(section: SectionSpec): string {
390
399
  return section.at === '' ? section.label : `${section.at}.${section.label}`;
391
400
  }
392
401
 
393
- interface ControlRowProps<TC extends Record<string, unknown>> {
402
+ export interface ControlRowProps<TC extends Record<string, unknown>> {
394
403
  path: string;
395
404
  leaf: PrefLeaf;
396
405
  resolved: ResolvedConfig;
@@ -423,7 +432,9 @@ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
423
432
  return (leaf as unknown as Record<string, T | undefined>)[key];
424
433
  }
425
434
 
426
- function ControlRow<TC extends Record<string, unknown>>({
435
+ /** One leaf's row, as the panel draws it. Shared with `ControlMatrix`, whose
436
+ * popover edits one cell with exactly this row. */
437
+ export function ControlRow<TC extends Record<string, unknown>>({
427
438
  path,
428
439
  leaf,
429
440
  resolved,
@@ -1,3 +1,9 @@
1
+ export {
2
+ ControlMatrix,
3
+ type ControlMatrixColumn,
4
+ type ControlMatrixProps,
5
+ type ControlMatrixRow,
6
+ } from './ControlMatrix';
1
7
  export { ControlPanel } from './ControlPanel';
2
8
  export { type InDialogOptions, inDialog, summarizeValue } from './inDialog';
3
9
  export type {
@@ -1,4 +1,5 @@
1
- import { openPointerSession, type PointerSession, useVisibleRaf } from '@weasel-js/core';
1
+ import { openPointerSession, type PointerSession, useLatest, useVisibleRaf } from '@weasel-js/core';
2
+ import { boxContainsPoint } from '@weasel-js/geom';
2
3
  import {
3
4
  type PointerEvent as ReactPointerEvent,
4
5
  type RefObject,
@@ -18,12 +19,16 @@ import type {
18
19
  } from '../instrument/types';
19
20
  import { DragGhost } from './DragGhost';
20
21
 
22
+ /** A palette drag in progress. `screenPos` is in client pixels; `feedback` is
23
+ * the last `onDragOver` answer — `null` off the canvas or without one. */
21
24
  export interface DragState {
22
25
  item: PaletteItem;
23
26
  screenPos: Point;
24
27
  feedback: DragFeedback | null;
25
28
  }
26
29
 
30
+ /** Inputs to `useDragDrop`: the capability, the element that counts as the
31
+ * drop target, and the trial state a drop rewrites. */
27
32
  export interface UseDragDropArgs<TS, TC> {
28
33
  capability: DragDropCapability<TS, TC>;
29
34
  canvasContainerRef: RefObject<HTMLElement | null>;
@@ -37,11 +42,18 @@ export interface UseDragDropArgs<TS, TC> {
37
42
  emit: (event: string) => void;
38
43
  }
39
44
 
45
+ /** The live drag, and the handler a palette item calls on pointerdown. */
40
46
  export interface UseDragDropResult {
41
47
  drag: DragState | null;
42
48
  startDrag: (item: PaletteItem, e: ReactPointerEvent) => void;
43
49
  }
44
50
 
51
+ /**
52
+ * Run palette-to-canvas drags for a `DragDropCapability`. Releasing over the
53
+ * container converts the pointer to world coordinates, applies `onDrop`'s
54
+ * state and emits `'canvas.itemAdded'`; releasing anywhere else, or a canceled
55
+ * gesture, drops nothing. `onDragOver` is probed at most once per frame.
56
+ */
45
57
  export function useDragDrop<TS, TC>({
46
58
  capability,
47
59
  canvasContainerRef,
@@ -53,8 +65,8 @@ export function useDragDrop<TS, TC>({
53
65
  worldSpec,
54
66
  }: UseDragDropArgs<TS, TC>): UseDragDropResult {
55
67
  const [drag, setDrag] = useState<DragState | null>(null);
68
+ // Owned by the handlers below, which write it alongside every `setDrag`.
56
69
  const dragRef = useRef<DragState | null>(null);
57
- dragRef.current = drag;
58
70
  /** The move the next frame will resolve. Doubles as the throttle's flag:
59
71
  * non-null means a frame is already queued. */
60
72
  const pendingPos = useRef<Point | null>(null);
@@ -62,20 +74,14 @@ export function useDragDrop<TS, TC>({
62
74
 
63
75
  /** A session keeps the closure it was opened with, so everything the drop
64
76
  * reads has to come from here rather than from that closure. */
65
- const liveRef = useRef({ capability, state, config, setState, emit, view, worldSpec });
66
- liveRef.current = { capability, state, config, setState, emit, view, worldSpec };
77
+ const liveRef = useLatest({ capability, state, config, setState, emit, view, worldSpec });
67
78
 
68
79
  const isOverCanvas = useCallback(
69
80
  (screenPos: Point): boolean => {
70
81
  const el = canvasContainerRef.current;
71
82
  if (!el) return false;
72
83
  const r = el.getBoundingClientRect();
73
- return (
74
- screenPos.x >= r.left &&
75
- screenPos.x <= r.right &&
76
- screenPos.y >= r.top &&
77
- screenPos.y <= r.bottom
78
- );
84
+ return boxContainsPoint([r.left, r.top, r.right, r.bottom], screenPos.x, screenPos.y);
79
85
  },
80
86
  [canvasContainerRef],
81
87
  );
@@ -92,7 +98,7 @@ export function useDragDrop<TS, TC>({
92
98
  frame,
93
99
  );
94
100
  },
95
- [canvasContainerRef],
101
+ [canvasContainerRef, liveRef],
96
102
  );
97
103
 
98
104
  const frameLoop = useVisibleRaf(() => {
@@ -106,7 +112,8 @@ export function useDragDrop<TS, TC>({
106
112
  const world = screenToWorldFromContainer(screenPos);
107
113
  if (world) feedback = live.capability.onDragOver(world, active.item, live.state, live.config);
108
114
  }
109
- setDrag({ ...active, screenPos, feedback });
115
+ dragRef.current = { ...active, screenPos, feedback };
116
+ setDrag(dragRef.current);
110
117
  });
111
118
 
112
119
  const clearDrag = useCallback(() => {
@@ -154,7 +161,7 @@ export function useDragDrop<TS, TC>({
154
161
  onCancel: clearDrag,
155
162
  });
156
163
  },
157
- [clearDrag, frameLoop, isOverCanvas, screenToWorldFromContainer],
164
+ [clearDrag, frameLoop, isOverCanvas, liveRef, screenToWorldFromContainer],
158
165
  );
159
166
 
160
167
  useEffect(() => () => sessionRef.current?.cancel(), []);
@@ -162,10 +169,12 @@ export function useDragDrop<TS, TC>({
162
169
  return { drag, startDrag };
163
170
  }
164
171
 
172
+ /** Props for `DragOverlay`. */
165
173
  export interface DragOverlayProps {
166
174
  drag: DragState | null;
167
175
  }
168
176
 
177
+ /** The ghost for `drag`, or nothing when no drag is live. */
169
178
  export function DragOverlay({ drag }: DragOverlayProps) {
170
179
  if (!drag) return null;
171
180
  return <DragGhost item={drag.item} screenPos={drag.screenPos} />;