@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
@@ -0,0 +1,108 @@
1
+ import type { PrefGroup, PrefLeaf, PrefRenderer } from '@weasel-js/ui';
2
+
3
+ /**
4
+ * Renders the control cell for one config leaf. Identical to weasel-ui's
5
+ * `PrefRenderer` on purpose — aliased rather than redeclared so the two
6
+ * cannot drift.
7
+ */
8
+ export type ControlRenderer = PrefRenderer;
9
+
10
+ /** One labeled choice in an `enum` leaf. */
11
+ export interface ConfigOption {
12
+ value: string;
13
+ label: string;
14
+ }
15
+
16
+ /** Everything a leaf can carry beyond its kind and default. A flat union of
17
+ * every `Pref*` leaf's extras, plus labkit's own `debounceMs`. */
18
+ export interface Annotations {
19
+ name?: string;
20
+ description?: string;
21
+ hidden?: boolean;
22
+ block?: boolean;
23
+ pair?: string;
24
+ min?: number;
25
+ max?: number;
26
+ step?: number;
27
+ control?: string;
28
+ options?: readonly ConfigOption[];
29
+ placeholder?: string;
30
+ maxLength?: number;
31
+ /** Milliseconds to debounce a string leaf's live writes. Default 150. */
32
+ debounceMs?: number;
33
+ }
34
+
35
+ /** What a rule contributes. `kind` is honored only while it is still unset,
36
+ * which is what makes `f.value` claimable and a kinded factory final. */
37
+ export type LeafPatch = Annotations & { kind?: string };
38
+
39
+ /** What a rule is given for the leaf it is deciding about. */
40
+ export interface ConfigRuleContext {
41
+ /** The config key this leaf writes. */
42
+ key: string;
43
+ /** Dotted path within the schema. Equal to `key` while schemas are flat. */
44
+ path: string;
45
+ /** The leaf's default value. A rule may read it but never change it. */
46
+ default: unknown;
47
+ /** What earlier rules and the author's own annotations have settled. */
48
+ leaf: Readonly<LeafPatch>;
49
+ }
50
+
51
+ /**
52
+ * Decides part of how a leaf is presented. Returns a patch, or null to
53
+ * abstain. Merging is gap-filling: a property already settled is never
54
+ * overwritten, so the author's annotations beat every rule and a consumer's
55
+ * rules beat labkit's built-ins.
56
+ */
57
+ export type ConfigRule = (ctx: ConfigRuleContext) => LeafPatch | null;
58
+
59
+ /** A presentational bucket of leaves, rendered under one heading. */
60
+ export interface SectionSpec {
61
+ label: string;
62
+ paths: readonly string[];
63
+ }
64
+
65
+ /** A schema resolved against a set of rules: the vocabulary weasel-ui renders,
66
+ * plus the three things labkit keeps on the side because `PrefLeaf` has no
67
+ * field for them. */
68
+ export interface ResolvedConfig {
69
+ /** Flat: every leaf is a direct child, so a leaf's path is its config key. */
70
+ group: PrefGroup;
71
+ sections: readonly SectionSpec[];
72
+ showIf: ReadonlyMap<string, (config: Record<string, unknown>) => boolean>;
73
+ /** Node-level `.render` overrides, keyed by path. */
74
+ renderers: Readonly<Record<string, ControlRenderer>>;
75
+ }
76
+
77
+ /** Per-node extras that do not belong on a `PrefLeaf`. */
78
+ export interface NodeOptions {
79
+ section?: string;
80
+ showIf?: (config: Record<string, unknown>) => boolean;
81
+ render?: ControlRenderer;
82
+ validate?: (leaf: PrefLeaf) => string[];
83
+ }
84
+
85
+ /** The builder's leaf: a kind (or null, to be decided by rules), a default,
86
+ * an annotation bag, and the extras above. */
87
+ export interface ConfigNode<T = unknown> {
88
+ readonly kind: string | null;
89
+ readonly default: T;
90
+ readonly annotations: Readonly<Annotations>;
91
+ readonly options: Readonly<NodeOptions>;
92
+ }
93
+
94
+ /** The value type a node produces. */
95
+ export type NodeValue<N> = N extends ConfigNode<infer T> ? T : never;
96
+
97
+ /** The config type a builder shape produces. */
98
+ export type InferConfig<S> = { [K in keyof S]: NodeValue<S[K]> };
99
+
100
+ /** An instrument's config, declared once. */
101
+ export interface ConfigSchema<TC> {
102
+ readonly nodes: Readonly<Record<string, ConfigNode>>;
103
+ /** The starting config — what `defaultConfig()` would have returned. */
104
+ defaults(): TC;
105
+ }
106
+
107
+ /** The config type behind a schema: `ConfigOf<typeof sceneConfig>`. */
108
+ export type ConfigOf<S> = S extends ConfigSchema<infer TC> ? TC : never;
@@ -0,0 +1,30 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import type { Instrument } from '../instrument/types';
3
+ import { LabContext } from '../lab/LabContext';
4
+ import { fromConfigFields } from './fromConfigField';
5
+ import { resolveConfigSchema } from './resolve';
6
+ import type { ResolvedConfig } from './types';
7
+
8
+ const NO_RULES: readonly [] = [];
9
+
10
+ /**
11
+ * An instrument's controls, resolved against the surrounding lab's rules.
12
+ *
13
+ * Resolution cannot happen in `defineInstrument` — rules are lab-scoped and
14
+ * that runs at module load — so it happens here, at render. Both instrument
15
+ * paths land in the same shape, so callers never branch on which was used.
16
+ */
17
+ export function useConfigSchema(
18
+ // biome-ignore lint/suspicious/noExplicitAny: instruments are stored contravariantly; see InstrumentList
19
+ instrument: Instrument<any, any, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
20
+ ): ResolvedConfig {
21
+ const lab = useContext(LabContext);
22
+ const rules = lab?.configRules ?? NO_RULES;
23
+ return useMemo(
24
+ () =>
25
+ instrument.config
26
+ ? resolveConfigSchema(instrument.config, rules)
27
+ : fromConfigFields(instrument.configSchema?.() ?? []),
28
+ [instrument, rules],
29
+ );
30
+ }
@@ -0,0 +1,20 @@
1
+ import type { ResolvedConfig } from './types';
2
+
3
+ /**
4
+ * Whether a leaf's row should be drawn right now.
5
+ *
6
+ * `hidden` is the leaf's own static flag; `showIf` is the schema's predicate
7
+ * over the live config. Both are presentational — a hidden leaf keeps its
8
+ * value and the instrument still reads it.
9
+ */
10
+ export function isLeafVisible(
11
+ resolved: ResolvedConfig,
12
+ path: string,
13
+ config: Record<string, unknown>,
14
+ showHidden = false,
15
+ ): boolean {
16
+ const leaf = resolved.group.children[path];
17
+ if (leaf && 'hidden' in leaf && leaf.hidden === true && !showHidden) return false;
18
+ const predicate = resolved.showIf.get(path);
19
+ return predicate ? predicate(config) : true;
20
+ }
@@ -1,61 +1,7 @@
1
+ // The panel is a `<PropertyList>` now, so the rows carry their own styling
2
+ // from PropertyPanel.less. This class remains as the consumer-facing hook.
3
+ // It carries no padding: whatever mounts the panel insets it, and a second
4
+ // inset here doubled the gutter inside an already narrow sidebar.
1
5
  .lk-control-panel {
2
- display: flex;
3
- flex-direction: column;
4
- gap: var(--wzl-space-sm);
5
- padding: var(--wzl-space-sm);
6
- }
7
-
8
- .lk-control-row {
9
- display: flex;
10
- flex-direction: column;
11
- gap: var(--wzl-space-xs);
12
- }
13
-
14
- .lk-control-row--inline {
15
- flex-direction: row;
16
- align-items: center;
17
- gap: var(--wzl-space-sm);
18
- }
19
-
20
- .lk-control-label {
21
- font-size: var(--wzl-font-size-sm);
22
- color: var(--wzl-fg);
23
- }
24
-
25
- .lk-control-input {
26
- width: 100%;
27
- background: var(--wzl-surface-raised);
28
- color: var(--wzl-fg);
29
- border: 1px solid var(--wzl-border);
30
- border-radius: 4px;
31
- padding: 2px 6px;
32
- }
33
-
34
- .lk-control-input--range {
35
6
  padding: 0;
36
- border: none;
37
- }
38
-
39
- .lk-control-input--checkbox {
40
- width: auto;
41
- }
42
-
43
- .lk-control-input--color {
44
- width: 40px;
45
- height: 24px;
46
- padding: 0;
47
- }
48
-
49
- .lk-control-slider-value {
50
- font-size: var(--wzl-font-size-sm);
51
- font-variant-numeric: tabular-nums;
52
- color: var(--wzl-fg-muted);
53
- min-width: 3ch;
54
- text-align: right;
55
- }
56
-
57
- .lk-control-slider-row {
58
- display: flex;
59
- align-items: center;
60
- gap: var(--wzl-space-sm);
61
7
  }
@@ -1,5 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
+ import { f } from '../config/builder';
4
+ import { resolveConfigSchema } from '../config/resolve';
5
+ import type { ConfigRule, ControlRenderer } from '../config/types';
6
+ import { PropertyRow } from '../ui/properties/PropertyPanel';
3
7
  import { ControlPanel } from './ControlPanel';
4
8
  import type { ConfigField } from './types';
5
9
 
@@ -75,3 +79,85 @@ export const Minimal: Story = {
75
79
  />
76
80
  ),
77
81
  };
82
+
83
+ const sectioned = f.schema({
84
+ showGrid: f.boolean(true),
85
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
86
+ seed: f.number(1).section('Advanced'),
87
+ jitter: f.number(0).range(0, 1).step(0.05).section('Advanced'),
88
+ label: f.string('untitled').section('Advanced').placeholder('name this run'),
89
+ offset: f.custom('vector2', { x: 0, y: 0 }).section('Advanced').label('Offset'),
90
+ });
91
+
92
+ function SchemaHarness({
93
+ rules = [],
94
+ renderers,
95
+ }: {
96
+ rules?: readonly ConfigRule[];
97
+ renderers?: Record<string, ControlRenderer>;
98
+ }) {
99
+ const schema = resolveConfigSchema(sectioned, rules);
100
+ const [config, setConfig] = useState<Record<string, unknown>>(sectioned.defaults());
101
+ return (
102
+ <ControlPanel
103
+ schema={schema}
104
+ config={config}
105
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
106
+ renderers={renderers}
107
+ />
108
+ );
109
+ }
110
+
111
+ /** Sections, and a kind no control is registered for — the placeholder names
112
+ * the gap rather than dropping the row. */
113
+ export const Sections: Story = {
114
+ render: () => <SchemaHarness />,
115
+ };
116
+
117
+ /** The same schema with a control supplied for `vector2`. */
118
+ export const CustomControl: Story = {
119
+ render: () => (
120
+ <SchemaHarness
121
+ renderers={{
122
+ vector2: ({ pref, value, setValue }) => {
123
+ const v = value as { x: number; y: number };
124
+ return (
125
+ <PropertyRow label={(pref as { name: string }).name}>
126
+ <input
127
+ type="number"
128
+ value={v.x}
129
+ onChange={(e) => setValue({ ...v, x: Number(e.target.value) })}
130
+ />
131
+ <input
132
+ type="number"
133
+ value={v.y}
134
+ onChange={(e) => setValue({ ...v, y: Number(e.target.value) })}
135
+ />
136
+ </PropertyRow>
137
+ );
138
+ },
139
+ }}
140
+ />
141
+ ),
142
+ };
143
+
144
+ const conditional = f.schema({
145
+ showGrid: f.boolean(true),
146
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing').showIf((c) => c.showGrid === true),
147
+ });
148
+
149
+ /** `showIf` hides the row while the value stays in config. Toggle the
150
+ * checkbox to watch the slider come and go. */
151
+ export const Conditional: Story = {
152
+ render: () => {
153
+ const schema = resolveConfigSchema(conditional, []);
154
+ const [config, setConfig] = useState<Record<string, unknown>>(conditional.defaults());
155
+ return (
156
+ <ControlPanel
157
+ schema={schema}
158
+ config={config}
159
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
160
+ />
161
+ );
162
+ },
163
+ };
@@ -1,5 +1,10 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
4
+ import type { PrefLeaf } from '@weasel-js/ui';
5
+ import type { ResolvedConfig } from '../config/types';
6
+ import { f } from '../config/builder';
7
+ import { resolveConfigSchema } from '../config/resolve';
3
8
  import { ControlPanel } from './ControlPanel';
4
9
  import type { ConfigField } from './types';
5
10
 
@@ -9,12 +14,13 @@ describe('<ControlPanel> slider', () => {
9
14
  { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, step: 0.5, default: 2 },
10
15
  ];
11
16
  render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={vi.fn()} />);
12
- const input = screen.getByLabelText('Frequency') as HTMLInputElement;
13
- expect(input.type).toBe('range');
17
+ // The row holds two controls -- the range and an editable readout -- inside
18
+ // one wrapping label, so query the slider by role rather than by label text.
19
+ const input = screen.getByRole('slider') as HTMLInputElement;
14
20
  expect(input.min).toBe('0');
15
21
  expect(input.max).toBe('10');
16
22
  expect(input.step).toBe('0.5');
17
- expect(screen.getByText('2')).toBeInTheDocument();
23
+ expect(screen.getByDisplayValue('2')).toBeInTheDocument();
18
24
  });
19
25
 
20
26
  it('calls setConfig with numeric value on change', () => {
@@ -23,7 +29,7 @@ describe('<ControlPanel> slider', () => {
23
29
  { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, default: 2 },
24
30
  ];
25
31
  render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={setConfig} />);
26
- fireEvent.change(screen.getByLabelText('Frequency'), { target: { value: '5' } });
32
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '5' } });
27
33
  expect(setConfig).toHaveBeenCalledWith('freq', 5);
28
34
  });
29
35
  });
@@ -86,8 +92,9 @@ describe('<ControlPanel> number', () => {
86
92
  { key: 'n', label: 'N', type: 'number', default: 0, min: 0, max: 100 },
87
93
  ];
88
94
  render(<ControlPanel fields={fields} config={{ n: 50 }} setConfig={setConfig} />);
89
- const input = screen.getByLabelText('N');
90
- fireEvent.blur(input, { target: { value: '999' } });
95
+ // The number row commits as you type, so the clamp applies on change
96
+ // rather than waiting for blur.
97
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '999' } });
91
98
  expect(setConfig).toHaveBeenCalledWith('n', 100);
92
99
  });
93
100
  });
@@ -136,14 +143,220 @@ describe('<ControlPanel> color', () => {
136
143
  });
137
144
 
138
145
  describe('<ControlPanel> defensive', () => {
139
- it('renders nothing for an unknown field type without crashing', () => {
146
+ it('names an unknown field type rather than dropping its row', () => {
140
147
  const fields = [
141
148
  { key: 'mystery', label: 'Mystery', type: 'mystery', default: 1 },
149
+ { key: 'ok', label: 'Ok', type: 'checkbox', default: true },
142
150
  ] as unknown as ConfigField[];
143
151
  const { container } = render(
144
- <ControlPanel fields={fields} config={{ mystery: 1 }} setConfig={vi.fn()} />,
152
+ <ControlPanel fields={fields} config={{ mystery: 1, ok: true }} setConfig={vi.fn()} />,
145
153
  );
146
154
  expect(container.querySelector('.lk-control-panel')).not.toBeNull();
147
- expect(container.querySelector('.lk-control-row')).toBeNull();
155
+ // A kind a lab has not wired up must not blank the panel, and a silently
156
+ // dropped row reads as "this control does not exist".
157
+ expect(screen.getByText(/mystery/)).toBeInTheDocument();
158
+ expect(screen.getByLabelText('Ok')).toBeInTheDocument();
159
+ });
160
+ });
161
+
162
+ describe('<ControlPanel> schema', () => {
163
+ it('renders a schema through the built-in rows', () => {
164
+ const schema = resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []);
165
+ render(<ControlPanel schema={schema} config={{ showGrid: true }} setConfig={vi.fn()} />);
166
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
167
+ });
168
+
169
+ it('writes back through setConfig keyed by path', () => {
170
+ const setConfig = vi.fn();
171
+ const schema = resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []);
172
+ render(<ControlPanel schema={schema} config={{ showGrid: true }} setConfig={setConfig} />);
173
+ fireEvent.click(screen.getByLabelText('Show grid'));
174
+ expect(setConfig).toHaveBeenCalledWith('showGrid', false);
175
+ });
176
+
177
+ it('picks a slider for a bounded number and an input for an open one', () => {
178
+ const schema = resolveConfigSchema(
179
+ f.schema({ a: f.number(5).range(0, 10), b: f.number(5) }),
180
+ [],
181
+ );
182
+ render(<ControlPanel schema={schema} config={{ a: 5, b: 5 }} setConfig={vi.fn()} />);
183
+ expect(screen.getByLabelText('B')).toHaveAttribute('type', 'number');
184
+ });
185
+
186
+ it('falls back to the leaf default when config holds no value', () => {
187
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#123456') }), []);
188
+ render(<ControlPanel schema={schema} config={{}} setConfig={vi.fn()} />);
189
+ expect(screen.getByLabelText('Tint')).toHaveValue('#123456');
190
+ });
191
+ });
192
+
193
+ describe('<ControlPanel> renderers', () => {
194
+ it('renderers[path] beats renderers[kind]', () => {
195
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
196
+ render(
197
+ <ControlPanel
198
+ schema={schema}
199
+ config={{ tint: '#ffffff' }}
200
+ setConfig={vi.fn()}
201
+ renderers={{ color: () => <span>by-kind</span>, tint: () => <span>by-path</span> }}
202
+ />,
203
+ );
204
+ expect(screen.getByText('by-path')).toBeInTheDocument();
205
+ expect(screen.queryByText('by-kind')).not.toBeInTheDocument();
206
+ });
207
+
208
+ it('a lab renderer for the path beats the node\'s own .render', () => {
209
+ const schema = resolveConfigSchema(
210
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
211
+ [],
212
+ );
213
+ render(
214
+ <ControlPanel
215
+ schema={schema}
216
+ config={{ tint: '#ffffff' }}
217
+ setConfig={vi.fn()}
218
+ renderers={{ tint: () => <span>by-path</span> }}
219
+ />,
220
+ );
221
+ expect(screen.getByText('by-path')).toBeInTheDocument();
222
+ });
223
+
224
+ it("a node's .render beats a lab renderer for the kind", () => {
225
+ const schema = resolveConfigSchema(
226
+ f.schema({ tint: f.color('#ffffff').render(() => <span>by-node</span>) }),
227
+ [],
228
+ );
229
+ render(
230
+ <ControlPanel
231
+ schema={schema}
232
+ config={{ tint: '#ffffff' }}
233
+ setConfig={vi.fn()}
234
+ renderers={{ color: () => <span>by-kind</span> }}
235
+ />,
236
+ );
237
+ expect(screen.getByText('by-node')).toBeInTheDocument();
238
+ });
239
+
240
+ it('supplies a control for a kind labkit does not ship', () => {
241
+ const schema = resolveConfigSchema(f.schema({ offset: f.custom('vector2', { x: 1 }) }), []);
242
+ render(
243
+ <ControlPanel
244
+ schema={schema}
245
+ config={{ offset: { x: 1 } }}
246
+ setConfig={vi.fn()}
247
+ renderers={{ vector2: (ctx) => <span>vec:{String((ctx.value as { x: number }).x)}</span> }}
248
+ />,
249
+ );
250
+ expect(screen.getByText('vec:1')).toBeInTheDocument();
251
+ });
252
+
253
+ it('a renderer returning null collapses the row', () => {
254
+ const schema = resolveConfigSchema(f.schema({ tint: f.color('#ffffff') }), []);
255
+ const { container } = render(
256
+ <ControlPanel
257
+ schema={schema}
258
+ config={{ tint: '#ffffff' }}
259
+ setConfig={vi.fn()}
260
+ renderers={{ color: () => null }}
261
+ />,
262
+ );
263
+ expect(container.querySelector('.lk-property-row')).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe('<ControlPanel> visibility and sections', () => {
268
+ it('hides a row whose showIf is false', () => {
269
+ const schema = resolveConfigSchema(
270
+ f.schema({
271
+ showGrid: f.boolean(true),
272
+ cellSize: f.number(20).showIf((c) => c.showGrid === true),
273
+ }),
274
+ [],
275
+ );
276
+ const { rerender } = render(
277
+ <ControlPanel schema={schema} config={{ showGrid: true, cellSize: 20 }} setConfig={vi.fn()} />,
278
+ );
279
+ expect(screen.getByLabelText('Cell size')).toBeInTheDocument();
280
+ rerender(
281
+ <ControlPanel
282
+ schema={schema}
283
+ config={{ showGrid: false, cellSize: 20 }}
284
+ setConfig={vi.fn()}
285
+ />,
286
+ );
287
+ expect(screen.queryByLabelText('Cell size')).not.toBeInTheDocument();
288
+ });
289
+
290
+ it('keeps a hidden leaf out unless asked for it', () => {
291
+ const schema = resolveConfigSchema(f.schema({ seed: f.number(0).hidden() }), []);
292
+ const { rerender } = render(
293
+ <ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} />,
294
+ );
295
+ expect(screen.queryByLabelText('Seed')).not.toBeInTheDocument();
296
+ rerender(<ControlPanel schema={schema} config={{ seed: 0 }} setConfig={vi.fn()} showHidden />);
297
+ expect(screen.getByLabelText('Seed')).toBeInTheDocument();
298
+ });
299
+
300
+ it('groups sectioned leaves under a heading, ungrouped ones first', () => {
301
+ const schema = resolveConfigSchema(
302
+ f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
303
+ [],
304
+ );
305
+ const { container } = render(
306
+ <ControlPanel schema={schema} config={{ showGrid: true, seed: 0 }} setConfig={vi.fn()} />,
307
+ );
308
+ expect(screen.getByText('Advanced')).toBeInTheDocument();
309
+ const group = container.querySelector('.lk-property-group');
310
+ expect(group).not.toBeNull();
311
+ expect(group?.textContent).toContain('Seed');
312
+ expect(group?.textContent).not.toContain('Show grid');
313
+ });
314
+ });
315
+
316
+ describe('<ControlPanel> built-in kind coverage', () => {
317
+ const KIND = 'spline' as ToolPrefKind;
318
+ afterEach(() => {
319
+ delete (TOOL_PREF_KINDS as Record<string, true>)[KIND];
320
+ });
321
+
322
+ const schemaWith = (...leaves: PrefLeaf[]): ResolvedConfig => ({
323
+ group: {
324
+ name: 'root',
325
+ children: Object.fromEntries(leaves.map((leaf, i) => [`k${i}`, leaf])),
326
+ },
327
+ sections: [],
328
+ showIf: new Map(),
329
+ renderers: {},
330
+ });
331
+
332
+ it('declines paint and object with a named row rather than a blank one', () => {
333
+ render(
334
+ <ControlPanel
335
+ schema={schemaWith(
336
+ { kind: 'paint', name: 'Fill', description: '', default: null },
337
+ {
338
+ kind: 'object', name: 'Stroke', description: '', default: {},
339
+ children: { width: { kind: 'number', name: 'Width', description: '', default: 1 } },
340
+ },
341
+ )}
342
+ config={{}}
343
+ setConfig={vi.fn()}
344
+ />,
345
+ );
346
+ expect(screen.getByText(/no control for .paint./)).toBeInTheDocument();
347
+ expect(screen.getByText(/no control for .object./)).toBeInTheDocument();
348
+ });
349
+
350
+ it('throws for a built-in kind with no case arm', () => {
351
+ (TOOL_PREF_KINDS as Record<string, true>)[KIND] = true;
352
+ expect(() =>
353
+ render(
354
+ <ControlPanel
355
+ schema={schemaWith({ kind: KIND, name: 'Curve', description: '', default: null })}
356
+ config={{}}
357
+ setConfig={vi.fn()}
358
+ />,
359
+ ),
360
+ ).toThrow(/spline/);
148
361
  });
149
362
  });