@weasel-js/labkit 1.2.0 → 1.3.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 (227) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  4. package/dist/_dts/types-BttTIent.d.ts +314 -0
  5. package/dist/_dts/types-DCGEcMO_.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +3 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +9 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/chunk-3XXPU73K.js +138 -0
  13. package/dist/chunk-3XXPU73K.js.map +1 -0
  14. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-6X5RBC6G.js +366 -0
  17. package/dist/chunk-6X5RBC6G.js.map +1 -0
  18. package/dist/{chunk-574LJAV4.js → chunk-BDJXIBRZ.js} +33 -35
  19. package/dist/chunk-BDJXIBRZ.js.map +1 -0
  20. package/dist/{chunk-SFL7NFKN.js → chunk-BMW4TDP5.js} +15 -7
  21. package/dist/chunk-BMW4TDP5.js.map +1 -0
  22. package/dist/chunk-JDROYQM3.js +266 -0
  23. package/dist/chunk-JDROYQM3.js.map +1 -0
  24. package/dist/{chunk-THBG7FQZ.js → chunk-LGKRIUSW.js} +15 -12
  25. package/dist/chunk-LGKRIUSW.js.map +1 -0
  26. package/dist/{chunk-BOHF3PQO.js → chunk-QCICIHEO.js} +8129 -5196
  27. package/dist/chunk-QCICIHEO.js.map +1 -0
  28. package/dist/chunk-QRYPXSGP.js +212 -0
  29. package/dist/chunk-QRYPXSGP.js.map +1 -0
  30. package/dist/chunk-RGT4EJIB.js +6961 -0
  31. package/dist/chunk-RGT4EJIB.js.map +1 -0
  32. package/dist/chunk-SBXNQW4G.js +3 -0
  33. package/dist/chunk-SBXNQW4G.js.map +1 -0
  34. package/dist/chunk-T66HU3HX.js +17 -0
  35. package/dist/chunk-T66HU3HX.js.map +1 -0
  36. package/dist/chunk-WTI26YTM.js +266 -0
  37. package/dist/chunk-WTI26YTM.js.map +1 -0
  38. package/dist/controls/index.d.ts +19 -5
  39. package/dist/controls/index.js +3 -1
  40. package/dist/dragdrop/index.d.ts +46 -3
  41. package/dist/dragdrop/index.js +3 -1
  42. package/dist/index.d.ts +735 -565
  43. package/dist/index.js +661 -1019
  44. package/dist/index.js.map +1 -1
  45. package/dist/layers/index.d.ts +5 -3
  46. package/dist/layers/index.js +4 -1
  47. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  48. package/dist/passthrough/weasel-canvas.js +1 -1
  49. package/dist/passthrough/weasel-ui.d.ts +3811 -3671
  50. package/dist/passthrough/weasel-ui.js +3 -2
  51. package/dist/primitives/index.d.ts +102 -8
  52. package/dist/primitives/index.js +5 -1
  53. package/dist/state/index.d.ts +3 -2
  54. package/dist/state/index.js +2 -2
  55. package/dist/styles.css +490 -803
  56. package/dist/surface/index.js +2 -1
  57. package/dist/ui/layers/index.d.ts +8 -2
  58. package/dist/ui/layers/index.js +2 -3
  59. package/dist/undo/index.d.ts +4 -2
  60. package/package.json +16 -7
  61. package/src/canvas/AGENTS.md +4 -0
  62. package/src/canvas/CanvasStack.tsx +6 -2
  63. package/src/canvas/useLayerScheduler.ts +22 -15
  64. package/src/canvas/usePanZoom.test.ts +80 -0
  65. package/src/canvas/usePanZoom.ts +16 -2
  66. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  67. package/src/chrome/builtins.test.ts +153 -0
  68. package/src/chrome/builtins.tsx +205 -0
  69. package/src/chrome/index.ts +25 -0
  70. package/src/chrome/merge.test.ts +51 -0
  71. package/src/chrome/merge.ts +52 -0
  72. package/src/chrome/regions/PaletteRegion.less +14 -0
  73. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  74. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  75. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  76. package/src/chrome/regions/StatusRegion.tsx +31 -0
  77. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  78. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  79. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  80. package/src/chrome/regions/ViewportRegion.less +42 -0
  81. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  82. package/src/chrome/regions/regions.test.tsx +81 -0
  83. package/src/chrome/types.ts +127 -0
  84. package/src/config/builder.test.ts +93 -0
  85. package/src/config/builder.ts +220 -0
  86. package/src/config/fromConfigField.test.ts +93 -0
  87. package/src/config/fromConfigField.ts +74 -0
  88. package/src/config/index.ts +22 -0
  89. package/src/config/resolve.test.ts +119 -0
  90. package/src/config/resolve.ts +67 -0
  91. package/src/config/rules.ts +68 -0
  92. package/src/config/types.ts +108 -0
  93. package/src/config/useConfigSchema.ts +30 -0
  94. package/src/config/visible.ts +20 -0
  95. package/src/controls/ControlPanel.less +4 -58
  96. package/src/controls/ControlPanel.stories.tsx +125 -0
  97. package/src/controls/ControlPanel.test.tsx +245 -10
  98. package/src/controls/ControlPanel.tsx +236 -203
  99. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  100. package/src/dragdrop/DragGhost.less +1 -1
  101. package/src/dragdrop/Palette.less +1 -1
  102. package/src/dragdrop/index.ts +11 -0
  103. package/src/index.ts +74 -5
  104. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  105. package/src/instrument/defineInstrument.test.ts +38 -1
  106. package/src/instrument/defineInstrument.ts +31 -4
  107. package/src/instrument/index.ts +1 -0
  108. package/src/instrument/types.ts +27 -4
  109. package/src/instrument/validateConfigSchema.test.ts +13 -3
  110. package/src/instrument/validateConfigSchema.ts +7 -10
  111. package/src/lab/Lab.chrome.test.tsx +65 -0
  112. package/src/lab/Lab.tsx +58 -15
  113. package/src/lab/LabContext.ts +6 -0
  114. package/src/lab/LabFullChrome.stories.tsx +308 -0
  115. package/src/lab/LabHeader.test.tsx +42 -0
  116. package/src/lab/LabHeader.tsx +57 -0
  117. package/src/lab/LabPalette.test.tsx +35 -0
  118. package/src/lab/LabPalette.tsx +34 -0
  119. package/src/lab/LabShell.less +50 -3
  120. package/src/lab/Workspace.less +1 -30
  121. package/src/lab/Workspace.test.tsx +20 -10
  122. package/src/lab/Workspace.tsx +5 -8
  123. package/src/lab/index.ts +3 -1
  124. package/src/layers/AGENTS.md +1 -1
  125. package/src/layers/LayerList.less +31 -7
  126. package/src/layers/LayerList.tsx +17 -4
  127. package/src/passthrough/weasel-ui.ts +28 -0
  128. package/src/primitives/FloatingPanel.less +28 -0
  129. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  130. package/src/primitives/FloatingPanel.test.tsx +240 -0
  131. package/src/primitives/FloatingPanel.tsx +190 -0
  132. package/src/primitives/FpsMeter.test.tsx +44 -0
  133. package/src/primitives/FpsMeter.tsx +20 -9
  134. package/src/primitives/JobProgress.less +69 -0
  135. package/src/primitives/JobProgress.stories.tsx +48 -0
  136. package/src/primitives/JobProgress.tsx +65 -0
  137. package/src/primitives/Legend.less +47 -0
  138. package/src/primitives/Legend.stories.tsx +32 -0
  139. package/src/primitives/Legend.test.tsx +57 -0
  140. package/src/primitives/Legend.tsx +40 -0
  141. package/src/primitives/Sidebar.less +32 -2
  142. package/src/primitives/StatusBar.less +7 -0
  143. package/src/primitives/StatusBar.tsx +10 -3
  144. package/src/primitives/Toolbar.less +50 -3
  145. package/src/primitives/Toolbar.test.tsx +67 -1
  146. package/src/primitives/Toolbar.tsx +72 -6
  147. package/src/primitives/ZoomControl.less +31 -0
  148. package/src/primitives/ZoomControl.tsx +76 -0
  149. package/src/primitives/floating.entry.test.tsx +14 -0
  150. package/src/primitives/index.ts +10 -2
  151. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  152. package/src/primitives/useRovingTabIndex.ts +79 -0
  153. package/src/state/store.ts +11 -3
  154. package/src/state/toolSlot.test.ts +36 -0
  155. package/src/state/types.ts +4 -0
  156. package/src/styles.less +6 -4
  157. package/src/surface/useTiledSurface.ts +14 -10
  158. package/src/test-setup.ts +26 -0
  159. package/src/theme/Interstellar.stories.tsx +1 -1
  160. package/src/theme/base.less +19 -2
  161. package/src/theme/interstellar.tokens.json +6 -2
  162. package/src/tools/types.ts +24 -0
  163. package/src/trial/Trial.config.test.tsx +108 -0
  164. package/src/trial/Trial.job.test.tsx +13 -15
  165. package/src/trial/Trial.less +115 -10
  166. package/src/trial/Trial.stories.tsx +1 -0
  167. package/src/trial/Trial.test.tsx +67 -96
  168. package/src/trial/Trial.tsx +80 -39
  169. package/src/trial/TrialChrome.tsx +99 -80
  170. package/src/trial/TrialDragContext.ts +10 -0
  171. package/src/trial/TrialTitleBar.tsx +41 -0
  172. package/src/trial/index.ts +1 -15
  173. package/src/trial/trialChrome.borders.test.ts +38 -0
  174. package/src/ui/layers/index.ts +5 -1
  175. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  176. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  177. package/dist/chunk-574LJAV4.js.map +0 -1
  178. package/dist/chunk-5R2ATYPJ.js +0 -201
  179. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  180. package/dist/chunk-73KA7WBO.js +0 -5992
  181. package/dist/chunk-73KA7WBO.js.map +0 -1
  182. package/dist/chunk-BOHF3PQO.js.map +0 -1
  183. package/dist/chunk-G5TJVQQT.js +0 -161
  184. package/dist/chunk-G5TJVQQT.js.map +0 -1
  185. package/dist/chunk-PMAU3SEE.js +0 -126
  186. package/dist/chunk-PMAU3SEE.js.map +0 -1
  187. package/dist/chunk-SFL7NFKN.js.map +0 -1
  188. package/dist/chunk-THBG7FQZ.js.map +0 -1
  189. package/dist/chunk-VUU5UXHE.js.map +0 -1
  190. package/src/instrument/capabilityDetector.test.ts +0 -64
  191. package/src/instrument/capabilityDetector.ts +0 -20
  192. package/src/trial/DefaultSidebar.tsx +0 -29
  193. package/src/trial/DefaultStatusBar.tsx +0 -19
  194. package/src/trial/DefaultToolbar.tsx +0 -92
  195. package/src/trial/slotTypes.ts +0 -54
  196. package/src/ui/format.test.ts +0 -33
  197. package/src/ui/format.ts +0 -30
  198. package/src/ui/layers/LayerStack.less +0 -143
  199. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  200. package/src/ui/layers/LayerStack.test.tsx +0 -170
  201. package/src/ui/layers/LayerStack.tsx +0 -220
  202. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  203. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  204. package/src/ui/properties/CurveField.less +0 -48
  205. package/src/ui/properties/CurveField.stories.tsx +0 -20
  206. package/src/ui/properties/CurveField.test.tsx +0 -127
  207. package/src/ui/properties/CurveField.tsx +0 -184
  208. package/src/ui/properties/EffectCard.tsx +0 -324
  209. package/src/ui/properties/Gallery.stories.tsx +0 -178
  210. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  211. package/src/ui/properties/PropertyGroup.less +0 -50
  212. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  213. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  214. package/src/ui/properties/PropertyGroup.tsx +0 -45
  215. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  216. package/src/ui/properties/PropertyPanel.less +0 -592
  217. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  218. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  219. package/src/ui/properties/PropertyPanel.tsx +0 -469
  220. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  221. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  222. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  223. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -396
  224. package/src/ui/properties/TextRow.stories.tsx +0 -62
  225. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  226. package/src/ui/properties/index.ts +0 -39
  227. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -1,251 +1,284 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import type {
3
- CheckboxField,
4
- ColorField,
5
- ConfigField,
6
- NumberField,
7
- SelectField,
8
- SliderField,
9
- TextField,
10
- } from './types';
1
+ import { isBuiltinToolPref } from '@weasel-js/core';
2
+ import {
3
+ CheckboxRow,
4
+ ColorRow,
5
+ NumberRow,
6
+ type PrefLeaf,
7
+ PropertyGroup,
8
+ PropertyList,
9
+ PropertyRow,
10
+ SelectRow,
11
+ SliderRow,
12
+ TextRow,
13
+ ToggleRow,
14
+ } from '@weasel-js/ui';
15
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { fromConfigFields } from '../config/fromConfigField';
17
+ import type { ControlRenderer, ResolvedConfig } from '../config/types';
18
+ import { isLeafVisible } from '../config/visible';
19
+ import type { ConfigField } from './types';
11
20
 
12
21
  export interface ControlPanelProps<TC extends Record<string, unknown>> {
13
- fields: ConfigField[];
22
+ /** The instrument's resolved config schema. */
23
+ schema?: ResolvedConfig;
24
+ /** @deprecated Pass `schema`. A field list is adapted into one internally. */
25
+ fields?: ConfigField[];
14
26
  config: TC;
15
27
  setConfig: (key: keyof TC, value: unknown) => void;
28
+ /**
29
+ * Control overrides and app-defined kinds, PrefsForm-style. Keys are config
30
+ * paths (checked first) or leaf kinds. A renderer returning `null` collapses
31
+ * the row; entries override the built-in rows on collision.
32
+ */
33
+ renderers?: Record<string, ControlRenderer>;
34
+ /** Draw leaves marked `hidden`. */
35
+ showHidden?: boolean;
16
36
  className?: string;
17
37
  }
18
38
 
19
39
  /** Render an instrument's config schema as a stack of controls, each writing
20
- * back through `setConfig`. */
40
+ * back through `setConfig`. Built on the property rows, so a lab's controls
41
+ * are the same aligned, themed rows the rest of the kit uses. */
21
42
  export function ControlPanel<TC extends Record<string, unknown>>({
43
+ schema,
22
44
  fields,
23
45
  config,
24
46
  setConfig,
47
+ renderers,
48
+ showHidden = false,
25
49
  className,
26
50
  }: ControlPanelProps<TC>) {
51
+ const resolved = useMemo(() => schema ?? fromConfigFields(fields ?? []), [schema, fields]);
52
+
53
+ const paths = Object.keys(resolved.group.children);
54
+ const sectioned = new Set(resolved.sections.flatMap((s) => s.paths));
55
+ const loose = paths.filter((p) => !sectioned.has(p));
56
+
57
+ const row = (path: string): ReactNode => (
58
+ <ControlRow
59
+ key={path}
60
+ path={path}
61
+ resolved={resolved}
62
+ config={config}
63
+ setConfig={setConfig}
64
+ renderers={renderers}
65
+ showHidden={showHidden}
66
+ />
67
+ );
68
+
27
69
  return (
28
- <div className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}>
29
- {fields.map((field) => (
30
- <ControlRow key={field.key} field={field} config={config} setConfig={setConfig} />
70
+ <PropertyList className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}>
71
+ {loose.map(row)}
72
+ {resolved.sections.map((section) => (
73
+ <PropertyGroup key={section.label} title={section.label}>
74
+ {section.paths.map(row)}
75
+ </PropertyGroup>
31
76
  ))}
32
- </div>
77
+ </PropertyList>
33
78
  );
34
79
  }
35
80
 
36
81
  interface ControlRowProps<TC extends Record<string, unknown>> {
37
- field: ConfigField;
82
+ path: string;
83
+ resolved: ResolvedConfig;
38
84
  config: TC;
39
85
  setConfig: (key: keyof TC, value: unknown) => void;
86
+ renderers?: Record<string, ControlRenderer>;
87
+ showHidden: boolean;
88
+ }
89
+
90
+ /** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
91
+ * and extra keys survive the resolve pass at runtime. */
92
+ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
93
+ return (leaf as unknown as Record<string, T | undefined>)[key];
40
94
  }
41
95
 
42
96
  function ControlRow<TC extends Record<string, unknown>>({
43
- field,
97
+ path,
98
+ resolved,
44
99
  config,
45
100
  setConfig,
101
+ renderers,
102
+ showHidden,
46
103
  }: ControlRowProps<TC>) {
47
- switch (field.type) {
48
- case 'slider':
49
- return <SliderRow field={field} config={config} setConfig={setConfig} />;
50
- case 'checkbox':
51
- return <CheckboxRow field={field} config={config} setConfig={setConfig} />;
52
- case 'select':
53
- return <SelectRow field={field} config={config} setConfig={setConfig} />;
54
- case 'number':
55
- return <NumberRow field={field} config={config} setConfig={setConfig} />;
56
- case 'text':
57
- return <TextRow field={field} config={config} setConfig={setConfig} />;
58
- case 'color':
59
- return <ColorRow field={field} config={config} setConfig={setConfig} />;
60
- default:
61
- return null;
62
- }
63
- }
104
+ const leaf = resolved.group.children[path] as PrefLeaf | undefined;
105
+ if (!leaf || !('kind' in leaf)) return null;
106
+ if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
64
107
 
65
- type RowProps<F extends ConfigField, TC extends Record<string, unknown>> = {
66
- field: F;
67
- config: TC;
68
- setConfig: (key: keyof TC, value: unknown) => void;
69
- };
108
+ const write = (value: unknown): void => setConfig(path as keyof TC, value);
109
+ const fallback = extra<unknown>(leaf, 'default');
110
+ const value = config[path] ?? fallback;
70
111
 
71
- function SliderRow<TC extends Record<string, unknown>>({
72
- field,
73
- config,
74
- setConfig,
75
- }: RowProps<SliderField, TC>) {
76
- const value = (config[field.key] as number | undefined) ?? field.default;
77
- return (
78
- <div className="lk-control-row">
79
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
80
- {field.label}
81
- </label>
82
- <div className="lk-control-slider-row">
83
- <input
84
- id={`lk-ctrl-${field.key}`}
85
- className="lk-control-input lk-control-input--range"
86
- type="range"
87
- min={field.min}
88
- max={field.max}
89
- step={field.step ?? 1}
90
- value={value}
91
- onChange={(e) => setConfig(field.key as keyof TC, Number(e.target.value))}
92
- />
93
- <span className="lk-control-slider-value">{value}</span>
94
- </div>
95
- </div>
96
- );
97
- }
112
+ // Most specific wins, and within a tier the lab's entry beats the
113
+ // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
114
+ const custom = renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
115
+ if (custom) return custom({ path, pref: leaf, value, setValue: write });
98
116
 
99
- function CheckboxRow<TC extends Record<string, unknown>>({
100
- field,
101
- config,
102
- setConfig,
103
- }: RowProps<CheckboxField, TC>) {
104
- const value = (config[field.key] as boolean | undefined) ?? field.default;
105
- return (
106
- <div className="lk-control-row lk-control-row--inline">
107
- <input
108
- id={`lk-ctrl-${field.key}`}
109
- className="lk-control-input lk-control-input--checkbox"
110
- type="checkbox"
111
- checked={value}
112
- onChange={(e) => setConfig(field.key as keyof TC, e.target.checked)}
113
- />
114
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
115
- {field.label}
116
- </label>
117
- </div>
118
- );
119
- }
117
+ const label = leaf.name;
118
+ const description = leaf.description;
119
+ const read = <T,>(): T => value as T;
120
120
 
121
- function SelectRow<TC extends Record<string, unknown>>({
122
- field,
123
- config,
124
- setConfig,
125
- }: RowProps<SelectField, TC>) {
126
- const value = (config[field.key] as string | undefined) ?? field.default;
127
- return (
128
- <div className="lk-control-row">
129
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
130
- {field.label}
131
- </label>
132
- <select
133
- id={`lk-ctrl-${field.key}`}
134
- className="lk-control-input"
135
- value={value}
136
- onChange={(e) => setConfig(field.key as keyof TC, e.target.value)}
137
- >
138
- {field.options.map((opt) => (
139
- <option key={opt.value} value={opt.value}>
140
- {opt.label}
141
- </option>
142
- ))}
143
- </select>
144
- </div>
145
- );
121
+ if (!isBuiltinToolPref(leaf))
122
+ return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
123
+
124
+ switch (leaf.kind) {
125
+ case 'number': {
126
+ const min = extra<number>(leaf, 'min');
127
+ const max = extra<number>(leaf, 'max');
128
+ const step = extra<number>(leaf, 'step');
129
+ if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
130
+ return (
131
+ <SliderRow
132
+ label={label}
133
+ value={read<number>()}
134
+ min={min}
135
+ max={max}
136
+ step={step}
137
+ onChange={write}
138
+ description={description}
139
+ />
140
+ );
141
+ }
142
+ // NumberRow commits every keystroke and does not clamp, so the bounds a
143
+ // schema declares are enforced here — an instrument should never be
144
+ // handed a config value outside the range it asked for.
145
+ const lo = min ?? Number.NEGATIVE_INFINITY;
146
+ const hi = max ?? Number.POSITIVE_INFINITY;
147
+ return (
148
+ <NumberRow
149
+ label={label}
150
+ value={read<number>()}
151
+ min={min}
152
+ max={max}
153
+ step={step}
154
+ onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
155
+ description={description}
156
+ />
157
+ );
158
+ }
159
+ case 'boolean':
160
+ return (
161
+ <CheckboxRow
162
+ label={label}
163
+ value={read<boolean>()}
164
+ onChange={write}
165
+ description={description}
166
+ />
167
+ );
168
+ case 'enum': {
169
+ const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
170
+ const Row = extra<string>(leaf, 'control') === 'radio' ? ToggleRow : SelectRow;
171
+ return (
172
+ <Row
173
+ label={label}
174
+ value={read<string>()}
175
+ options={options}
176
+ onChange={write}
177
+ description={description}
178
+ />
179
+ );
180
+ }
181
+ case 'string':
182
+ return (
183
+ <DebouncedTextRow
184
+ leaf={leaf}
185
+ label={label}
186
+ value={read<string>()}
187
+ write={write}
188
+ description={description}
189
+ />
190
+ );
191
+ case 'color':
192
+ return (
193
+ <ColorRow label={label} value={read<string>()} onChange={write} description={description} />
194
+ );
195
+ case 'paint':
196
+ case 'object':
197
+ // Declined: a hex swatch would write a solid over a gradient, and a flat
198
+ // row would write one field into a half-built object. Override with
199
+ // `render` to edit either.
200
+ return <UnwiredRow label={label} kind={leaf.kind} description={description} />;
201
+ default: {
202
+ // Not reachable while every built-in kind has an arm — and a new kind
203
+ // that lacks one is a compile error here, never a blank row.
204
+ const _exhaustive: never = leaf;
205
+ throw new Error(
206
+ `ControlPanel: no control for built-in pref kind "${(_exhaustive as { kind: string }).kind}"`,
207
+ );
208
+ }
209
+ }
146
210
  }
147
211
 
148
- function NumberRow<TC extends Record<string, unknown>>({
149
- field,
150
- config,
151
- setConfig,
152
- }: RowProps<NumberField, TC>) {
153
- const value = (config[field.key] as number | undefined) ?? field.default;
212
+ /** A leaf this panel has no control for is named rather than dropped: a silent
213
+ * gap reads as "this control does not exist". */
214
+ function UnwiredRow({
215
+ label,
216
+ kind,
217
+ description,
218
+ }: {
219
+ label: string;
220
+ kind: string;
221
+ description?: string;
222
+ }) {
154
223
  return (
155
- <div className="lk-control-row">
156
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
157
- {field.label}
158
- </label>
159
- <input
160
- id={`lk-ctrl-${field.key}`}
161
- className="lk-control-input"
162
- type="number"
163
- value={value}
164
- min={field.min}
165
- max={field.max}
166
- step={field.step}
167
- onChange={(e) => setConfig(field.key as keyof TC, Number(e.target.value))}
168
- onBlur={(e) => {
169
- const n = Number(e.target.value);
170
- if (!Number.isFinite(n)) return;
171
- let clamped = n;
172
- if (field.min !== undefined && clamped < field.min) clamped = field.min;
173
- if (field.max !== undefined && clamped > field.max) clamped = field.max;
174
- if (clamped !== n) setConfig(field.key as keyof TC, clamped);
175
- }}
176
- />
177
- </div>
224
+ <PropertyRow label={label} description={description}>
225
+ <span className="lk-control-panel__unknown">no control for “{kind}”</span>
226
+ </PropertyRow>
178
227
  );
179
228
  }
180
229
 
181
- function TextRow<TC extends Record<string, unknown>>({
182
- field,
183
- config,
184
- setConfig,
185
- }: RowProps<TextField, TC>) {
186
- const debounceMs = field.debounceMs ?? 150;
187
- const initial = (config[field.key] as string | undefined) ?? field.default;
188
- const [local, setLocal] = useState(initial);
230
+ /** Text writes are debounced so typing does not re-run the instrument on every
231
+ * keystroke, which means the row is locally controlled between commits and has
232
+ * to notice when the config changes underneath it. */
233
+ function DebouncedTextRow({
234
+ leaf,
235
+ label,
236
+ value,
237
+ write,
238
+ description,
239
+ }: {
240
+ leaf: PrefLeaf;
241
+ label: string;
242
+ value: string;
243
+ write: (value: unknown) => void;
244
+ description?: string;
245
+ }) {
246
+ const debounceMs = extra<number>(leaf, 'debounceMs') ?? 150;
247
+ const [local, setLocal] = useState(value);
189
248
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
190
- const lastExternal = useRef(initial);
249
+ const lastExternal = useRef(value);
191
250
 
192
251
  useEffect(() => {
193
- const ext = (config[field.key] as string | undefined) ?? field.default;
194
- if (ext !== lastExternal.current) {
195
- lastExternal.current = ext;
196
- setLocal(ext);
252
+ if (value !== lastExternal.current) {
253
+ lastExternal.current = value;
254
+ setLocal(value);
197
255
  }
198
- }, [config, field.key, field.default]);
256
+ }, [value]);
199
257
 
200
- return (
201
- <div className="lk-control-row">
202
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
203
- {field.label}
204
- </label>
205
- <input
206
- id={`lk-ctrl-${field.key}`}
207
- className="lk-control-input"
208
- type="text"
209
- value={local}
210
- placeholder={field.placeholder}
211
- maxLength={field.maxLength}
212
- onChange={(e) => {
213
- const next = e.target.value;
214
- setLocal(next);
215
- if (timer.current) clearTimeout(timer.current);
216
- if (debounceMs === 0) {
217
- lastExternal.current = next;
218
- setConfig(field.key as keyof TC, next);
219
- } else {
220
- timer.current = setTimeout(() => {
221
- lastExternal.current = next;
222
- setConfig(field.key as keyof TC, next);
223
- }, debounceMs);
224
- }
225
- }}
226
- />
227
- </div>
258
+ useEffect(
259
+ () => () => {
260
+ if (timer.current) clearTimeout(timer.current);
261
+ },
262
+ [],
228
263
  );
229
- }
230
264
 
231
- function ColorRow<TC extends Record<string, unknown>>({
232
- field,
233
- config,
234
- setConfig,
235
- }: RowProps<ColorField, TC>) {
236
- const value = (config[field.key] as string | undefined) ?? field.default;
237
265
  return (
238
- <div className="lk-control-row lk-control-row--inline">
239
- <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
240
- {field.label}
241
- </label>
242
- <input
243
- id={`lk-ctrl-${field.key}`}
244
- className="lk-control-input lk-control-input--color"
245
- type="color"
246
- value={value}
247
- onChange={(e) => setConfig(field.key as keyof TC, e.target.value)}
248
- />
249
- </div>
266
+ <TextRow
267
+ label={label}
268
+ description={description}
269
+ value={local}
270
+ placeholder={extra<string>(leaf, 'placeholder')}
271
+ maxLength={extra<number>(leaf, 'maxLength')}
272
+ onChange={(next) => {
273
+ setLocal(next);
274
+ if (timer.current) clearTimeout(timer.current);
275
+ const commit = () => {
276
+ lastExternal.current = next;
277
+ write(next);
278
+ };
279
+ if (debounceMs === 0) commit();
280
+ else timer.current = setTimeout(commit, debounceMs);
281
+ }}
282
+ />
250
283
  );
251
284
  }
@@ -1,3 +1,4 @@
1
+ import { useVisibleRaf } from '@weasel-js/core';
1
2
  import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
2
3
  import { screenToWorld } from '../canvas/canvasCoords';
3
4
  import type {
@@ -42,7 +43,9 @@ export function useDragDrop<TS, TC>({
42
43
  const [drag, setDrag] = useState<DragState | null>(null);
43
44
  const dragRef = useRef<DragState | null>(null);
44
45
  dragRef.current = drag;
45
- const rafThrottle = useRef<number | null>(null);
46
+ /** The move the next frame will resolve. Doubles as the throttle's flag:
47
+ * non-null means a frame is already queued. */
48
+ const pendingPos = useRef<Point | null>(null);
46
49
 
47
50
  const isOverCanvas = useCallback(
48
51
  (screenPos: Point): boolean => {
@@ -69,37 +72,42 @@ export function useDragDrop<TS, TC>({
69
72
  [canvasContainerRef, view],
70
73
  );
71
74
 
75
+ const frameLoop = useVisibleRaf(() => {
76
+ const screenPos = pendingPos.current;
77
+ pendingPos.current = null;
78
+ const active = dragRef.current;
79
+ if (!active || !screenPos) return;
80
+ let feedback: DragFeedback | null = null;
81
+ if (capability.onDragOver && isOverCanvas(screenPos)) {
82
+ const world = screenToWorldFromContainer(screenPos);
83
+ if (world) feedback = capability.onDragOver(world, active.item, state, config);
84
+ }
85
+ setDrag({ ...active, screenPos, feedback });
86
+ });
87
+
72
88
  useEffect(() => {
73
89
  if (!drag) return;
74
90
  const handleMove = (e: PointerEvent) => {
75
91
  const screenPos = { x: e.clientX, y: e.clientY };
76
92
  const current = dragRef.current;
77
93
  if (!current) return;
78
- if (rafThrottle.current !== null) {
94
+ if (pendingPos.current !== null) {
95
+ // A frame is already queued: keep the ghost under the cursor and let
96
+ // that frame probe the drop target from the newest position.
97
+ pendingPos.current = screenPos;
79
98
  dragRef.current = { ...current, screenPos };
80
99
  setDrag(dragRef.current);
81
100
  return;
82
101
  }
83
- rafThrottle.current = requestAnimationFrame(() => {
84
- rafThrottle.current = null;
85
- const active = dragRef.current;
86
- if (!active) return;
87
- let feedback: DragFeedback | null = null;
88
- if (capability.onDragOver && isOverCanvas(screenPos)) {
89
- const world = screenToWorldFromContainer(screenPos);
90
- if (world) feedback = capability.onDragOver(world, active.item, state, config);
91
- }
92
- setDrag({ ...active, screenPos, feedback });
93
- });
102
+ pendingPos.current = screenPos;
103
+ frameLoop.request();
94
104
  };
95
105
  const handleUp = (e: PointerEvent) => {
96
106
  const screenPos = { x: e.clientX, y: e.clientY };
97
107
  const active = dragRef.current;
98
108
  if (!active) return;
99
- if (rafThrottle.current !== null) {
100
- cancelAnimationFrame(rafThrottle.current);
101
- rafThrottle.current = null;
102
- }
109
+ pendingPos.current = null;
110
+ frameLoop.cancel();
103
111
  if (isOverCanvas(screenPos)) {
104
112
  const world = screenToWorldFromContainer(screenPos);
105
113
  if (world) {
@@ -116,7 +124,17 @@ export function useDragDrop<TS, TC>({
116
124
  window.removeEventListener('pointermove', handleMove);
117
125
  window.removeEventListener('pointerup', handleUp);
118
126
  };
119
- }, [drag, capability, state, config, setState, emit, isOverCanvas, screenToWorldFromContainer]);
127
+ }, [
128
+ drag,
129
+ capability,
130
+ state,
131
+ config,
132
+ setState,
133
+ emit,
134
+ frameLoop,
135
+ isOverCanvas,
136
+ screenToWorldFromContainer,
137
+ ]);
120
138
 
121
139
  const startDrag = useCallback((item: PaletteItem, originScreenPos: Point) => {
122
140
  setDrag({ item, screenPos: originScreenPos, feedback: null });
@@ -5,7 +5,7 @@
5
5
  padding: 4px 8px;
6
6
  background: var(--wzl-surface-raised);
7
7
  border: 1px solid var(--wzl-border);
8
- border-radius: 4px;
8
+ border-radius: var(--wzl-radius-sm);
9
9
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
10
10
  font: inherit;
11
11
  color: inherit;
@@ -13,7 +13,7 @@
13
13
  padding: var(--wzl-space-sm);
14
14
  background: var(--wzl-surface-raised);
15
15
  border: 1px solid var(--wzl-border);
16
- border-radius: 4px;
16
+ border-radius: var(--wzl-radius-sm);
17
17
  cursor: grab;
18
18
  font: inherit;
19
19
  color: inherit;
@@ -1 +1,12 @@
1
1
  export type { DragDropCapability, DragFeedback, PaletteItem } from '../instrument/types';
2
+ export type {
3
+ DragOverlayProps,
4
+ DragState,
5
+ UseDragDropArgs,
6
+ UseDragDropResult,
7
+ } from './DragDropRuntime';
8
+ export { DragOverlay, useDragDrop } from './DragDropRuntime';
9
+ export type { DragGhostProps } from './DragGhost';
10
+ export { DragGhost } from './DragGhost';
11
+ export type { PaletteProps } from './Palette';
12
+ export { Palette } from './Palette';