@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -1,251 +1,250 @@
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 type { PrefLeaf } from '@weasel-js/ui';
3
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
4
+ import { fromConfigFields } from '../config/fromConfigField';
5
+ import type { ControlRenderer, ResolvedConfig } from '../config/types';
6
+ import { isLeafVisible } from '../config/visible';
7
+ import { PropertyGroup } from '../ui/properties/PropertyGroup';
8
+ import {
9
+ CheckboxRow,
10
+ ColorRow,
11
+ NumberRow,
12
+ PropertyList,
13
+ PropertyRow,
14
+ SelectRow,
15
+ TextRow,
16
+ ToggleRow,
17
+ SliderRow,
18
+ } from '../ui/properties/PropertyPanel';
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(
52
+ () => schema ?? fromConfigFields(fields ?? []),
53
+ [schema, fields],
54
+ );
55
+
56
+ const paths = Object.keys(resolved.group.children);
57
+ const sectioned = new Set(resolved.sections.flatMap((s) => s.paths));
58
+ const loose = paths.filter((p) => !sectioned.has(p));
59
+
60
+ const row = (path: string): ReactNode => (
61
+ <ControlRow
62
+ key={path}
63
+ path={path}
64
+ resolved={resolved}
65
+ config={config}
66
+ setConfig={setConfig}
67
+ renderers={renderers}
68
+ showHidden={showHidden}
69
+ />
70
+ );
71
+
27
72
  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} />
73
+ <PropertyList className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}>
74
+ {loose.map(row)}
75
+ {resolved.sections.map((section) => (
76
+ <PropertyGroup key={section.label} title={section.label}>
77
+ {section.paths.map(row)}
78
+ </PropertyGroup>
31
79
  ))}
32
- </div>
80
+ </PropertyList>
33
81
  );
34
82
  }
35
83
 
36
84
  interface ControlRowProps<TC extends Record<string, unknown>> {
37
- field: ConfigField;
85
+ path: string;
86
+ resolved: ResolvedConfig;
38
87
  config: TC;
39
88
  setConfig: (key: keyof TC, value: unknown) => void;
89
+ renderers?: Record<string, ControlRenderer>;
90
+ showHidden: boolean;
91
+ }
92
+
93
+ /** Reads a labkit-only extra off a leaf. `PrefLeaf` has no field for these,
94
+ * and extra keys survive the resolve pass at runtime. */
95
+ function extra<T>(leaf: PrefLeaf, key: string): T | undefined {
96
+ return (leaf as unknown as Record<string, T | undefined>)[key];
40
97
  }
41
98
 
42
99
  function ControlRow<TC extends Record<string, unknown>>({
43
- field,
100
+ path,
101
+ resolved,
44
102
  config,
45
103
  setConfig,
104
+ renderers,
105
+ showHidden,
46
106
  }: 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
- }
107
+ const leaf = resolved.group.children[path] as PrefLeaf | undefined;
108
+ if (!leaf || !('kind' in leaf)) return null;
109
+ if (!isLeafVisible(resolved, path, config as Record<string, unknown>, showHidden)) return null;
64
110
 
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
- };
111
+ const write = (value: unknown): void => setConfig(path as keyof TC, value);
112
+ const fallback = extra<unknown>(leaf, 'default');
113
+ const value = config[path] ?? fallback;
70
114
 
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
- }
115
+ // Most specific wins, and within a tier the lab's entry beats the
116
+ // instrument's: controls[path] -> node .render -> controls[kind] -> built-in.
117
+ const custom =
118
+ renderers?.[path] ?? resolved.renderers[path] ?? renderers?.[leaf.kind];
119
+ if (custom) return custom({ path, pref: leaf, value, setValue: write });
98
120
 
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
- }
121
+ const label = leaf.name;
122
+ const read = <T,>(): T => value as T;
120
123
 
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
- );
124
+ if (!isBuiltinToolPref(leaf)) return <UnwiredRow label={label} kind={leaf.kind} />;
125
+
126
+ switch (leaf.kind) {
127
+ case 'number': {
128
+ const min = extra<number>(leaf, 'min');
129
+ const max = extra<number>(leaf, 'max');
130
+ const step = extra<number>(leaf, 'step');
131
+ if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
132
+ return (
133
+ <SliderRow
134
+ label={label}
135
+ value={read<number>()}
136
+ min={min}
137
+ max={max}
138
+ step={step}
139
+ onChange={write}
140
+ />
141
+ );
142
+ }
143
+ // NumberRow commits every keystroke and does not clamp, so the bounds a
144
+ // schema declares are enforced here — an instrument should never be
145
+ // handed a config value outside the range it asked for.
146
+ const lo = min ?? Number.NEGATIVE_INFINITY;
147
+ const hi = max ?? Number.POSITIVE_INFINITY;
148
+ return (
149
+ <NumberRow
150
+ label={label}
151
+ value={read<number>()}
152
+ min={min}
153
+ max={max}
154
+ step={step}
155
+ onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
156
+ />
157
+ );
158
+ }
159
+ case 'boolean':
160
+ return <CheckboxRow label={label} value={read<boolean>()} onChange={write} />;
161
+ case 'enum': {
162
+ const options = extra<readonly { value: string; label: string }[]>(leaf, 'options') ?? [];
163
+ const Row = extra<string>(leaf, 'control') === 'radio' ? ToggleRow : SelectRow;
164
+ return (
165
+ <Row label={label} value={read<string>()} options={options} onChange={write} />
166
+ );
167
+ }
168
+ case 'string':
169
+ return <DebouncedTextRow leaf={leaf} label={label} value={read<string>()} write={write} />;
170
+ case 'color':
171
+ return <ColorRow label={label} value={read<string>()} onChange={write} />;
172
+ case 'paint':
173
+ case 'object':
174
+ // Declined: a hex swatch would write a solid over a gradient, and a flat
175
+ // row would write one field into a half-built object. Override with
176
+ // `render` to edit either.
177
+ return <UnwiredRow label={label} kind={leaf.kind} />;
178
+ default: {
179
+ // Not reachable while every built-in kind has an arm — and a new kind
180
+ // that lacks one is a compile error here, never a blank row.
181
+ const _exhaustive: never = leaf;
182
+ throw new Error(
183
+ `ControlPanel: no control for built-in pref kind "${(_exhaustive as { kind: string }).kind}"`,
184
+ );
185
+ }
186
+ }
146
187
  }
147
188
 
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;
189
+ /** A leaf this panel has no control for is named rather than dropped: a silent
190
+ * gap reads as "this control does not exist". */
191
+ function UnwiredRow({ label, kind }: { label: string; kind: string }) {
154
192
  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>
193
+ <PropertyRow label={label}>
194
+ <span className="lk-control-panel__unknown">no control for “{kind}”</span>
195
+ </PropertyRow>
178
196
  );
179
197
  }
180
198
 
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);
199
+ /** Text writes are debounced so typing does not re-run the instrument on every
200
+ * keystroke, which means the row is locally controlled between commits and has
201
+ * to notice when the config changes underneath it. */
202
+ function DebouncedTextRow({
203
+ leaf,
204
+ label,
205
+ value,
206
+ write,
207
+ }: {
208
+ leaf: PrefLeaf;
209
+ label: string;
210
+ value: string;
211
+ write: (value: unknown) => void;
212
+ }) {
213
+ const debounceMs = extra<number>(leaf, 'debounceMs') ?? 150;
214
+ const [local, setLocal] = useState(value);
189
215
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
190
- const lastExternal = useRef(initial);
216
+ const lastExternal = useRef(value);
191
217
 
192
218
  useEffect(() => {
193
- const ext = (config[field.key] as string | undefined) ?? field.default;
194
- if (ext !== lastExternal.current) {
195
- lastExternal.current = ext;
196
- setLocal(ext);
219
+ if (value !== lastExternal.current) {
220
+ lastExternal.current = value;
221
+ setLocal(value);
197
222
  }
198
- }, [config, field.key, field.default]);
223
+ }, [value]);
199
224
 
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>
225
+ useEffect(
226
+ () => () => {
227
+ if (timer.current) clearTimeout(timer.current);
228
+ },
229
+ [],
228
230
  );
229
- }
230
231
 
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
232
  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>
233
+ <TextRow
234
+ label={label}
235
+ value={local}
236
+ placeholder={extra<string>(leaf, 'placeholder')}
237
+ maxLength={extra<number>(leaf, 'maxLength')}
238
+ onChange={(next) => {
239
+ setLocal(next);
240
+ if (timer.current) clearTimeout(timer.current);
241
+ const commit = () => {
242
+ lastExternal.current = next;
243
+ write(next);
244
+ };
245
+ if (debounceMs === 0) commit();
246
+ else timer.current = setTimeout(commit, debounceMs);
247
+ }}
248
+ />
250
249
  );
251
250
  }
@@ -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;
package/src/index.test.ts CHANGED
@@ -50,3 +50,21 @@ describe('public export surface', () => {
50
50
  expect(labkit).toHaveProperty('useTrialId');
51
51
  });
52
52
  });
53
+
54
+ describe('surface, job and orbit entry points', () => {
55
+ it('are reachable from the package root', async () => {
56
+ const kit = await import('./index');
57
+ expect(typeof kit.useTiledSurface).toBe('function');
58
+ expect(typeof kit.useSurfaceTile).toBe('function');
59
+ expect(typeof kit.useSurface).toBe('function');
60
+ expect(typeof kit.useSurfaceOptional).toBe('function');
61
+ expect(typeof kit.toDeviceRect).toBe('function');
62
+ expect(typeof kit.composeRects).toBe('function');
63
+ expect(typeof kit.useOrbit).toBe('function');
64
+ expect(typeof kit.orbitAfterDrag).toBe('function');
65
+ expect(typeof kit.useJob).toBe('function');
66
+ expect(typeof kit.as2DView).toBe('function');
67
+ expect(kit.SurfaceContext).toBeDefined();
68
+ expect(kit.DEFAULT_VIEW).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
69
+ });
70
+ });
package/src/index.ts CHANGED
@@ -1,5 +1,37 @@
1
1
  export type { CanvasLayerDescriptor, CanvasStackContextValue, CanvasStackProps } from './canvas';
2
2
  export { CanvasStack, CanvasStackContext, screenToWorld, worldToScreen } from './canvas';
3
+ export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
4
+ export {
5
+ clampPitch,
6
+ orbitAfterDrag,
7
+ orbitAfterWheel,
8
+ PITCH_LIMIT,
9
+ useOrbit,
10
+ wrapYaw,
11
+ } from './canvas/useOrbit';
12
+ export * from './chrome';
13
+ export { f } from './config/builder';
14
+ export { fromConfigFields } from './config/fromConfigField';
15
+ export { resolveConfigSchema } from './config/resolve';
16
+ export { applyRules, builtinRules, titleCase } from './config/rules';
17
+ export type {
18
+ Annotations,
19
+ ConfigNode,
20
+ ConfigOf,
21
+ ConfigOption,
22
+ ConfigRule,
23
+ ConfigRuleContext,
24
+ ConfigSchema,
25
+ ControlRenderer,
26
+ InferConfig,
27
+ LeafPatch,
28
+ NodeOptions,
29
+ NodeValue,
30
+ ResolvedConfig,
31
+ SectionSpec,
32
+ } from './config/types';
33
+ export { useConfigSchema } from './config/useConfigSchema';
34
+ export { isLeafVisible } from './config/visible';
3
35
  export { ControlPanel } from './controls/ControlPanel';
4
36
  export type {
5
37
  CheckboxField,
@@ -13,9 +45,7 @@ export type {
13
45
  SliderField,
14
46
  TextField,
15
47
  } from './controls/types';
16
- export type { CapabilityFlags } from './instrument/capabilityDetector';
17
- export { detectCapabilities } from './instrument/capabilityDetector';
18
- export { defineInstrument } from './instrument/defineInstrument';
48
+ export { defineInstrument, type InstrumentSpec } from './instrument/defineInstrument';
19
49
  export type {
20
50
  CanvasCapability,
21
51
  CanvasLayer,
@@ -35,6 +65,7 @@ export type {
35
65
  } from './instrument/types';
36
66
  export type { ValidationResult } from './instrument/validateConfigSchema';
37
67
  export { validateConfigSchema } from './instrument/validateConfigSchema';
68
+ export * from './job';
38
69
  export * from './lab';
39
70
  export type { LayerListProps } from './layers';
40
71
  export { LayerList } from './layers';
@@ -68,7 +99,11 @@ export type {
68
99
  UndoStack,
69
100
  } from './state/types';
70
101
  export { useTrialState } from './state/useTrialState';
102
+ export type { ViewTransform2D } from './state/view';
103
+ export { as2DView, DEFAULT_VIEW } from './state/view';
104
+ export * from './surface';
71
105
  export { interstellarTheme } from './theme/interstellar';
106
+ export type { ToolCapability, TrialTool } from './tools/types';
72
107
  export * from './trial';
73
108
  export * from './ui/layers';
74
109
  export * from './ui/properties';