@pixel-point/toolcraft 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,3736 @@
1
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { FileDrop, Panel } from "@repo/ui";
3
+ import {
4
+ DEFAULT_COLOR_FORMAT_MODE,
5
+ getColorSurfaceModel,
6
+ getColorSurfaceSliderConfig,
7
+ getColorSurfaceStyle,
8
+ getColorSurfaceThumbPosition,
9
+ getSurfaceHsvColor,
10
+ } from "@repo/ui/controls";
11
+ import * as React from "react";
12
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
13
+
14
+ import { defineToolcraft } from "../schema/define-toolcraft";
15
+ import type { ResolvedToolcraftAppSchema } from "../schema/types";
16
+ import type { ToolcraftInitialState } from "../state/types";
17
+ import { CanvasShell } from "./canvas-shell";
18
+ import { ControlsPanel } from "./controls-panel";
19
+ import { ToolcraftRoot } from "./toolcraft-root";
20
+ import { useToolcraft } from "./use-toolcraft";
21
+
22
+ beforeAll(() => {
23
+ Object.defineProperty(window, "matchMedia", {
24
+ value: (query: string) => ({
25
+ addEventListener: () => undefined,
26
+ addListener: () => undefined,
27
+ dispatchEvent: () => false,
28
+ matches: query === "(prefers-reduced-motion: reduce)",
29
+ media: query,
30
+ onchange: null,
31
+ removeEventListener: () => undefined,
32
+ removeListener: () => undefined,
33
+ }),
34
+ writable: true,
35
+ });
36
+
37
+ window.requestAnimationFrame ??= ((callback: FrameRequestCallback) =>
38
+ window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
39
+ window.cancelAnimationFrame ??= ((handle: number) =>
40
+ window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
41
+ });
42
+
43
+ afterEach(() => {
44
+ cleanup();
45
+ window.localStorage.clear();
46
+ });
47
+
48
+ function createSchema() {
49
+ return defineToolcraft({
50
+ canvas: { enabled: true, size: { height: 180, unit: "px", width: 320 } },
51
+ panels: {
52
+ controls: {
53
+ sections: [
54
+ {
55
+ controls: {
56
+ canvasHeight: {
57
+ defaultValue: 180,
58
+ label: "Canvas height",
59
+ target: "canvas.size.height",
60
+ type: "text",
61
+ },
62
+ canvasWidth: {
63
+ defaultValue: 320,
64
+ label: "Canvas width",
65
+ target: "canvas.size.width",
66
+ type: "text",
67
+ },
68
+ prompt: {
69
+ defaultValue: "Initial prompt",
70
+ description: "Describe the generated result.",
71
+ label: "Prompt",
72
+ target: "generation.prompt",
73
+ type: "text",
74
+ },
75
+ opacity: {
76
+ defaultValue: 75,
77
+ label: "Opacity",
78
+ max: 100,
79
+ min: 0,
80
+ target: "selectedLayer.opacity",
81
+ type: "slider",
82
+ unit: "%",
83
+ },
84
+ staticOpacity: {
85
+ defaultValue: 40,
86
+ keyframeable: false,
87
+ label: "Static opacity",
88
+ max: 100,
89
+ min: 0,
90
+ target: "style.staticOpacity",
91
+ type: "slider",
92
+ unit: "%",
93
+ },
94
+ blend: {
95
+ defaultValue: "normal",
96
+ label: "Blend",
97
+ options: [
98
+ { label: "Normal", value: "normal" },
99
+ { label: "Screen", value: "screen" },
100
+ ],
101
+ target: "style.blend",
102
+ type: "segmented",
103
+ },
104
+ enabled: {
105
+ defaultValue: true,
106
+ label: "Enabled",
107
+ target: "generation.enabled",
108
+ type: "switch",
109
+ },
110
+ },
111
+ layoutGroups: [
112
+ {
113
+ columns: 2,
114
+ controls: ["canvasWidth", "canvasHeight"],
115
+ layout: "inline",
116
+ },
117
+ ],
118
+ title: "Basic",
119
+ },
120
+ {
121
+ controls: {
122
+ anchor: {
123
+ defaultValue: "center",
124
+ label: "Anchor",
125
+ target: "generation.anchor",
126
+ type: "anchorGrid",
127
+ },
128
+ },
129
+ layout: "standalone",
130
+ },
131
+ {
132
+ controls: {
133
+ image: {
134
+ defaultValue: "image-1",
135
+ items: [
136
+ {
137
+ alt: "Image 1",
138
+ src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E",
139
+ value: "image-1",
140
+ },
141
+ {
142
+ alt: "Image 2",
143
+ src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E",
144
+ value: "image-2",
145
+ },
146
+ ],
147
+ label: "Image",
148
+ target: "input.image",
149
+ type: "imagePicker",
150
+ },
151
+ },
152
+ layout: "standalone",
153
+ },
154
+ {
155
+ controls: {
156
+ outputMix: {
157
+ label: "Output Mix",
158
+ target: "style.outputMix",
159
+ type: "channelMixer",
160
+ },
161
+ },
162
+ layout: "standalone",
163
+ title: "Output Mix",
164
+ },
165
+ {
166
+ controls: {
167
+ curves: {
168
+ label: false,
169
+ target: "style.curves",
170
+ type: "curves",
171
+ },
172
+ },
173
+ layout: "standalone",
174
+ },
175
+ {
176
+ controls: {
177
+ fill: {
178
+ defaultValue: { hex: "#C1FF00" },
179
+ label: "Fill",
180
+ target: "style.fill",
181
+ type: "color",
182
+ },
183
+ stroke: {
184
+ defaultValue: { hex: "#FF6A00" },
185
+ label: "Stroke",
186
+ target: "style.stroke",
187
+ type: "color",
188
+ },
189
+ },
190
+ layout: "standalone",
191
+ },
192
+ {
193
+ actionGroup: "secondary",
194
+ controls: {
195
+ footer: {
196
+ actions: [
197
+ {
198
+ command: "controls.reset",
199
+ label: "Reset",
200
+ value: "reset",
201
+ variant: "outline",
202
+ },
203
+ {
204
+ command: "controls.apply",
205
+ label: "Apply",
206
+ value: "apply",
207
+ variant: "default",
208
+ },
209
+ ],
210
+ target: "panel.actions",
211
+ type: "panelActions",
212
+ },
213
+ },
214
+ layout: "standalone",
215
+ },
216
+ ],
217
+ title: "Generation Controls",
218
+ },
219
+ timeline: true,
220
+ },
221
+ });
222
+ }
223
+
224
+ function StateProbe() {
225
+ const { dispatch, state } = useToolcraft();
226
+
227
+ return (
228
+ <>
229
+ <button
230
+ onClick={() => dispatch({ expanded: true, type: "timeline.setExpanded" })}
231
+ type="button"
232
+ >
233
+ Expand timeline
234
+ </button>
235
+ <span data-testid="prompt-value">{String(state.values["generation.prompt"])}</span>
236
+ <span data-testid="enabled-value">{String(state.values["generation.enabled"])}</span>
237
+ <span data-testid="image-value">{String(state.values["input.image"])}</span>
238
+ <span data-testid="font-value">
239
+ {JSON.stringify(state.values["typography.font"])}
240
+ </span>
241
+ <span data-testid="text-color-value">
242
+ {JSON.stringify(state.values["text.color"])}
243
+ </span>
244
+ <span data-testid="gradient-value">
245
+ {JSON.stringify(state.values["style.gradient"])}
246
+ </span>
247
+ <span data-testid="canvas-size">
248
+ {state.canvas.size.width},{state.canvas.size.height}
249
+ </span>
250
+ <span data-testid="media-count">{state.mediaAssets.length}</span>
251
+ <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
252
+ <span data-testid="timeline-keyframes">
253
+ {JSON.stringify(state.timeline.keyframeGroups)}
254
+ </span>
255
+ <span data-testid="values-json">{JSON.stringify(state.values)}</span>
256
+ </>
257
+ );
258
+ }
259
+
260
+ function renderControlsPanel(
261
+ props: Partial<React.ComponentProps<typeof ControlsPanel>> = {},
262
+ initialState?: ToolcraftInitialState,
263
+ ) {
264
+ return renderControlsPanelWithSchema(createSchema(), props, initialState);
265
+ }
266
+
267
+ function renderControlsPanelWithSchema(
268
+ schema: ResolvedToolcraftAppSchema,
269
+ props: Partial<React.ComponentProps<typeof ControlsPanel>> = {},
270
+ initialState?: ToolcraftInitialState,
271
+ ) {
272
+ return render(
273
+ <ToolcraftRoot initialState={initialState} schema={schema}>
274
+ <ControlsPanel framed={false} {...props} />
275
+ <StateProbe />
276
+ </ToolcraftRoot>,
277
+ );
278
+ }
279
+
280
+ describe("ControlsPanel", () => {
281
+ it("renders schema sections and standalone controls through the panel visual shell", () => {
282
+ const { container } = renderControlsPanel();
283
+
284
+ expect(screen.getByText("Generation Controls")).toBeTruthy();
285
+ expect(screen.getByText("Basic")).toBeTruthy();
286
+ expect(screen.getByText("Prompt")).toBeTruthy();
287
+ expect(screen.getByText("Enabled")).toBeTruthy();
288
+ expect(screen.getByRole("button", { name: "Center" })).toBeTruthy();
289
+ expect(screen.getByRole("button", { name: "Image 1" })).toBeTruthy();
290
+ expect(
291
+ screen
292
+ .getByRole("button", { name: "Prompt help" })
293
+ .closest("[data-control-field-label]"),
294
+ ).toBeTruthy();
295
+ expect(screen.queryByRole("button", { name: "Enabled help" })).toBeNull();
296
+ expect(
297
+ container.querySelector('[data-toolcraft-section-action-group="secondary"]'),
298
+ ).toBeTruthy();
299
+ });
300
+
301
+ it("renders compact switch pairs inline", () => {
302
+ const schema = defineToolcraft({
303
+ canvas: { enabled: false },
304
+ panels: {
305
+ controls: {
306
+ sections: [
307
+ {
308
+ controls: {
309
+ glow: {
310
+ defaultValue: true,
311
+ label: "Glow",
312
+ target: "style.glow",
313
+ type: "switch",
314
+ },
315
+ loop: {
316
+ defaultValue: false,
317
+ label: "Loop",
318
+ target: "animation.loop",
319
+ type: "switch",
320
+ },
321
+ },
322
+ layoutGroups: [
323
+ {
324
+ columns: 2,
325
+ controls: ["glow", "loop"],
326
+ layout: "inline",
327
+ },
328
+ ],
329
+ title: "Style",
330
+ },
331
+ ],
332
+ title: "Controls",
333
+ },
334
+ },
335
+ });
336
+
337
+ const { container } = renderControlsPanelWithSchema(schema);
338
+
339
+ expect(screen.getByText("Glow")).toBeTruthy();
340
+ expect(screen.getByText("Loop")).toBeTruthy();
341
+ expect(
342
+ container.querySelector('[data-control-layout="inline"][data-control-layout-columns="2"]'),
343
+ ).toBeTruthy();
344
+ });
345
+
346
+ it("automatically renders adjacent short switches for one entity inline", () => {
347
+ const schema = defineToolcraft({
348
+ canvas: { enabled: false },
349
+ panels: {
350
+ controls: {
351
+ sections: [
352
+ {
353
+ controls: {
354
+ snapX: {
355
+ defaultValue: true,
356
+ label: "Snap X",
357
+ target: "icon.snapX",
358
+ type: "switch",
359
+ },
360
+ snapY: {
361
+ defaultValue: true,
362
+ label: "Snap Y",
363
+ target: "icon.snapY",
364
+ type: "switch",
365
+ },
366
+ },
367
+ title: "Icon",
368
+ },
369
+ ],
370
+ title: "Controls",
371
+ },
372
+ },
373
+ });
374
+
375
+ const { container } = renderControlsPanelWithSchema(schema);
376
+ const inlineGroup = container.querySelector(
377
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
378
+ );
379
+
380
+ expect(screen.getByText("Snap X")).toBeTruthy();
381
+ expect(screen.getByText("Snap Y")).toBeTruthy();
382
+ expect(inlineGroup).toBeTruthy();
383
+ expect(inlineGroup?.textContent).toContain("Snap X");
384
+ expect(inlineGroup?.textContent).toContain("Snap Y");
385
+ });
386
+
387
+ it("renders hidden-label toggle plus parameter rows inline without duplicating section labels", () => {
388
+ const schema = defineToolcraft({
389
+ canvas: { enabled: false },
390
+ panels: {
391
+ controls: {
392
+ sections: [
393
+ {
394
+ controls: {
395
+ includeBackground: {
396
+ defaultValue: true,
397
+ description:
398
+ "Controls PNG background transparency while preview and video keep the background.",
399
+ label: false,
400
+ target: "export.includeBackground",
401
+ type: "switch",
402
+ },
403
+ background: {
404
+ defaultValue: { hex: "#0F0F0F" },
405
+ label: false,
406
+ target: "appearance.background",
407
+ type: "color",
408
+ },
409
+ },
410
+ layoutGroups: [
411
+ {
412
+ columns: 2,
413
+ controls: ["includeBackground", "background"],
414
+ layout: "inline",
415
+ },
416
+ ],
417
+ title: "Background",
418
+ },
419
+ ],
420
+ title: "Controls",
421
+ },
422
+ },
423
+ });
424
+
425
+ const { container } = renderControlsPanelWithSchema(schema);
426
+ const toggleParameterGroup = container.querySelector(
427
+ '[data-control-layout="inline"][data-control-layout-kind="toggleParameter"]',
428
+ );
429
+
430
+ expect(screen.getAllByText("Background")).toHaveLength(1);
431
+ expect(screen.getByRole("switch", { name: "includeBackground" })).toBeTruthy();
432
+ expect(toggleParameterGroup).toBeTruthy();
433
+ expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
434
+ });
435
+
436
+ it("renders short visible toggle plus parameter rows as compact toggle-parameter rows", () => {
437
+ const schema = defineToolcraft({
438
+ canvas: { enabled: false },
439
+ panels: {
440
+ controls: {
441
+ sections: [
442
+ {
443
+ controls: {
444
+ loop: {
445
+ defaultValue: true,
446
+ label: "Loop",
447
+ target: "animation.loop",
448
+ type: "switch",
449
+ },
450
+ duration: {
451
+ defaultValue: "8",
452
+ label: "Duration",
453
+ target: "animation.duration",
454
+ type: "text",
455
+ },
456
+ },
457
+ layoutGroups: [
458
+ {
459
+ columns: 2,
460
+ controls: ["loop", "duration"],
461
+ layout: "inline",
462
+ },
463
+ ],
464
+ title: "Playback",
465
+ },
466
+ ],
467
+ title: "Controls",
468
+ },
469
+ },
470
+ });
471
+
472
+ const { container } = renderControlsPanelWithSchema(schema);
473
+ const toggleParameterGroup = container.querySelector(
474
+ '[data-control-layout="inline"][data-control-layout-kind="toggleParameter"]',
475
+ );
476
+
477
+ expect(screen.getByText("Loop")).toBeTruthy();
478
+ expect(screen.getByText("Duration")).toBeTruthy();
479
+ expect(toggleParameterGroup).toBeTruthy();
480
+ expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
481
+ });
482
+
483
+ it("renders compact select pairs inline with stacked full-width fields", () => {
484
+ const schema = defineToolcraft({
485
+ canvas: { enabled: false },
486
+ panels: {
487
+ controls: {
488
+ sections: [
489
+ {
490
+ controls: {
491
+ videoFormat: {
492
+ defaultValue: "mp4",
493
+ label: "Format",
494
+ options: [
495
+ { label: "MP4", value: "mp4" },
496
+ { label: "WebM", value: "webm" },
497
+ ],
498
+ target: "export.video.format",
499
+ type: "select",
500
+ },
501
+ videoResolution: {
502
+ defaultValue: "current",
503
+ label: "Resolution",
504
+ options: [
505
+ { label: "Current", value: "current" },
506
+ { label: "4K", value: "4k" },
507
+ ],
508
+ target: "export.video.resolution",
509
+ type: "select",
510
+ },
511
+ },
512
+ layoutGroups: [
513
+ {
514
+ columns: 2,
515
+ controls: ["videoFormat", "videoResolution"],
516
+ layout: "inline",
517
+ },
518
+ ],
519
+ title: "Video Export",
520
+ },
521
+ ],
522
+ title: "Controls",
523
+ },
524
+ },
525
+ });
526
+
527
+ const { container } = renderControlsPanelWithSchema(schema);
528
+ const inlineGroup = container.querySelector(
529
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
530
+ );
531
+ const triggers = screen.getAllByRole("combobox");
532
+ const fields = triggers.map((trigger) =>
533
+ trigger.closest<HTMLElement>('[data-slot="field"]'),
534
+ );
535
+
536
+ expect(inlineGroup).toBeTruthy();
537
+ expect(screen.getByText("Format")).toBeTruthy();
538
+ expect(screen.getByText("Resolution")).toBeTruthy();
539
+ expect(triggers).toHaveLength(2);
540
+ expect(triggers[0]?.textContent).toContain("MP4");
541
+ expect(fields.every((field) => field?.dataset.orientation === "vertical")).toBe(
542
+ true,
543
+ );
544
+ expect(fields.every((field) => (field ? inlineGroup?.contains(field) : false))).toBe(
545
+ true,
546
+ );
547
+ });
548
+
549
+ it("stacks switch pairs when an inline label would truncate", () => {
550
+ const schema = defineToolcraft({
551
+ canvas: { enabled: false },
552
+ panels: {
553
+ controls: {
554
+ sections: [
555
+ {
556
+ controls: {
557
+ background: {
558
+ defaultValue: true,
559
+ label: "Background",
560
+ target: "output.background",
561
+ type: "switch",
562
+ },
563
+ diagnosticOverlay: {
564
+ defaultValue: false,
565
+ label: "Diagnostic overlay",
566
+ target: "debug.diagnosticOverlay",
567
+ type: "switch",
568
+ },
569
+ },
570
+ layoutGroups: [
571
+ {
572
+ columns: 2,
573
+ controls: ["background", "diagnosticOverlay"],
574
+ layout: "inline",
575
+ },
576
+ ],
577
+ title: "Output",
578
+ },
579
+ ],
580
+ title: "Controls",
581
+ },
582
+ },
583
+ });
584
+
585
+ const { container } = renderControlsPanelWithSchema(schema);
586
+
587
+ expect(screen.getByText("Background")).toBeTruthy();
588
+ expect(screen.getByText("Diagnostic overlay")).toBeTruthy();
589
+ expect(
590
+ container.querySelector('[data-control-layout="inline"][data-control-layout-columns="2"]'),
591
+ ).toBeNull();
592
+ });
593
+
594
+ it("renders single curves without RGB channel tabs", () => {
595
+ const schema = defineToolcraft({
596
+ canvas: { enabled: false },
597
+ panels: {
598
+ controls: {
599
+ sections: [
600
+ {
601
+ controls: {
602
+ easing: {
603
+ defaultValue: {
604
+ activeChannel: "RGB",
605
+ points: {
606
+ RGB: [
607
+ { x: 0, y: 0 },
608
+ { x: 1, y: 1 },
609
+ ],
610
+ },
611
+ },
612
+ label: "Easing",
613
+ target: "animation.easing",
614
+ type: "curves",
615
+ variant: "single",
616
+ },
617
+ speed: {
618
+ defaultValue: 1,
619
+ label: "Speed",
620
+ max: 2,
621
+ min: 0,
622
+ target: "animation.speed",
623
+ type: "slider",
624
+ },
625
+ },
626
+ title: "Motion",
627
+ },
628
+ ],
629
+ title: "Controls",
630
+ },
631
+ },
632
+ });
633
+
634
+ const { container } = renderControlsPanelWithSchema(schema);
635
+
636
+ expect(screen.getByRole("img", { name: "Easing curve editor" })).toBeTruthy();
637
+ expect(
638
+ screen
639
+ .getAllByText("Easing")
640
+ .some((element) => element.closest("[data-control-field-label]")),
641
+ ).toBeTruthy();
642
+ expect(
643
+ screen
644
+ .getByRole("img", { name: "Easing curve editor" })
645
+ .closest("[data-control-item-compound-context]"),
646
+ ).toBeNull();
647
+ expect(
648
+ container.querySelector('[data-curve-interpolation="monotone"]'),
649
+ ).toBeTruthy();
650
+ expect(screen.queryByText("RGB")).toBeNull();
651
+ expect(screen.queryByText("R")).toBeNull();
652
+ expect(screen.queryByText("G")).toBeNull();
653
+ expect(screen.queryByText("B")).toBeNull();
654
+ });
655
+
656
+ it("keeps RGB curves as sectioned compound controls with channel tabs", () => {
657
+ const schema = defineToolcraft({
658
+ canvas: { enabled: false },
659
+ panels: {
660
+ controls: {
661
+ sections: [
662
+ {
663
+ controls: {
664
+ curves: {
665
+ defaultValue: {
666
+ activeChannel: "RGB",
667
+ points: {
668
+ B: [
669
+ { x: 0, y: 0 },
670
+ { x: 1, y: 1 },
671
+ ],
672
+ G: [
673
+ { x: 0, y: 0 },
674
+ { x: 1, y: 1 },
675
+ ],
676
+ R: [
677
+ { x: 0, y: 0 },
678
+ { x: 1, y: 1 },
679
+ ],
680
+ RGB: [
681
+ { x: 0, y: 0 },
682
+ { x: 1, y: 1 },
683
+ ],
684
+ },
685
+ },
686
+ label: "Tone curves",
687
+ target: "tone.curves",
688
+ type: "curves",
689
+ },
690
+ intensity: {
691
+ defaultValue: 50,
692
+ label: "Intensity",
693
+ max: 100,
694
+ min: 0,
695
+ target: "tone.intensity",
696
+ type: "slider",
697
+ },
698
+ },
699
+ title: "Tone",
700
+ },
701
+ ],
702
+ title: "Controls",
703
+ },
704
+ },
705
+ });
706
+
707
+ const { container } = renderControlsPanelWithSchema(schema);
708
+
709
+ expect(screen.getByText("RGB")).toBeTruthy();
710
+ expect(screen.getByText("R")).toBeTruthy();
711
+ expect(
712
+ container.querySelector('[data-control-section-divider="compound"]'),
713
+ ).toBeTruthy();
714
+ });
715
+
716
+ it("passes explicit curve interpolation from schema", () => {
717
+ const schema = defineToolcraft({
718
+ canvas: { enabled: false },
719
+ panels: {
720
+ controls: {
721
+ sections: [
722
+ {
723
+ controls: {
724
+ toneCurve: {
725
+ defaultValue: {
726
+ activeChannel: "RGB",
727
+ points: {
728
+ RGB: [
729
+ { x: 0, y: 0 },
730
+ { x: 0.2, y: 0.85 },
731
+ { x: 1, y: 1 },
732
+ ],
733
+ },
734
+ },
735
+ interpolation: "smooth",
736
+ label: "Tone curve",
737
+ target: "tone.curve",
738
+ type: "curves",
739
+ variant: "single",
740
+ },
741
+ },
742
+ title: "Tone",
743
+ },
744
+ ],
745
+ title: "Controls",
746
+ },
747
+ },
748
+ });
749
+
750
+ const { container } = renderControlsPanelWithSchema(schema);
751
+
752
+ expect(
753
+ container.querySelector('[data-curve-interpolation="smooth"]'),
754
+ ).toBeTruthy();
755
+ });
756
+
757
+ it("selects curve points with pointer events and deletes selected interior points", async () => {
758
+ const schema = defineToolcraft({
759
+ canvas: { enabled: false },
760
+ panels: {
761
+ controls: {
762
+ sections: [
763
+ {
764
+ controls: {
765
+ toneCurve: {
766
+ defaultValue: {
767
+ activeChannel: "RGB",
768
+ points: {
769
+ RGB: [
770
+ { x: 0, y: 0 },
771
+ { x: 0.5, y: 0.5 },
772
+ { x: 1, y: 1 },
773
+ ],
774
+ },
775
+ },
776
+ label: "Tone curve",
777
+ target: "tone.curve",
778
+ type: "curves",
779
+ variant: "single",
780
+ },
781
+ },
782
+ title: "Tone",
783
+ },
784
+ ],
785
+ title: "Controls",
786
+ },
787
+ },
788
+ });
789
+
790
+ renderControlsPanelWithSchema(schema);
791
+ const point = screen.getByRole("button", { name: "Curve point 2" });
792
+
793
+ fireEvent.pointerDown(point, { pointerId: 1 });
794
+ fireEvent.pointerUp(point, { pointerId: 1 });
795
+
796
+ await waitFor(() => expect(point.getAttribute("aria-pressed")).toBe("true"));
797
+
798
+ fireEvent.keyDown(point, { key: "Delete" });
799
+
800
+ await waitFor(() => {
801
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
802
+
803
+ expect(values["tone.curve"].points.RGB).toEqual([
804
+ { x: 0, y: 0 },
805
+ { x: 1, y: 1 },
806
+ ]);
807
+ });
808
+ });
809
+
810
+ it("renders editable text inputs and textareas with caret cursor affordance", () => {
811
+ const schema = defineToolcraft({
812
+ canvas: { enabled: true },
813
+ panels: {
814
+ controls: {
815
+ sections: [
816
+ {
817
+ controls: {
818
+ prompt: {
819
+ defaultValue: "Short prompt",
820
+ label: "Prompt",
821
+ target: "generation.prompt",
822
+ type: "text",
823
+ },
824
+ instructions: {
825
+ defaultValue: "Long prompt instructions",
826
+ label: "Instructions",
827
+ target: "generation.instructions",
828
+ type: "code",
829
+ },
830
+ },
831
+ title: "Text",
832
+ },
833
+ ],
834
+ title: "Controls",
835
+ },
836
+ },
837
+ });
838
+
839
+ renderControlsPanelWithSchema(schema);
840
+
841
+ expect(screen.getByDisplayValue("Short prompt").className).toContain("cursor-text");
842
+ expect(screen.getByDisplayValue("Long prompt instructions").className).toContain(
843
+ "cursor-text",
844
+ );
845
+ expect(screen.getByDisplayValue("Long prompt instructions").className).toContain(
846
+ "max-h-[calc(12lh+12px)]",
847
+ );
848
+ expect(screen.getByDisplayValue("Long prompt instructions").className).toContain(
849
+ "overflow-y-auto",
850
+ );
851
+ });
852
+
853
+ it("preserves newline characters in code textarea values by default", () => {
854
+ const initialInstructions = "First line\nSecond line";
855
+ const editedInstructions = "Alpha\nBeta\nGamma";
856
+ const schema = defineToolcraft({
857
+ canvas: { enabled: true },
858
+ panels: {
859
+ controls: {
860
+ sections: [
861
+ {
862
+ controls: {
863
+ instructions: {
864
+ defaultValue: initialInstructions,
865
+ label: "Instructions",
866
+ target: "generation.instructions",
867
+ type: "code",
868
+ },
869
+ },
870
+ title: "Text",
871
+ },
872
+ ],
873
+ title: "Controls",
874
+ },
875
+ },
876
+ });
877
+
878
+ renderControlsPanelWithSchema(schema);
879
+
880
+ const textarea = screen.getByLabelText("Instructions") as HTMLTextAreaElement;
881
+
882
+ expect(textarea.tagName).toBe("TEXTAREA");
883
+ expect(textarea.value).toBe(initialInstructions);
884
+
885
+ fireEvent.change(textarea, { target: { value: editedInstructions } });
886
+
887
+ expect(textarea.value).toBe(editedInstructions);
888
+ expect(screen.getByTestId("values-json").textContent).toContain(
889
+ `"generation.instructions":${JSON.stringify(editedInstructions)}`,
890
+ );
891
+ });
892
+
893
+ it("applies text input values while typing", () => {
894
+ const schema = defineToolcraft({
895
+ canvas: { enabled: true },
896
+ panels: {
897
+ controls: {
898
+ sections: [
899
+ {
900
+ controls: {
901
+ prompt: {
902
+ defaultValue: "Initial prompt",
903
+ label: "Prompt",
904
+ target: "generation.prompt",
905
+ type: "text",
906
+ },
907
+ },
908
+ title: "Text",
909
+ },
910
+ ],
911
+ title: "Controls",
912
+ },
913
+ },
914
+ });
915
+
916
+ renderControlsPanelWithSchema(schema);
917
+
918
+ const input = screen.getByDisplayValue("Initial prompt") as HTMLInputElement;
919
+
920
+ fireEvent.change(input, { target: { value: "Live prompt" } });
921
+
922
+ expect(input.value).toBe("Live prompt");
923
+ expect(screen.getByTestId("values-json").textContent).toContain(
924
+ '"generation.prompt":"Live prompt"',
925
+ );
926
+ });
927
+
928
+ it("commits setting text inputs on blur or Enter", () => {
929
+ const schema = defineToolcraft({
930
+ canvas: { enabled: true },
931
+ panels: {
932
+ controls: {
933
+ sections: [
934
+ {
935
+ controls: {
936
+ fontSize: {
937
+ commitMode: "setting",
938
+ defaultValue: "16",
939
+ label: "Font size",
940
+ target: "typography.fontSize",
941
+ type: "text",
942
+ },
943
+ },
944
+ title: "Typography",
945
+ },
946
+ ],
947
+ title: "Controls",
948
+ },
949
+ },
950
+ });
951
+
952
+ renderControlsPanelWithSchema(schema);
953
+
954
+ const input = screen.getByDisplayValue("16") as HTMLInputElement;
955
+
956
+ fireEvent.change(input, { target: { value: "24" } });
957
+ expect(input.value).toBe("24");
958
+ expect(screen.getByTestId("values-json").textContent).toContain(
959
+ '"typography.fontSize":"16"',
960
+ );
961
+
962
+ fireEvent.blur(input);
963
+ expect(screen.getByTestId("values-json").textContent).toContain(
964
+ '"typography.fontSize":"24"',
965
+ );
966
+
967
+ fireEvent.change(input, { target: { value: "32" } });
968
+ expect(screen.getByTestId("values-json").textContent).toContain(
969
+ '"typography.fontSize":"24"',
970
+ );
971
+
972
+ fireEvent.keyDown(input, { code: "Enter", key: "Enter" });
973
+ expect(screen.getByTestId("values-json").textContent).toContain(
974
+ '"typography.fontSize":"32"',
975
+ );
976
+
977
+ fireEvent.change(input, { target: { value: "" } });
978
+ expect(input.value).toBe("");
979
+
980
+ fireEvent.blur(input);
981
+ expect(screen.getByDisplayValue("16")).toBeTruthy();
982
+ expect(screen.getByTestId("values-json").textContent).toContain(
983
+ '"typography.fontSize":"16"',
984
+ );
985
+ });
986
+
987
+ it("binds image picker controls to runtime state", () => {
988
+ renderControlsPanel();
989
+
990
+ fireEvent.click(screen.getByRole("button", { name: "Image 2" }));
991
+
992
+ expect(screen.getByTestId("image-value").textContent).toBe("image-2");
993
+ });
994
+
995
+ it("renders font picker as one compound popup control with font, spacing, and line-height state", async () => {
996
+ const schema = defineToolcraft({
997
+ canvas: { enabled: true },
998
+ panels: {
999
+ controls: {
1000
+ sections: [
1001
+ {
1002
+ controls: {
1003
+ font: {
1004
+ defaultValue: {
1005
+ color: "#FFFFFF",
1006
+ fontId: "inter",
1007
+ fontSize: 16,
1008
+ fontWeight: "400",
1009
+ letterSpacing: "normal",
1010
+ lineHeight: "normal",
1011
+ opacity: 100,
1012
+ textCase: "uppercase",
1013
+ },
1014
+ label: "Font",
1015
+ target: "typography.font",
1016
+ type: "fontPicker",
1017
+ },
1018
+ },
1019
+ layout: "standalone",
1020
+ title: "Typography",
1021
+ },
1022
+ ],
1023
+ title: "Generation Controls",
1024
+ },
1025
+ },
1026
+ });
1027
+
1028
+ renderControlsPanelWithSchema(schema);
1029
+
1030
+ expect(screen.getByText("Case")).toBeTruthy();
1031
+ expect(screen.getByRole("combobox", { name: "Text case" }).textContent).toContain(
1032
+ "Uppercase",
1033
+ );
1034
+ const fontControlItem = screen
1035
+ .getByText("Weight")
1036
+ .closest("[data-control-item-compound-context]");
1037
+ expect(fontControlItem).toBeNull();
1038
+
1039
+ fireEvent.click(screen.getByRole("button", { name: "Select Font" }));
1040
+
1041
+ const triggerValue = document.querySelector<HTMLElement>(
1042
+ '[data-slot="font-picker-trigger-value"]',
1043
+ );
1044
+ const trigger = triggerValue?.closest<HTMLElement>('[data-slot="select-trigger"]');
1045
+ expect(trigger).toBeTruthy();
1046
+ expect(trigger?.getAttribute("data-variant")).toBe("default");
1047
+ expect(trigger?.getAttribute("data-size")).toBe("default");
1048
+ expect(trigger?.className).toContain("h-7");
1049
+ expect(trigger?.className).not.toContain("h-9");
1050
+ expect(triggerValue?.style.fontFamily).toContain("Inter");
1051
+ expect(triggerValue?.style.fontWeight).toBe("400");
1052
+ expect(screen.getByText("Weight")).toBeTruthy();
1053
+ const familyWeightRow = document.querySelector<HTMLElement>(
1054
+ '[data-slot="font-picker-family-weight-row"]',
1055
+ );
1056
+ expect(familyWeightRow?.className).toContain("grid-cols-2");
1057
+ expect(familyWeightRow?.className).not.toContain("max-content");
1058
+ expect(screen.getByText("Size")).toBeTruthy();
1059
+ expect((screen.getByLabelText("Font size") as HTMLInputElement).value).toBe("16");
1060
+ expect(screen.getByText("Color")).toBeTruthy();
1061
+ expect(screen.getByLabelText("Color opacity")).toBeTruthy();
1062
+ expect(screen.getByPlaceholderText("Find font")).toBeTruthy();
1063
+ expect(screen.getByText("Sans").closest("button")?.getAttribute("data-state")).toBe(
1064
+ "active",
1065
+ );
1066
+ expect(screen.getByLabelText("Letter spacing")).toBeTruthy();
1067
+ expect(screen.getByLabelText("Line height")).toBeTruthy();
1068
+ const footerLabels = document.querySelectorAll<HTMLElement>(
1069
+ '[data-slot="font-picker-footer-label"]',
1070
+ );
1071
+ expect(footerLabels).toHaveLength(0);
1072
+ const footerIcons = document.querySelectorAll<HTMLElement>(
1073
+ '[data-slot="font-picker-footer-icon"]',
1074
+ );
1075
+ expect(footerIcons).toHaveLength(2);
1076
+ expect(footerIcons[0]?.getAttribute("class")).toContain("size-4");
1077
+ expect(footerIcons[1]?.getAttribute("class")).toContain("size-4");
1078
+ const footerSliders = document.querySelectorAll<HTMLElement>(
1079
+ '[data-slot="font-picker-footer-slider"] [data-slot="slider"]',
1080
+ );
1081
+ expect(footerSliders).toHaveLength(2);
1082
+ expect(footerSliders[0]?.getAttribute("data-variant")).toBe("discrete");
1083
+ expect(footerSliders[1]?.getAttribute("data-variant")).toBe("discrete");
1084
+ expect(footerSliders[0]?.className).toContain("[&_[data-slot=slider-range]]:transition-none");
1085
+ expect(footerSliders[0]?.className).toContain("[&_[data-slot=slider-thumb]]:transition-none");
1086
+ expect(footerSliders[1]?.className).toContain("[&_[data-slot=slider-range]]:transition-none");
1087
+ expect(footerSliders[1]?.className).toContain("[&_[data-slot=slider-thumb]]:transition-none");
1088
+ expect(
1089
+ footerSliders[0]?.querySelectorAll('[data-slot="slider-marker"]'),
1090
+ ).toHaveLength(4);
1091
+ expect(
1092
+ footerSliders[1]?.querySelectorAll('[data-slot="slider-marker"]'),
1093
+ ).toHaveLength(4);
1094
+ const lineHeightMarkerOffsets = Array.from(
1095
+ footerSliders[1]?.querySelectorAll<HTMLElement>('[data-slot="slider-marker"]') ??
1096
+ [],
1097
+ ).map((marker) => Number.parseFloat(marker.style.left));
1098
+ expect(lineHeightMarkerOffsets).toHaveLength(4);
1099
+ expect(lineHeightMarkerOffsets[0]).toBeCloseTo(20);
1100
+ expect(lineHeightMarkerOffsets[1]).toBeCloseTo(40);
1101
+ expect(lineHeightMarkerOffsets[2]).toBeCloseTo(60);
1102
+ expect(lineHeightMarkerOffsets[3]).toBeCloseTo(80);
1103
+
1104
+ fireEvent.click(screen.getByText("Roboto").closest("button") as HTMLButtonElement);
1105
+ fireEvent.change(screen.getByLabelText("Letter spacing"), {
1106
+ target: { value: "4" },
1107
+ });
1108
+ fireEvent.change(screen.getByLabelText("Line height"), {
1109
+ target: { value: "5" },
1110
+ });
1111
+
1112
+ await waitFor(() => {
1113
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1114
+ color: "#FFFFFF",
1115
+ fontId: "roboto",
1116
+ fontSize: 16,
1117
+ fontWeight: "400",
1118
+ letterSpacing: "wider",
1119
+ lineHeight: "loose",
1120
+ opacity: 100,
1121
+ textCase: "uppercase",
1122
+ });
1123
+ });
1124
+ expect(triggerValue?.style.fontFamily).toContain("Roboto");
1125
+
1126
+ fireEvent.change(screen.getByLabelText("Line height"), {
1127
+ target: { value: "4.4" },
1128
+ });
1129
+
1130
+ await waitFor(() => {
1131
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1132
+ color: "#FFFFFF",
1133
+ fontId: "roboto",
1134
+ fontSize: 16,
1135
+ fontWeight: "400",
1136
+ letterSpacing: "wider",
1137
+ lineHeight: "relaxed",
1138
+ opacity: 100,
1139
+ textCase: "uppercase",
1140
+ });
1141
+ });
1142
+
1143
+ const fontSizeInput = screen.getByLabelText("Font size") as HTMLInputElement;
1144
+
1145
+ fireEvent.change(fontSizeInput, {
1146
+ target: { value: "24" },
1147
+ });
1148
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1149
+ color: "#FFFFFF",
1150
+ fontId: "roboto",
1151
+ fontSize: 16,
1152
+ fontWeight: "400",
1153
+ letterSpacing: "wider",
1154
+ lineHeight: "relaxed",
1155
+ opacity: 100,
1156
+ textCase: "uppercase",
1157
+ });
1158
+
1159
+ fireEvent.blur(fontSizeInput);
1160
+
1161
+ await waitFor(() => {
1162
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1163
+ color: "#FFFFFF",
1164
+ fontId: "roboto",
1165
+ fontSize: 24,
1166
+ fontWeight: "400",
1167
+ letterSpacing: "wider",
1168
+ lineHeight: "relaxed",
1169
+ opacity: 100,
1170
+ textCase: "uppercase",
1171
+ });
1172
+ });
1173
+
1174
+ const colorHexInput = screen.getByLabelText("Color hex") as HTMLInputElement;
1175
+ fireEvent.change(colorHexInput, {
1176
+ target: { value: "#C1FF00" },
1177
+ });
1178
+ fireEvent.blur(colorHexInput);
1179
+ const colorOpacityInput = screen.getByLabelText("Color opacity") as HTMLInputElement;
1180
+ fireEvent.change(colorOpacityInput, {
1181
+ target: { value: "82" },
1182
+ });
1183
+ fireEvent.blur(colorOpacityInput);
1184
+
1185
+ await waitFor(() => {
1186
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1187
+ color: "#C1FF00",
1188
+ fontId: "roboto",
1189
+ fontSize: 24,
1190
+ fontWeight: "400",
1191
+ letterSpacing: "wider",
1192
+ lineHeight: "relaxed",
1193
+ opacity: 82,
1194
+ textCase: "uppercase",
1195
+ });
1196
+ });
1197
+
1198
+ fireEvent.change(fontSizeInput, {
1199
+ target: { value: "" },
1200
+ });
1201
+ expect(fontSizeInput.value).toBe("");
1202
+
1203
+ fireEvent.blur(fontSizeInput);
1204
+ expect((screen.getByLabelText("Font size") as HTMLInputElement).value).toBe("16");
1205
+
1206
+ const listViewport = document.querySelector<HTMLElement>(
1207
+ '[data-slot="font-picker-list-viewport"]',
1208
+ );
1209
+ expect(listViewport).toBeTruthy();
1210
+ expect(listViewport?.className).toContain("toolcraft-scrollbar");
1211
+ expect(listViewport?.className).not.toContain("no-scrollbar");
1212
+ await new Promise<void>((resolve) => {
1213
+ window.requestAnimationFrame(() => resolve());
1214
+ });
1215
+ await new Promise<void>((resolve) => {
1216
+ window.requestAnimationFrame(() => resolve());
1217
+ });
1218
+
1219
+ Object.defineProperty(listViewport, "clientHeight", {
1220
+ configurable: true,
1221
+ value: 240,
1222
+ });
1223
+ listViewport!.scrollTop = 4800;
1224
+ fireEvent.scroll(listViewport!);
1225
+ await new Promise<void>((resolve) => {
1226
+ window.requestAnimationFrame(() => resolve());
1227
+ });
1228
+
1229
+ await waitFor(() => {
1230
+ const firstVirtualFont = document.querySelector<HTMLElement>(
1231
+ '[data-slot="font-picker-list"] button span',
1232
+ );
1233
+ expect(firstVirtualFont?.textContent).not.toBe("Inter");
1234
+ });
1235
+ });
1236
+
1237
+ it("uses a square vector pad when vector is the only vector control in the panel", () => {
1238
+ const schema = defineToolcraft({
1239
+ canvas: { enabled: true },
1240
+ panels: {
1241
+ controls: {
1242
+ sections: [
1243
+ {
1244
+ controls: {
1245
+ position: {
1246
+ defaultValue: { x: "0.00", y: "0.00" },
1247
+ label: "Position",
1248
+ target: "geometry.position",
1249
+ type: "vector",
1250
+ },
1251
+ },
1252
+ title: "Geometry",
1253
+ },
1254
+ ],
1255
+ title: "Controls",
1256
+ },
1257
+ },
1258
+ });
1259
+
1260
+ const { container } = renderControlsPanelWithSchema(schema);
1261
+
1262
+ expect(
1263
+ container
1264
+ .querySelector('[aria-label="Position X/Y pad"]')
1265
+ ?.getAttribute("data-vector-pad-shape"),
1266
+ ).toBe("square");
1267
+ });
1268
+
1269
+ it("keeps compact vector pads when the panel has multiple vector controls", () => {
1270
+ const schema = defineToolcraft({
1271
+ canvas: { enabled: true },
1272
+ panels: {
1273
+ controls: {
1274
+ sections: [
1275
+ {
1276
+ controls: {
1277
+ origin: {
1278
+ defaultValue: { x: "0.00", y: "0.00" },
1279
+ label: "Origin",
1280
+ target: "geometry.origin",
1281
+ type: "vector",
1282
+ },
1283
+ target: {
1284
+ defaultValue: { x: "0.50", y: "-0.50" },
1285
+ label: "Target",
1286
+ target: "geometry.target",
1287
+ type: "vector",
1288
+ },
1289
+ },
1290
+ title: "Geometry",
1291
+ },
1292
+ ],
1293
+ title: "Controls",
1294
+ },
1295
+ },
1296
+ });
1297
+
1298
+ const { container } = renderControlsPanelWithSchema(schema);
1299
+
1300
+ expect(
1301
+ [...container.querySelectorAll("[data-vector-pad-shape]")].map((pad) =>
1302
+ pad.getAttribute("data-vector-pad-shape"),
1303
+ ),
1304
+ ).toEqual(["compact", "compact"]);
1305
+ });
1306
+
1307
+ it("passes supported vector pad variants from schema to the vector pad", () => {
1308
+ const schema = defineToolcraft({
1309
+ canvas: { enabled: true },
1310
+ panels: {
1311
+ controls: {
1312
+ sections: [
1313
+ {
1314
+ controls: {
1315
+ whiteBalance: {
1316
+ defaultValue: { x: "0.00", y: "0.00" },
1317
+ label: "White Balance",
1318
+ target: "color.whiteBalance",
1319
+ type: "vector",
1320
+ variant: "whiteBalance",
1321
+ },
1322
+ colorBalance: {
1323
+ defaultValue: { x: "0.00", y: "0.00" },
1324
+ label: "Color Balance",
1325
+ target: "color.balance",
1326
+ type: "vector",
1327
+ variant: "colorBalance",
1328
+ },
1329
+ chromaOffset: {
1330
+ defaultValue: { x: "0.00", y: "0.00" },
1331
+ label: "Chroma Offset",
1332
+ target: "effect.chromaOffset",
1333
+ type: "vector",
1334
+ variant: "chromaOffset",
1335
+ },
1336
+ toneBias: {
1337
+ defaultValue: { x: "0.00", y: "0.00" },
1338
+ label: "Tone Bias",
1339
+ target: "tone.bias",
1340
+ type: "vector",
1341
+ variant: "toneBias",
1342
+ },
1343
+ unknown: {
1344
+ defaultValue: { x: "0.00", y: "0.00" },
1345
+ label: "Unknown",
1346
+ target: "effect.unknown",
1347
+ type: "vector",
1348
+ variant: "unknown",
1349
+ },
1350
+ },
1351
+ title: "Color",
1352
+ },
1353
+ ],
1354
+ title: "Controls",
1355
+ },
1356
+ },
1357
+ });
1358
+
1359
+ const { container } = renderControlsPanelWithSchema(schema);
1360
+
1361
+ expect(
1362
+ [...container.querySelectorAll("[data-vector-pad-variant]")].map((pad) =>
1363
+ pad.getAttribute("data-vector-pad-variant"),
1364
+ ),
1365
+ ).toEqual([
1366
+ "whiteBalance",
1367
+ "colorBalance",
1368
+ "chromaOffset",
1369
+ "toneBias",
1370
+ "default",
1371
+ ]);
1372
+ });
1373
+
1374
+ it("keeps explanatory label text in the native title instead of the visible label", () => {
1375
+ const schema = defineToolcraft({
1376
+ canvas: { enabled: true },
1377
+ panels: {
1378
+ controls: {
1379
+ sections: [
1380
+ {
1381
+ controls: {
1382
+ gridDensity: {
1383
+ defaultValue: "6",
1384
+ label: "Grid Density (every Nth)",
1385
+ options: [
1386
+ { label: "Every 4th", value: "4" },
1387
+ { label: "Every 6th", value: "6" },
1388
+ ],
1389
+ target: "grid.density",
1390
+ type: "select",
1391
+ },
1392
+ },
1393
+ title: "Grid",
1394
+ },
1395
+ ],
1396
+ title: "Generation Controls",
1397
+ },
1398
+ },
1399
+ });
1400
+
1401
+ const { container } = renderControlsPanelWithSchema(schema);
1402
+ const label = container.querySelector('[data-slot="template-field-label-text"]');
1403
+
1404
+ expect(label?.textContent).toBe("Grid Density");
1405
+ expect(label?.getAttribute("title")).toBe("Grid Density (every Nth)");
1406
+ expect(screen.queryByText("Grid Density (every Nth)")).toBeNull();
1407
+ });
1408
+
1409
+ it("renders schema discrete sliders with Toolcraft variant and markers", () => {
1410
+ const schema = defineToolcraft({
1411
+ canvas: { enabled: true },
1412
+ panels: {
1413
+ controls: {
1414
+ sections: [
1415
+ {
1416
+ controls: {
1417
+ grain: {
1418
+ defaultValue: 3,
1419
+ label: "Grain",
1420
+ max: 5,
1421
+ min: 0,
1422
+ step: 1,
1423
+ target: "shader.grain",
1424
+ type: "slider",
1425
+ variant: "discrete",
1426
+ },
1427
+ },
1428
+ title: "Texture",
1429
+ },
1430
+ ],
1431
+ title: "Generation Controls",
1432
+ },
1433
+ },
1434
+ });
1435
+
1436
+ const { container } = renderControlsPanelWithSchema(schema);
1437
+ const slider = container.querySelector<HTMLElement>(
1438
+ '[data-slot="slider"][data-variant="discrete"]',
1439
+ );
1440
+ const markers = Array.from(
1441
+ container.querySelectorAll<HTMLElement>('[data-slot="slider-marker"]'),
1442
+ );
1443
+
1444
+ expect(slider).toBeTruthy();
1445
+ expect(markers).toHaveLength(4);
1446
+
1447
+ for (const marker of markers) {
1448
+ expect(marker.className).toContain("group-hover/slider-control:opacity-100");
1449
+ expect(marker.className.split(/\s+/)).not.toContain("opacity-100");
1450
+ }
1451
+
1452
+ fireEvent.pointerDown(slider as HTMLElement, { button: 0 });
1453
+
1454
+ for (const marker of markers) {
1455
+ expect(marker.className.split(/\s+/)).toContain("opacity-100");
1456
+ }
1457
+ });
1458
+
1459
+ it("renders one discrete slider marker for each fractional step", () => {
1460
+ const schema = defineToolcraft({
1461
+ canvas: { enabled: true },
1462
+ panels: {
1463
+ controls: {
1464
+ sections: [
1465
+ {
1466
+ controls: {
1467
+ duration: {
1468
+ defaultValue: 0.3,
1469
+ label: "Duration",
1470
+ markerCount: 3,
1471
+ max: 0.5,
1472
+ min: 0,
1473
+ step: 0.1,
1474
+ target: "animation.duration",
1475
+ type: "slider",
1476
+ variant: "discrete",
1477
+ },
1478
+ },
1479
+ title: "Timing",
1480
+ },
1481
+ ],
1482
+ title: "Generation Controls",
1483
+ },
1484
+ },
1485
+ });
1486
+
1487
+ const { container } = renderControlsPanelWithSchema(schema);
1488
+ const slider = container.querySelector<HTMLElement>(
1489
+ '[data-slot="slider"][data-variant="discrete"]',
1490
+ );
1491
+ const markers = Array.from(
1492
+ container.querySelectorAll<HTMLElement>('[data-slot="slider-marker"]'),
1493
+ );
1494
+
1495
+ expect(slider).toBeTruthy();
1496
+ expect(markers).toHaveLength(4);
1497
+ });
1498
+
1499
+ it("keeps stepped continuous sliders free of discrete markers", () => {
1500
+ const schema = defineToolcraft({
1501
+ canvas: { enabled: true },
1502
+ panels: {
1503
+ controls: {
1504
+ sections: [
1505
+ {
1506
+ controls: {
1507
+ speed: {
1508
+ defaultValue: 118,
1509
+ label: "Reveal speed",
1510
+ max: 150,
1511
+ min: 0,
1512
+ step: 1,
1513
+ target: "animation.speed",
1514
+ type: "slider",
1515
+ unit: " cols/s",
1516
+ },
1517
+ },
1518
+ title: "Reveal",
1519
+ },
1520
+ ],
1521
+ title: "Generation Controls",
1522
+ },
1523
+ },
1524
+ });
1525
+
1526
+ const { container } = renderControlsPanelWithSchema(schema);
1527
+
1528
+ expect(
1529
+ container.querySelector('[data-slot="slider"][data-variant="discrete"]'),
1530
+ ).toBeNull();
1531
+ expect(container.querySelectorAll('[data-slot="slider-marker"]')).toHaveLength(0);
1532
+ });
1533
+
1534
+ it("keeps schema sliders stacked even when a layout group asks for an inline row", () => {
1535
+ const schema = defineToolcraft({
1536
+ canvas: { enabled: true },
1537
+ panels: {
1538
+ controls: {
1539
+ sections: [
1540
+ {
1541
+ controls: {
1542
+ fps: {
1543
+ defaultValue: 17,
1544
+ label: "FPS",
1545
+ max: 30,
1546
+ min: 1,
1547
+ step: 1,
1548
+ target: "animation.fps",
1549
+ type: "slider",
1550
+ unit: "fps",
1551
+ variant: "discrete",
1552
+ },
1553
+ speed: {
1554
+ defaultValue: 3.7,
1555
+ label: "Speed",
1556
+ max: 4,
1557
+ min: 0.1,
1558
+ step: 0.1,
1559
+ target: "animation.speed",
1560
+ type: "slider",
1561
+ unit: "x",
1562
+ },
1563
+ },
1564
+ layoutGroups: [
1565
+ {
1566
+ columns: 2,
1567
+ controls: ["fps", "speed"],
1568
+ layout: "inline",
1569
+ },
1570
+ ],
1571
+ title: "Pattern Animation",
1572
+ },
1573
+ ],
1574
+ title: "Generation Controls",
1575
+ },
1576
+ },
1577
+ });
1578
+
1579
+ const { container } = renderControlsPanelWithSchema(schema);
1580
+ const fpsSlider = container.querySelector<HTMLElement>(
1581
+ '[data-slot="slider"][data-variant="discrete"]',
1582
+ );
1583
+
1584
+ expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
1585
+ expect(fpsSlider).toBeTruthy();
1586
+ expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1587
+ });
1588
+
1589
+ it("passes schema disabled state into slider controls", () => {
1590
+ const schema = defineToolcraft({
1591
+ canvas: { enabled: true },
1592
+ panels: {
1593
+ controls: {
1594
+ sections: [
1595
+ {
1596
+ controls: {
1597
+ opacity: {
1598
+ defaultValue: 70,
1599
+ disabled: true,
1600
+ label: "Opacity",
1601
+ max: 100,
1602
+ min: 0,
1603
+ step: 1,
1604
+ target: "shader.opacity",
1605
+ type: "slider",
1606
+ },
1607
+ },
1608
+ title: "Output",
1609
+ },
1610
+ ],
1611
+ title: "Generation Controls",
1612
+ },
1613
+ },
1614
+ });
1615
+
1616
+ const { container } = renderControlsPanelWithSchema(schema);
1617
+ const field = screen.getByText("Opacity").closest('[data-slot="field"]');
1618
+ const slider = container.querySelector<HTMLElement>('[data-slot="slider"]');
1619
+
1620
+ expect(field?.getAttribute("data-disabled")).toBe("true");
1621
+ expect(slider).toBeTruthy();
1622
+ expect(slider?.hasAttribute("data-disabled")).toBe(true);
1623
+ });
1624
+
1625
+ it("disables sliders from dependent mode values", async () => {
1626
+ const schema = defineToolcraft({
1627
+ canvas: { enabled: true },
1628
+ panels: {
1629
+ controls: {
1630
+ sections: [
1631
+ {
1632
+ controls: {
1633
+ fillMode: {
1634
+ defaultValue: "full",
1635
+ label: "Fill mode",
1636
+ options: [
1637
+ { label: "Full", value: "full" },
1638
+ { label: "Partial", value: "partial" },
1639
+ ],
1640
+ target: "distribution.fillMode",
1641
+ type: "segmented",
1642
+ },
1643
+ fillAmount: {
1644
+ defaultValue: 41,
1645
+ disabledWhen: {
1646
+ equals: "full",
1647
+ target: "distribution.fillMode",
1648
+ },
1649
+ label: "Fill level",
1650
+ max: 100,
1651
+ min: 0,
1652
+ step: 1,
1653
+ target: "distribution.fillAmount",
1654
+ type: "slider",
1655
+ unit: "%",
1656
+ },
1657
+ clusterBias: {
1658
+ defaultValue: 63,
1659
+ disabledWhen: {
1660
+ equals: "full",
1661
+ target: "distribution.fillMode",
1662
+ },
1663
+ label: "Islands",
1664
+ max: 100,
1665
+ min: 0,
1666
+ step: 1,
1667
+ target: "distribution.clusterBias",
1668
+ type: "slider",
1669
+ unit: "%",
1670
+ },
1671
+ },
1672
+ title: "Distribution",
1673
+ },
1674
+ ],
1675
+ title: "Token Grid",
1676
+ },
1677
+ },
1678
+ });
1679
+
1680
+ const { container } = renderControlsPanelWithSchema(schema);
1681
+ const fillLevelField = screen.getByText("Fill level").closest('[data-slot="field"]');
1682
+ const islandsField = screen.getByText("Islands").closest('[data-slot="field"]');
1683
+
1684
+ expect(fillLevelField?.getAttribute("data-disabled")).toBe("true");
1685
+ expect(islandsField?.getAttribute("data-disabled")).toBe("true");
1686
+ expect(
1687
+ container.querySelectorAll<HTMLElement>('[data-slot="slider"][data-disabled]'),
1688
+ ).toHaveLength(2);
1689
+
1690
+ fireEvent.click(screen.getByRole("button", { name: "Partial" }));
1691
+
1692
+ await waitFor(() => {
1693
+ expect(fillLevelField?.getAttribute("data-disabled")).not.toBe("true");
1694
+ expect(islandsField?.getAttribute("data-disabled")).not.toBe("true");
1695
+ });
1696
+ expect(
1697
+ container.querySelectorAll<HTMLElement>('[data-slot="slider"][data-disabled]'),
1698
+ ).toHaveLength(0);
1699
+ });
1700
+
1701
+ it("renders only controls that match the current visibleWhen mode", async () => {
1702
+ const schema = defineToolcraft({
1703
+ canvas: { enabled: true },
1704
+ panels: {
1705
+ controls: {
1706
+ sections: [
1707
+ {
1708
+ controls: {
1709
+ identityMode: {
1710
+ defaultValue: "text",
1711
+ label: "Identity",
1712
+ options: [
1713
+ { label: "Text", value: "text" },
1714
+ { label: "Logo", value: "logo" },
1715
+ ],
1716
+ target: "coBrand.identityMode",
1717
+ type: "segmented",
1718
+ },
1719
+ partnerName: {
1720
+ defaultValue: "tRPC",
1721
+ label: "Partner",
1722
+ target: "coBrand.partnerName",
1723
+ type: "text",
1724
+ visibleWhen: {
1725
+ equals: "text",
1726
+ target: "coBrand.identityMode",
1727
+ },
1728
+ },
1729
+ partnerLogo: {
1730
+ defaultValue: "logo.svg",
1731
+ label: "Partner logo",
1732
+ target: "coBrand.partnerLogo",
1733
+ type: "text",
1734
+ visibleWhen: {
1735
+ equals: "logo",
1736
+ target: "coBrand.identityMode",
1737
+ },
1738
+ },
1739
+ },
1740
+ title: "Partner Lockup",
1741
+ },
1742
+ ],
1743
+ title: "Cover Builder",
1744
+ },
1745
+ },
1746
+ });
1747
+
1748
+ renderControlsPanelWithSchema(schema);
1749
+
1750
+ const partnerInput = screen.getByDisplayValue("tRPC");
1751
+
1752
+ expect(screen.getByText("Partner")).toBeTruthy();
1753
+ expect(screen.queryByText("Partner logo")).toBeNull();
1754
+
1755
+ fireEvent.change(partnerInput, { target: { value: "Databricks" } });
1756
+ fireEvent.blur(partnerInput);
1757
+
1758
+ fireEvent.click(screen.getByRole("button", { name: "Logo" }));
1759
+
1760
+ await waitFor(() => {
1761
+ expect(screen.queryByText("Partner")).toBeNull();
1762
+ expect(screen.getByText("Partner logo")).toBeTruthy();
1763
+ });
1764
+
1765
+ fireEvent.click(screen.getByRole("button", { name: "Text" }));
1766
+
1767
+ await waitFor(() => {
1768
+ expect(screen.getByDisplayValue("Databricks")).toBeTruthy();
1769
+ expect(screen.queryByText("Partner logo")).toBeNull();
1770
+ });
1771
+ });
1772
+
1773
+ it("renders count-dependent controls through numeric visibleWhen conditions", () => {
1774
+ const schema = defineToolcraft({
1775
+ canvas: { enabled: true },
1776
+ panels: {
1777
+ controls: {
1778
+ sections: [
1779
+ {
1780
+ controls: {
1781
+ shadeCount: {
1782
+ defaultValue: 2,
1783
+ label: "Shades",
1784
+ max: 5,
1785
+ min: 1,
1786
+ step: 1,
1787
+ target: "shapes.shadeCount",
1788
+ type: "slider",
1789
+ variant: "discrete",
1790
+ },
1791
+ shade1: {
1792
+ defaultValue: { hex: "#FFFFFF" },
1793
+ label: "Shade 1",
1794
+ target: "shapes.color1",
1795
+ type: "color",
1796
+ },
1797
+ shade2: {
1798
+ defaultValue: { hex: "#DDDDDD" },
1799
+ label: "Shade 2",
1800
+ target: "shapes.color2",
1801
+ type: "color",
1802
+ visibleWhen: {
1803
+ greaterThanOrEqual: 2,
1804
+ target: "shapes.shadeCount",
1805
+ },
1806
+ },
1807
+ shade3: {
1808
+ defaultValue: { hex: "#BBBBBB" },
1809
+ label: "Shade 3",
1810
+ target: "shapes.color3",
1811
+ type: "color",
1812
+ visibleWhen: {
1813
+ greaterThanOrEqual: 3,
1814
+ target: "shapes.shadeCount",
1815
+ },
1816
+ },
1817
+ },
1818
+ title: "Shapes",
1819
+ },
1820
+ ],
1821
+ title: "Pattern Controls",
1822
+ },
1823
+ },
1824
+ });
1825
+
1826
+ const { unmount } = renderControlsPanelWithSchema(schema);
1827
+
1828
+ expect(screen.getByText("Shade 1")).toBeTruthy();
1829
+ expect(screen.getByText("Shade 2")).toBeTruthy();
1830
+ expect(screen.queryByText("Shade 3")).toBeNull();
1831
+
1832
+ unmount();
1833
+
1834
+ renderControlsPanelWithSchema(schema, {}, {
1835
+ values: {
1836
+ "shapes.shadeCount": 3,
1837
+ },
1838
+ });
1839
+
1840
+ expect(screen.getByText("Shade 1")).toBeTruthy();
1841
+ expect(screen.getByText("Shade 2")).toBeTruthy();
1842
+ expect(screen.getByText("Shade 3")).toBeTruthy();
1843
+ });
1844
+
1845
+ it("skips sections that do not match visibleWhen", async () => {
1846
+ const schema = defineToolcraft({
1847
+ canvas: { enabled: true },
1848
+ panels: {
1849
+ controls: {
1850
+ sections: [
1851
+ {
1852
+ controls: {
1853
+ template: {
1854
+ defaultValue: "editorial",
1855
+ label: "Template",
1856
+ options: [
1857
+ { label: "Editorial", value: "editorial" },
1858
+ { label: "Code", value: "code" },
1859
+ ],
1860
+ target: "cover.templateId",
1861
+ type: "segmented",
1862
+ },
1863
+ },
1864
+ title: "Cover Format",
1865
+ },
1866
+ {
1867
+ controls: {
1868
+ codeBody: {
1869
+ defaultValue: "const value = true;",
1870
+ label: "Code",
1871
+ target: "code.body",
1872
+ type: "code",
1873
+ },
1874
+ },
1875
+ title: "Code Sample",
1876
+ visibleWhen: {
1877
+ equals: "code",
1878
+ target: "cover.templateId",
1879
+ },
1880
+ },
1881
+ ],
1882
+ title: "Cover Builder",
1883
+ },
1884
+ },
1885
+ });
1886
+
1887
+ renderControlsPanelWithSchema(schema);
1888
+
1889
+ expect(screen.queryByText("Code Sample")).toBeNull();
1890
+ expect(screen.queryByDisplayValue("const value = true;")).toBeNull();
1891
+
1892
+ fireEvent.click(screen.getByRole("button", { name: "Code" }));
1893
+
1894
+ await waitFor(() => {
1895
+ expect(screen.getByText("Code Sample")).toBeTruthy();
1896
+ expect(screen.getByDisplayValue("const value = true;")).toBeTruthy();
1897
+ });
1898
+ });
1899
+
1900
+ it("skips sections when every control inside is hidden by visibleWhen", async () => {
1901
+ const schema = defineToolcraft({
1902
+ canvas: { enabled: true },
1903
+ panels: {
1904
+ controls: {
1905
+ sections: [
1906
+ {
1907
+ controls: {
1908
+ identityMode: {
1909
+ defaultValue: "text",
1910
+ label: "Identity",
1911
+ options: [
1912
+ { label: "Text", value: "text" },
1913
+ { label: "Logo", value: "logo" },
1914
+ ],
1915
+ target: "coBrand.identityMode",
1916
+ type: "segmented",
1917
+ },
1918
+ },
1919
+ title: "Identity",
1920
+ },
1921
+ {
1922
+ controls: {
1923
+ logoScale: {
1924
+ defaultValue: 80,
1925
+ label: "Scale",
1926
+ target: "coBrand.logoScale",
1927
+ type: "slider",
1928
+ visibleWhen: {
1929
+ equals: "logo",
1930
+ target: "coBrand.identityMode",
1931
+ },
1932
+ },
1933
+ logoRadius: {
1934
+ defaultValue: 12,
1935
+ label: "Radius",
1936
+ target: "coBrand.logoRadius",
1937
+ type: "slider",
1938
+ visibleWhen: {
1939
+ equals: "logo",
1940
+ target: "coBrand.identityMode",
1941
+ },
1942
+ },
1943
+ },
1944
+ title: "Logo Options",
1945
+ },
1946
+ ],
1947
+ title: "Cover Builder",
1948
+ },
1949
+ },
1950
+ });
1951
+
1952
+ renderControlsPanelWithSchema(schema);
1953
+
1954
+ expect(screen.queryByText("Logo Options")).toBeNull();
1955
+ expect(screen.queryByText("Scale")).toBeNull();
1956
+ expect(screen.queryByText("Radius")).toBeNull();
1957
+
1958
+ fireEvent.click(screen.getByRole("button", { name: "Logo" }));
1959
+
1960
+ await waitFor(() => {
1961
+ expect(screen.getByText("Logo Options")).toBeTruthy();
1962
+ expect(screen.getByText("Scale")).toBeTruthy();
1963
+ expect(screen.getByText("Radius")).toBeTruthy();
1964
+ });
1965
+ });
1966
+
1967
+ it("passes schema disabled state into range slider controls", () => {
1968
+ const schema = defineToolcraft({
1969
+ canvas: { enabled: true },
1970
+ panels: {
1971
+ controls: {
1972
+ sections: [
1973
+ {
1974
+ controls: {
1975
+ band: {
1976
+ defaultValue: [20, 80],
1977
+ disabled: true,
1978
+ label: "Band",
1979
+ max: 100,
1980
+ min: 0,
1981
+ step: 1,
1982
+ target: "shader.band",
1983
+ type: "rangeSlider",
1984
+ },
1985
+ },
1986
+ title: "Output",
1987
+ },
1988
+ ],
1989
+ title: "Generation Controls",
1990
+ },
1991
+ },
1992
+ });
1993
+
1994
+ const { container } = renderControlsPanelWithSchema(schema);
1995
+ const field = screen.getByText("Band").closest('[data-slot="field"]');
1996
+ const slider = container.querySelector<HTMLElement>('[data-slot="slider"]');
1997
+
1998
+ expect(field?.getAttribute("data-disabled")).toBe("true");
1999
+ expect(slider).toBeTruthy();
2000
+ expect(slider?.hasAttribute("data-disabled")).toBe(true);
2001
+ });
2002
+
2003
+ it("commits common range slider value label separators", () => {
2004
+ const schema = defineToolcraft({
2005
+ canvas: { enabled: true },
2006
+ panels: {
2007
+ controls: {
2008
+ sections: [
2009
+ {
2010
+ controls: {
2011
+ range: {
2012
+ defaultValue: [20, 80],
2013
+ label: "Range",
2014
+ max: 100,
2015
+ min: 0,
2016
+ step: 1,
2017
+ target: "shape.range",
2018
+ type: "rangeSlider",
2019
+ unit: "%",
2020
+ },
2021
+ },
2022
+ title: "Shape",
2023
+ },
2024
+ ],
2025
+ title: "Controls",
2026
+ },
2027
+ },
2028
+ });
2029
+
2030
+ renderControlsPanelWithSchema(schema);
2031
+
2032
+ for (const [draftValue, expectedValue, expectedLabel] of [
2033
+ ["0/1", '"shape.range":[0,1]', "0% – 1%"],
2034
+ ["2-3", '"shape.range":[2,3]', "2% – 3%"],
2035
+ ["4 - 5", '"shape.range":[4,5]', "4% – 5%"],
2036
+ ["6–7", '"shape.range":[6,7]', "6% – 7%"],
2037
+ ] as const) {
2038
+ fireEvent.click(screen.getByRole("button", { name: "Edit Range value" }));
2039
+ const editor = screen.getByRole("textbox", { name: "Range value" });
2040
+ editor.textContent = draftValue;
2041
+ fireEvent.blur(editor);
2042
+
2043
+ expect(screen.getByTestId("values-json").textContent).toContain(expectedValue);
2044
+ expect(screen.getByRole("button", { name: "Edit Range value" }).textContent).toBe(
2045
+ expectedLabel,
2046
+ );
2047
+ }
2048
+ });
2049
+
2050
+ it("does not place any schema slider in inline layout rows", () => {
2051
+ const schema = defineToolcraft({
2052
+ canvas: { enabled: true },
2053
+ panels: {
2054
+ controls: {
2055
+ sections: [
2056
+ {
2057
+ controls: {
2058
+ range: {
2059
+ defaultValue: [20, 80],
2060
+ label: "Range",
2061
+ max: 100,
2062
+ min: 0,
2063
+ step: 1,
2064
+ target: "shape.range",
2065
+ type: "rangeSlider",
2066
+ unit: "%",
2067
+ },
2068
+ strength: {
2069
+ defaultValue: 50,
2070
+ label: "Strength",
2071
+ max: 100,
2072
+ min: 0,
2073
+ step: 1,
2074
+ target: "shape.strength",
2075
+ type: "slider",
2076
+ unit: "%",
2077
+ },
2078
+ },
2079
+ layoutGroups: [
2080
+ {
2081
+ columns: 2,
2082
+ controls: ["range", "strength"],
2083
+ layout: "inline",
2084
+ },
2085
+ ],
2086
+ title: "Shape",
2087
+ },
2088
+ ],
2089
+ title: "Controls",
2090
+ },
2091
+ },
2092
+ });
2093
+
2094
+ const { container } = renderControlsPanelWithSchema(schema);
2095
+
2096
+ expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
2097
+ expect(screen.getByText("Range")).toBeTruthy();
2098
+ expect(screen.getByText("Strength")).toBeTruthy();
2099
+ });
2100
+
2101
+ it("steps manually edited slider values with arrow keys", () => {
2102
+ const schema = defineToolcraft({
2103
+ canvas: { enabled: true },
2104
+ panels: {
2105
+ controls: {
2106
+ sections: [
2107
+ {
2108
+ controls: {
2109
+ opacity: {
2110
+ defaultValue: 10,
2111
+ label: "Opacity",
2112
+ max: 100,
2113
+ min: 0,
2114
+ step: 5,
2115
+ target: "shape.opacity",
2116
+ type: "slider",
2117
+ unit: "%",
2118
+ },
2119
+ },
2120
+ title: "Shape",
2121
+ },
2122
+ ],
2123
+ title: "Controls",
2124
+ },
2125
+ },
2126
+ });
2127
+
2128
+ renderControlsPanelWithSchema(schema);
2129
+
2130
+ fireEvent.click(screen.getByRole("button", { name: "Edit Opacity value" }));
2131
+ const editor = screen.getByRole("textbox", { name: "Opacity value" });
2132
+
2133
+ fireEvent.keyDown(editor, { key: "ArrowUp" });
2134
+ expect(screen.getByTestId("values-json").textContent).toContain(
2135
+ '"shape.opacity":15',
2136
+ );
2137
+ expect(editor.textContent).toBe("15%");
2138
+
2139
+ fireEvent.keyDown(editor, { key: "ArrowDown" });
2140
+ expect(screen.getByTestId("values-json").textContent).toContain(
2141
+ '"shape.opacity":10',
2142
+ );
2143
+ expect(editor.textContent).toBe("10%");
2144
+
2145
+ fireEvent.blur(editor);
2146
+ expect(screen.getByRole("button", { name: "Edit Opacity value" }).textContent).toBe(
2147
+ "10%",
2148
+ );
2149
+ });
2150
+
2151
+ it("renders single-layer file uploads as a removable preview", () => {
2152
+ const schema = defineToolcraft({
2153
+ canvas: { enabled: true, upload: true },
2154
+ panels: {
2155
+ controls: {
2156
+ sections: [
2157
+ {
2158
+ controls: {
2159
+ source: {
2160
+ accept: "PNG, SVG",
2161
+ label: "Image",
2162
+ target: "input.source",
2163
+ type: "fileDrop",
2164
+ },
2165
+ },
2166
+ layout: "standalone",
2167
+ title: "Input",
2168
+ },
2169
+ ],
2170
+ title: "Generation Controls",
2171
+ },
2172
+ },
2173
+ });
2174
+
2175
+ renderControlsPanelWithSchema(schema, undefined, {
2176
+ layers: [
2177
+ {
2178
+ id: "layer-1",
2179
+ kind: "layer",
2180
+ name: "material",
2181
+ visible: true,
2182
+ },
2183
+ ],
2184
+ mediaAssets: [
2185
+ {
2186
+ dataUrl:
2187
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64' viewBox='0 0 96 64'%3E%3Crect width='96' height='64' fill='%23777'/%3E%3C/svg%3E",
2188
+ fileName: "material.svg",
2189
+ id: "media-1",
2190
+ layerId: "layer-1",
2191
+ mimeType: "image/svg+xml",
2192
+ position: { x: 0, y: 0 },
2193
+ size: { height: 64, unit: "px", width: 96 },
2194
+ },
2195
+ ],
2196
+ selectedLayerId: "layer-1",
2197
+ });
2198
+
2199
+ expect(screen.getByRole("img", { name: "material.svg" })).toBeTruthy();
2200
+ expect(
2201
+ screen.queryByText("Click to upload an image"),
2202
+ ).toBeNull();
2203
+
2204
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
2205
+
2206
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
2207
+ expect(screen.queryByRole("img", { name: "material.svg" })).toBeNull();
2208
+ expect(screen.getByText("Click to upload an image")).toBeTruthy();
2209
+ });
2210
+
2211
+ it("removes single-layer file uploads from the canvas and returns the file control to empty", () => {
2212
+ const schema = defineToolcraft({
2213
+ canvas: { enabled: true, upload: true },
2214
+ panels: {
2215
+ controls: {
2216
+ sections: [
2217
+ {
2218
+ controls: {
2219
+ source: {
2220
+ accept: "PNG, SVG",
2221
+ label: "Image",
2222
+ target: "input.source",
2223
+ type: "fileDrop",
2224
+ },
2225
+ },
2226
+ layout: "standalone",
2227
+ title: "Input",
2228
+ },
2229
+ ],
2230
+ title: "Generation Controls",
2231
+ },
2232
+ },
2233
+ });
2234
+
2235
+ render(
2236
+ <ToolcraftRoot
2237
+ initialState={{
2238
+ layers: [
2239
+ {
2240
+ id: "layer-1",
2241
+ kind: "layer",
2242
+ name: "material",
2243
+ visible: true,
2244
+ },
2245
+ ],
2246
+ mediaAssets: [
2247
+ {
2248
+ dataUrl:
2249
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64' viewBox='0 0 96 64'%3E%3Crect width='96' height='64' fill='%23777'/%3E%3C/svg%3E",
2250
+ fileName: "material.svg",
2251
+ id: "media-1",
2252
+ layerId: "layer-1",
2253
+ mimeType: "image/svg+xml",
2254
+ position: { x: 0, y: 0 },
2255
+ size: { height: 64, unit: "px", width: 96 },
2256
+ },
2257
+ ],
2258
+ selectedLayerId: "layer-1",
2259
+ }}
2260
+ schema={schema}
2261
+ >
2262
+ <div style={{ height: 320, position: "relative", width: 320 }}>
2263
+ <CanvasShell />
2264
+ </div>
2265
+ <ControlsPanel framed={false} />
2266
+ <StateProbe />
2267
+ </ToolcraftRoot>,
2268
+ );
2269
+
2270
+ expect(screen.getByRole("button", { name: "Select material.svg" })).toBeTruthy();
2271
+ expect(screen.getAllByRole("img", { name: "material.svg" })).toHaveLength(2);
2272
+
2273
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
2274
+
2275
+ expect(screen.queryByRole("button", { name: "Select material.svg" })).toBeNull();
2276
+ expect(screen.queryAllByRole("img", { name: "material.svg" })).toHaveLength(0);
2277
+ expect(screen.getByText("Click to upload an image")).toBeTruthy();
2278
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
2279
+ });
2280
+
2281
+ it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2282
+ const schema = defineToolcraft({
2283
+ canvas: { enabled: true, upload: true },
2284
+ panels: {
2285
+ controls: {
2286
+ sections: [
2287
+ {
2288
+ controls: {
2289
+ source: {
2290
+ accept: "PNG, SVG",
2291
+ label: "Image",
2292
+ target: "input.source",
2293
+ type: "fileDrop",
2294
+ },
2295
+ },
2296
+ layout: "standalone",
2297
+ title: "Input",
2298
+ },
2299
+ ],
2300
+ title: "Generation Controls",
2301
+ },
2302
+ layers: true,
2303
+ },
2304
+ });
2305
+
2306
+ renderControlsPanelWithSchema(schema, undefined, {
2307
+ layers: [
2308
+ {
2309
+ id: "layer-1",
2310
+ kind: "layer",
2311
+ name: "material",
2312
+ visible: true,
2313
+ },
2314
+ ],
2315
+ mediaAssets: [
2316
+ {
2317
+ dataUrl:
2318
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64' viewBox='0 0 96 64'%3E%3Crect width='96' height='64' fill='%23777'/%3E%3C/svg%3E",
2319
+ fileName: "material.svg",
2320
+ id: "media-1",
2321
+ layerId: "layer-1",
2322
+ mimeType: "image/svg+xml",
2323
+ position: { x: 0, y: 0 },
2324
+ size: { height: 64, unit: "px", width: 96 },
2325
+ },
2326
+ ],
2327
+ selectedLayerId: "layer-1",
2328
+ });
2329
+
2330
+ expect(screen.queryByRole("img", { name: "material.svg" })).toBeNull();
2331
+ expect(screen.queryByRole("button", { name: "Remove image" })).toBeNull();
2332
+ expect(screen.getByText("Click to upload an image")).toBeTruthy();
2333
+ });
2334
+
2335
+ it("renders paired color controls as one compound color grid item", () => {
2336
+ renderControlsPanel();
2337
+
2338
+ const fillInput = screen.getByLabelText("Fill hex");
2339
+ const colorControlList = fillInput.closest("[data-control-list]");
2340
+ const colorSection = fillInput.closest("section");
2341
+
2342
+ expect(colorSection?.textContent).toContain("Fill & Stroke");
2343
+ expect(screen.getByLabelText("Stroke hex")).toBeTruthy();
2344
+ expect(colorControlList?.children).toHaveLength(1);
2345
+ });
2346
+
2347
+ it("omits visible color field labels only inside color-only sections", () => {
2348
+ const schema = defineToolcraft({
2349
+ canvas: { enabled: true },
2350
+ panels: {
2351
+ controls: {
2352
+ sections: [
2353
+ {
2354
+ controls: {
2355
+ fillColor: {
2356
+ defaultValue: { hex: "#C1FF00" },
2357
+ label: "Fill",
2358
+ target: "shape.fill",
2359
+ type: "color",
2360
+ },
2361
+ strokeColor: {
2362
+ defaultValue: { hex: "#FF6A00" },
2363
+ label: "Stroke",
2364
+ target: "shape.stroke",
2365
+ type: "color",
2366
+ },
2367
+ shadowColor: {
2368
+ defaultValue: { hex: "#000000", opacity: 45 },
2369
+ label: "Shadow",
2370
+ target: "shape.shadow",
2371
+ type: "colorOpacity",
2372
+ },
2373
+ },
2374
+ title: "Appearance",
2375
+ },
2376
+ ],
2377
+ title: "Generation Controls",
2378
+ },
2379
+ },
2380
+ });
2381
+
2382
+ const { container } = renderControlsPanelWithSchema(schema);
2383
+ const labels = [...container.querySelectorAll("[data-control-field-label]")].map(
2384
+ (label) => label.textContent,
2385
+ );
2386
+
2387
+ expect(labels).not.toContain("Fill");
2388
+ expect(labels).not.toContain("Stroke");
2389
+ expect(labels).not.toContain("Shadow");
2390
+ expect(screen.getByLabelText("Fill hex")).toBeTruthy();
2391
+ expect(screen.getByLabelText("Shadow opacity")).toBeTruthy();
2392
+ });
2393
+
2394
+ it("shows color field labels inside mixed semantic sections", () => {
2395
+ const schema = defineToolcraft({
2396
+ canvas: { enabled: true },
2397
+ panels: {
2398
+ controls: {
2399
+ sections: [
2400
+ {
2401
+ controls: {
2402
+ maskSize: {
2403
+ defaultValue: "180",
2404
+ label: "Mask size",
2405
+ target: "mask.size",
2406
+ type: "text",
2407
+ },
2408
+ maskColor: {
2409
+ defaultValue: { hex: "#0EA5E9" },
2410
+ label: "Color",
2411
+ target: "mask.color",
2412
+ type: "color",
2413
+ },
2414
+ glowColor: {
2415
+ defaultValue: { hex: "#C1FF00", opacity: 72 },
2416
+ label: "Glow",
2417
+ target: "mask.glow",
2418
+ type: "colorOpacity",
2419
+ },
2420
+ },
2421
+ title: "Mask",
2422
+ },
2423
+ ],
2424
+ title: "Generation Controls",
2425
+ },
2426
+ },
2427
+ });
2428
+
2429
+ const { container } = renderControlsPanelWithSchema(schema);
2430
+ const labels = [...container.querySelectorAll("[data-control-field-label]")].map(
2431
+ (label) => label.textContent,
2432
+ );
2433
+
2434
+ expect(labels).toContain("Color");
2435
+ expect(labels).toContain("Glow");
2436
+ expect(screen.getByLabelText("Color hex")).toBeTruthy();
2437
+ expect(screen.getByLabelText("Glow opacity")).toBeTruthy();
2438
+ });
2439
+
2440
+ it("renders color opacity as one compound color and opacity control", async () => {
2441
+ const schema = defineToolcraft({
2442
+ canvas: { enabled: true },
2443
+ panels: {
2444
+ controls: {
2445
+ sections: [
2446
+ {
2447
+ controls: {
2448
+ textColor: {
2449
+ defaultValue: { hex: "#C1FF00", opacity: 72 },
2450
+ label: "Text color",
2451
+ target: "text.color",
2452
+ type: "colorOpacity",
2453
+ },
2454
+ },
2455
+ title: "Typography",
2456
+ },
2457
+ ],
2458
+ title: "Generation Controls",
2459
+ },
2460
+ },
2461
+ });
2462
+
2463
+ renderControlsPanelWithSchema(schema);
2464
+
2465
+ expect(screen.getByLabelText("Text color hex")).toBeTruthy();
2466
+ expect(screen.getByLabelText("Text color opacity")).toBeTruthy();
2467
+ expect((screen.getByLabelText("Text color opacity") as HTMLInputElement).value).toBe(
2468
+ "72",
2469
+ );
2470
+
2471
+ const textColorOpacityInput = screen.getByLabelText(
2472
+ "Text color opacity",
2473
+ ) as HTMLInputElement;
2474
+
2475
+ fireEvent.change(textColorOpacityInput, {
2476
+ target: { value: "55" },
2477
+ });
2478
+ fireEvent.blur(textColorOpacityInput);
2479
+
2480
+ await waitFor(() => {
2481
+ expect(JSON.parse(screen.getByTestId("text-color-value").textContent ?? "{}")).toEqual({
2482
+ hex: "#C1FF00",
2483
+ opacity: 55,
2484
+ });
2485
+ });
2486
+ });
2487
+
2488
+ it("keeps two color opacity controls stacked even when an inline row is requested", () => {
2489
+ const schema = defineToolcraft({
2490
+ canvas: { enabled: true },
2491
+ panels: {
2492
+ controls: {
2493
+ sections: [
2494
+ {
2495
+ controls: {
2496
+ fillColor: {
2497
+ defaultValue: { hex: "#0EA5E9", opacity: 82 },
2498
+ label: "Fill",
2499
+ target: "shape.fill",
2500
+ type: "colorOpacity",
2501
+ },
2502
+ strokeColor: {
2503
+ defaultValue: { hex: "#111827", opacity: 100 },
2504
+ label: "Stroke",
2505
+ target: "shape.stroke",
2506
+ type: "colorOpacity",
2507
+ },
2508
+ },
2509
+ layoutGroups: [
2510
+ {
2511
+ columns: 2,
2512
+ controls: ["fillColor", "strokeColor"],
2513
+ layout: "inline",
2514
+ },
2515
+ ],
2516
+ title: "Shape",
2517
+ },
2518
+ ],
2519
+ title: "Generation Controls",
2520
+ },
2521
+ },
2522
+ });
2523
+
2524
+ const { container } = renderControlsPanelWithSchema(schema);
2525
+ const inlineGroup = container.querySelector<HTMLElement>(
2526
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
2527
+ );
2528
+ const fillOpacityInput = screen.getByLabelText("Fill opacity");
2529
+ const strokeOpacityInput = screen.getByLabelText("Stroke opacity");
2530
+
2531
+ expect(inlineGroup).toBeNull();
2532
+ expect(fillOpacityInput).toBeTruthy();
2533
+ expect(strokeOpacityInput).toBeTruthy();
2534
+ });
2535
+
2536
+ it("keeps mixed plain color and color opacity controls stacked when opacity is present", () => {
2537
+ const schema = defineToolcraft({
2538
+ canvas: { enabled: true },
2539
+ panels: {
2540
+ controls: {
2541
+ sections: [
2542
+ {
2543
+ controls: {
2544
+ backgroundColor: {
2545
+ defaultValue: { hex: "#0E0E0E" },
2546
+ label: "Background",
2547
+ target: "background.color",
2548
+ type: "color",
2549
+ },
2550
+ textColor: {
2551
+ defaultValue: { hex: "#FFFFFF", opacity: 88 },
2552
+ label: "Text",
2553
+ target: "text.color",
2554
+ type: "colorOpacity",
2555
+ },
2556
+ },
2557
+ layoutGroups: [
2558
+ {
2559
+ columns: 2,
2560
+ controls: ["backgroundColor", "textColor"],
2561
+ layout: "inline",
2562
+ },
2563
+ ],
2564
+ title: "Colors",
2565
+ },
2566
+ ],
2567
+ title: "Generation Controls",
2568
+ },
2569
+ },
2570
+ });
2571
+
2572
+ const { container } = renderControlsPanelWithSchema(schema);
2573
+ const inlineGroup = container.querySelector<HTMLElement>(
2574
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
2575
+ );
2576
+
2577
+ expect(inlineGroup).toBeNull();
2578
+ expect(screen.getByLabelText("Background hex")).toBeTruthy();
2579
+ expect(screen.getByLabelText("Text opacity")).toBeTruthy();
2580
+ });
2581
+
2582
+ it("keeps mixed numeric and color opacity controls stacked even when an inline row is requested", () => {
2583
+ const schema = defineToolcraft({
2584
+ canvas: { enabled: true },
2585
+ panels: {
2586
+ controls: {
2587
+ sections: [
2588
+ {
2589
+ controls: {
2590
+ maskSize: {
2591
+ defaultValue: "180",
2592
+ label: "Mask size",
2593
+ target: "mask.size",
2594
+ type: "text",
2595
+ },
2596
+ maskColor: {
2597
+ defaultValue: { hex: "#0EA5E9", opacity: 82 },
2598
+ label: "Color",
2599
+ target: "mask.color",
2600
+ type: "colorOpacity",
2601
+ },
2602
+ },
2603
+ layoutGroups: [
2604
+ {
2605
+ columns: 2,
2606
+ controls: ["maskSize", "maskColor"],
2607
+ layout: "inline",
2608
+ },
2609
+ ],
2610
+ title: "Mask",
2611
+ },
2612
+ ],
2613
+ title: "Generation Controls",
2614
+ },
2615
+ },
2616
+ });
2617
+
2618
+ const { container } = renderControlsPanelWithSchema(schema);
2619
+ const inlineGroup = container.querySelector<HTMLElement>(
2620
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
2621
+ );
2622
+
2623
+ expect(inlineGroup).toBeNull();
2624
+ expect(container.textContent).toContain("Mask size");
2625
+ expect(screen.getByLabelText("Color hex")).toBeTruthy();
2626
+ expect((screen.getByLabelText("Color opacity") as HTMLInputElement).value).toBe(
2627
+ "82",
2628
+ );
2629
+ });
2630
+
2631
+ it("uses HSL as the default color field format with the Figma-style HSL surface", () => {
2632
+ const defaultModel = getColorSurfaceModel(DEFAULT_COLOR_FORMAT_MODE);
2633
+
2634
+ expect(DEFAULT_COLOR_FORMAT_MODE).toBe("hsl");
2635
+ expect(defaultModel).toBe("hsl");
2636
+ expect(
2637
+ getColorSurfaceSliderConfig({
2638
+ colorModel: defaultModel,
2639
+ currentColorHex: "#336699",
2640
+ hueLabel: "Color hue",
2641
+ optimisticColor: { h: 210, s: 0.67, v: 0.6 },
2642
+ }),
2643
+ ).toEqual({
2644
+ label: "Color hue",
2645
+ max: 360,
2646
+ railBackground:
2647
+ "linear-gradient(90deg, #ff0000 0%, #ffff00 16.67%, #00ff00 33.33%, #00ffff 50%, #0000ff 66.67%, #ff00ff 83.33%, #ff0000 100%)",
2648
+ value: 210,
2649
+ });
2650
+ });
2651
+
2652
+ it("matches Figma color surface models for editable color formats", () => {
2653
+ expect(getColorSurfaceModel("rgb")).toBe("hsb");
2654
+ expect(getColorSurfaceModel("hsl")).toBe("hsl");
2655
+ expect(getColorSurfaceModel("hsb")).toBe("hsb");
2656
+ expect(getColorSurfaceModel("hex")).toBe("hsb");
2657
+
2658
+ const rgbStyle = getColorSurfaceStyle({
2659
+ colorModel: getColorSurfaceModel("rgb"),
2660
+ currentColorHex: "#336699",
2661
+ hueColor: "#0088FF",
2662
+ });
2663
+ const hslStyle = getColorSurfaceStyle({
2664
+ colorModel: getColorSurfaceModel("hsl"),
2665
+ currentColorHex: "#336699",
2666
+ hueColor: "#0088FF",
2667
+ });
2668
+ const hsbStyle = getColorSurfaceStyle({
2669
+ colorModel: "hsb",
2670
+ currentColorHex: "#336699",
2671
+ hueColor: "#0088FF",
2672
+ });
2673
+
2674
+ expect(rgbStyle.backgroundColor).toBe("#0088FF");
2675
+ expect(String(hslStyle.backgroundImage)).toContain("hsl(210 100% 50%)");
2676
+ expect(hsbStyle.backgroundColor).toBe("#0088FF");
2677
+
2678
+ expect(
2679
+ getColorSurfaceSliderConfig({
2680
+ colorModel: getColorSurfaceModel("rgb"),
2681
+ currentColorHex: "#336699",
2682
+ hueLabel: "Color hue",
2683
+ optimisticColor: { h: 210, s: 0.67, v: 0.6 },
2684
+ }),
2685
+ ).toEqual({
2686
+ label: "Color hue",
2687
+ max: 360,
2688
+ railBackground:
2689
+ "linear-gradient(90deg, #ff0000 0%, #ffff00 16.67%, #00ff00 33.33%, #00ffff 50%, #0000ff 66.67%, #ff00ff 83.33%, #ff0000 100%)",
2690
+ value: 210,
2691
+ });
2692
+ expect(
2693
+ getColorSurfaceSliderConfig({
2694
+ colorModel: getColorSurfaceModel("hsl"),
2695
+ currentColorHex: "#336699",
2696
+ hueLabel: "Color hue",
2697
+ optimisticColor: { h: 210, s: 0.67, v: 0.6 },
2698
+ }),
2699
+ ).toEqual({
2700
+ label: "Color hue",
2701
+ max: 360,
2702
+ railBackground:
2703
+ "linear-gradient(90deg, #ff0000 0%, #ffff00 16.67%, #00ff00 33.33%, #00ffff 50%, #0000ff 66.67%, #ff00ff 83.33%, #ff0000 100%)",
2704
+ value: 210,
2705
+ });
2706
+
2707
+ expect(
2708
+ getColorSurfaceThumbPosition({
2709
+ colorModel: getColorSurfaceModel("rgb"),
2710
+ currentColorHex: "#336699",
2711
+ optimisticColor: { h: 210, s: 0.67, v: 0.6 },
2712
+ }),
2713
+ ).toEqual({ left: "67%", top: "40%" });
2714
+ expect(
2715
+ getColorSurfaceThumbPosition({
2716
+ colorModel: getColorSurfaceModel("hsl"),
2717
+ currentColorHex: "#336699",
2718
+ optimisticColor: { h: 210, s: 0.67, v: 0.6 },
2719
+ }),
2720
+ ).toEqual({ left: "50%", top: "60%" });
2721
+ expect(
2722
+ getColorSurfaceThumbPosition({
2723
+ colorModel: getColorSurfaceModel("hsl"),
2724
+ currentColorHex: "#000000",
2725
+ optimisticColor: { h: 210, s: 0.67, v: 0 },
2726
+ surfacePosition: { x: 0.72, y: 1 },
2727
+ }),
2728
+ ).toEqual({ left: "72%", top: "100%" });
2729
+ });
2730
+
2731
+ it("keeps hue fixed while dragging inside HSB and HSL color surfaces", () => {
2732
+ const surfaceBounds = { height: 100, left: 0, top: 0, width: 100 };
2733
+
2734
+ expect(
2735
+ getSurfaceHsvColor({
2736
+ clientX: 5,
2737
+ clientY: 95,
2738
+ currentColor: { h: 133, s: 0.85, v: 0.85 },
2739
+ surfaceBounds,
2740
+ surfaceModel: "hsb",
2741
+ }).h,
2742
+ ).toBe(133);
2743
+ expect(
2744
+ getSurfaceHsvColor({
2745
+ clientX: 5,
2746
+ clientY: 95,
2747
+ currentColor: { h: 133, s: 0.85, v: 0.85 },
2748
+ surfaceBounds,
2749
+ surfaceModel: "hsl",
2750
+ }).h,
2751
+ ).toBe(133);
2752
+ });
2753
+
2754
+ it.each([
2755
+ ["linear", "Linear"],
2756
+ ["radial", "Radial"],
2757
+ ["angular", "Angular"],
2758
+ ["diamond", "Diamond"],
2759
+ ] as const)("preserves %s gradient type in the gradient select", (gradientType, label) => {
2760
+ const schema = defineToolcraft({
2761
+ canvas: { enabled: true },
2762
+ panels: {
2763
+ controls: {
2764
+ sections: [
2765
+ {
2766
+ controls: {
2767
+ gradient: {
2768
+ defaultValue: {
2769
+ angle: 138,
2770
+ gradientType,
2771
+ stops: [
2772
+ { color: "#111111", position: "0%" },
2773
+ { color: "#14B8FF", position: "45%" },
2774
+ { color: "#F472B6", position: "76%" },
2775
+ ],
2776
+ },
2777
+ label: "Gradient",
2778
+ target: "style.gradient",
2779
+ type: "gradient",
2780
+ },
2781
+ },
2782
+ title: "Gradient",
2783
+ },
2784
+ ],
2785
+ title: "Generation Controls",
2786
+ },
2787
+ },
2788
+ });
2789
+
2790
+ const { container } = renderControlsPanelWithSchema(schema);
2791
+ const gradientSelect = container.querySelector('[data-slot="select-trigger"]');
2792
+
2793
+ expect(gradientSelect?.textContent).toContain(label);
2794
+ });
2795
+
2796
+ it("commits gradient stop position inputs on blur or Enter with a separated percent suffix", () => {
2797
+ const schema = defineToolcraft({
2798
+ canvas: { enabled: true },
2799
+ panels: {
2800
+ controls: {
2801
+ sections: [
2802
+ {
2803
+ controls: {
2804
+ gradient: {
2805
+ defaultValue: {
2806
+ angle: 90,
2807
+ gradientType: "linear",
2808
+ stops: [
2809
+ { color: "#111111", position: "0%" },
2810
+ { color: "#FFFFFF", position: "100%" },
2811
+ ],
2812
+ },
2813
+ label: "Gradient",
2814
+ target: "style.gradient",
2815
+ type: "gradient",
2816
+ },
2817
+ },
2818
+ title: "Gradient",
2819
+ },
2820
+ ],
2821
+ title: "Generation Controls",
2822
+ },
2823
+ },
2824
+ });
2825
+
2826
+ renderControlsPanelWithSchema(schema);
2827
+
2828
+ const firstPositionInput = screen.getByLabelText(
2829
+ "Stop 1 position",
2830
+ ) as HTMLInputElement;
2831
+ const secondPositionInput = screen.getByLabelText(
2832
+ "Stop 2 position",
2833
+ ) as HTMLInputElement;
2834
+ const firstPositionGroup = firstPositionInput.closest('[data-slot="input-group"]');
2835
+ const firstOpacityInput = screen.getByLabelText("Stop 1 opacity");
2836
+ const stopsList = firstPositionInput.closest('[data-slot="gradient-stops-list"]');
2837
+ const gradientControlItem = stopsList?.closest(
2838
+ "[data-control-item-compound-context]",
2839
+ );
2840
+ const firstStopGrid = firstPositionInput.closest('[data-slot="field"]')
2841
+ ?.querySelector('[data-slot="gradient-stop-row-grid"]');
2842
+
2843
+ expect(firstPositionInput.value).toBe("0");
2844
+ expect(firstPositionGroup?.textContent).toContain("%");
2845
+ expect(stopsList?.className).not.toContain("border-y");
2846
+ expect(gradientControlItem).toBeNull();
2847
+ expect(firstPositionInput.className.split(/\s+/)).toContain("pl-[5px]");
2848
+ expect(firstOpacityInput.className.split(/\s+/)).toContain("pl-[5px]");
2849
+ expect(firstStopGrid?.className).toContain("grid-cols-[3.5rem_minmax(0,1fr)_1.5rem]");
2850
+
2851
+ fireEvent.change(firstPositionInput, { target: { value: "25" } });
2852
+ expect(JSON.parse(screen.getByTestId("gradient-value").textContent ?? "{}")).toMatchObject({
2853
+ stops: [
2854
+ { position: "0%" },
2855
+ { position: "100%" },
2856
+ ],
2857
+ });
2858
+
2859
+ fireEvent.blur(firstPositionInput);
2860
+ expect(JSON.parse(screen.getByTestId("gradient-value").textContent ?? "{}")).toMatchObject({
2861
+ stops: [
2862
+ { position: "25%" },
2863
+ { position: "100%" },
2864
+ ],
2865
+ });
2866
+
2867
+ fireEvent.change(secondPositionInput, { target: { value: "75" } });
2868
+ expect(JSON.parse(screen.getByTestId("gradient-value").textContent ?? "{}")).toMatchObject({
2869
+ stops: [
2870
+ { position: "25%" },
2871
+ { position: "100%" },
2872
+ ],
2873
+ });
2874
+
2875
+ fireEvent.keyDown(secondPositionInput, { code: "Enter", key: "Enter" });
2876
+ expect(JSON.parse(screen.getByTestId("gradient-value").textContent ?? "{}")).toMatchObject({
2877
+ stops: [
2878
+ { position: "25%" },
2879
+ { position: "75%" },
2880
+ ],
2881
+ });
2882
+ });
2883
+
2884
+ it("renders content-width dividers for sectioned compound controls only when a section has sibling controls", () => {
2885
+ const schema = defineToolcraft({
2886
+ canvas: { enabled: true },
2887
+ panels: {
2888
+ controls: {
2889
+ sections: [
2890
+ {
2891
+ controls: {
2892
+ gradient: {
2893
+ defaultValue: {
2894
+ angle: 90,
2895
+ gradientType: "linear",
2896
+ stops: [
2897
+ { color: "#111111", position: "0%" },
2898
+ { color: "#FFFFFF", position: "100%" },
2899
+ ],
2900
+ },
2901
+ label: "Gradient",
2902
+ target: "style.gradient",
2903
+ type: "gradient",
2904
+ },
2905
+ font: {
2906
+ defaultValue: {
2907
+ color: "#FFFFFF",
2908
+ fontId: "inter",
2909
+ fontSize: 16,
2910
+ fontWeight: "400",
2911
+ letterSpacing: "normal",
2912
+ lineHeight: "normal",
2913
+ opacity: 100,
2914
+ textCase: "original",
2915
+ },
2916
+ label: "Font",
2917
+ target: "typography.font",
2918
+ type: "fontPicker",
2919
+ },
2920
+ },
2921
+ title: "Appearance",
2922
+ },
2923
+ ],
2924
+ title: "Generation Controls",
2925
+ },
2926
+ },
2927
+ });
2928
+
2929
+ renderControlsPanelWithSchema(schema);
2930
+
2931
+ const stopsList = screen
2932
+ .getByLabelText("Stop 1 position")
2933
+ .closest('[data-slot="gradient-stops-list"]');
2934
+ const gradientControlItem = stopsList?.closest(
2935
+ "[data-control-item-compound-context]",
2936
+ ) as HTMLElement | null | undefined;
2937
+ const fontControlItem = screen
2938
+ .getByText("Weight")
2939
+ .closest("[data-control-item-compound-context]") as HTMLElement | null;
2940
+
2941
+ expect(stopsList?.className).not.toContain("border-y");
2942
+ expect(gradientControlItem?.dataset.controlItemCompoundDividerPlacement).toBe(
2943
+ "bottom",
2944
+ );
2945
+ expect(gradientControlItem?.className).not.toContain(
2946
+ "has-data-[control-section-divider=compound]:py-[18px]",
2947
+ );
2948
+ expect(gradientControlItem?.className).not.toContain(
2949
+ "has-data-[control-section-divider=compound]:pt-[18px]",
2950
+ );
2951
+ expect(gradientControlItem?.className).toContain(
2952
+ "has-data-[control-section-divider=compound]:pb-[18px]",
2953
+ );
2954
+ expect(gradientControlItem?.className).not.toContain(
2955
+ "has-data-[control-section-divider=compound]:before:inset-x-3",
2956
+ );
2957
+ expect(gradientControlItem?.className).toContain(
2958
+ "has-data-[control-section-divider=compound]:after:inset-x-3",
2959
+ );
2960
+ expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe(
2961
+ "both",
2962
+ );
2963
+ expect(fontControlItem?.className).toContain(
2964
+ "has-data-[control-section-divider=compound]:pt-[18px]",
2965
+ );
2966
+ expect(fontControlItem?.className).toContain(
2967
+ "has-data-[control-section-divider=compound]:pb-[18px]",
2968
+ );
2969
+ expect(fontControlItem?.className).toContain(
2970
+ "has-data-[control-section-divider=compound]:before:inset-x-3",
2971
+ );
2972
+ expect(fontControlItem?.className).toContain(
2973
+ "has-data-[control-section-divider=compound]:after:inset-x-3",
2974
+ );
2975
+ });
2976
+
2977
+ it("splits sectioned compound controls out of mixed inline layout groups", () => {
2978
+ const schema = defineToolcraft({
2979
+ canvas: { enabled: true },
2980
+ panels: {
2981
+ controls: {
2982
+ sections: [
2983
+ {
2984
+ controls: {
2985
+ gradient: {
2986
+ defaultValue: {
2987
+ angle: 90,
2988
+ gradientType: "linear",
2989
+ stops: [
2990
+ { color: "#111111", position: "0%" },
2991
+ { color: "#FFFFFF", position: "100%" },
2992
+ ],
2993
+ },
2994
+ label: "Gradient",
2995
+ target: "style.gradient",
2996
+ type: "gradient",
2997
+ },
2998
+ mode: {
2999
+ defaultValue: "normal",
3000
+ label: "Mode",
3001
+ options: [
3002
+ { label: "Normal", value: "normal" },
3003
+ { label: "Screen", value: "screen" },
3004
+ ],
3005
+ target: "style.mode",
3006
+ type: "select",
3007
+ },
3008
+ },
3009
+ layoutGroups: [
3010
+ {
3011
+ controls: ["gradient", "mode"],
3012
+ layout: "inline",
3013
+ },
3014
+ ],
3015
+ title: "Appearance",
3016
+ },
3017
+ ],
3018
+ title: "Generation Controls",
3019
+ },
3020
+ },
3021
+ });
3022
+ const { container } = renderControlsPanelWithSchema(schema);
3023
+
3024
+ const inlineGroup = container.querySelector(
3025
+ '[data-control-layout="inline"][data-control-layout-group]',
3026
+ );
3027
+ const stopsList = screen
3028
+ .getByLabelText("Stop 1 position")
3029
+ .closest('[data-slot="gradient-stops-list"]');
3030
+ const gradientSection = stopsList?.closest("section");
3031
+ const modeSection = screen.getByText("Mode").closest("section");
3032
+
3033
+ expect(inlineGroup).toBeNull();
3034
+ expect(stopsList?.closest("[data-control-item-compound-context]")).toBeNull();
3035
+ expect(gradientSection).not.toBe(modeSection);
3036
+ });
3037
+
3038
+ it("binds control changes and reset actions to runtime state", () => {
3039
+ renderControlsPanel();
3040
+
3041
+ const promptInput = screen.getByDisplayValue("Initial prompt");
3042
+
3043
+ fireEvent.change(promptInput, { target: { value: "Updated prompt" } });
3044
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Updated prompt");
3045
+
3046
+ fireEvent.click(screen.getByRole("switch"));
3047
+ expect(screen.getByTestId("enabled-value").textContent).toBe("false");
3048
+
3049
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3050
+
3051
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Initial prompt");
3052
+ expect(screen.getByTestId("enabled-value").textContent).toBe("true");
3053
+ });
3054
+
3055
+ it("commits canvas size controls to runtime state on blur or Enter", () => {
3056
+ const { container } = renderControlsPanel();
3057
+
3058
+ const canvasSizeGroup = container.querySelector(
3059
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
3060
+ );
3061
+
3062
+ expect(canvasSizeGroup).toBeTruthy();
3063
+ expect(canvasSizeGroup?.textContent).toContain("Canvas width");
3064
+ expect(canvasSizeGroup?.textContent).toContain("Canvas height");
3065
+
3066
+ const widthInput = screen.getByDisplayValue("320") as HTMLInputElement;
3067
+
3068
+ fireEvent.change(widthInput, { target: { value: "640" } });
3069
+ expect(screen.getByTestId("canvas-size").textContent).toBe("320,180");
3070
+ expect(widthInput.value).toBe("640");
3071
+
3072
+ fireEvent.blur(widthInput);
3073
+ expect(screen.getByTestId("canvas-size").textContent).toBe("640,180");
3074
+
3075
+ const heightInput = screen.getByDisplayValue("180") as HTMLInputElement;
3076
+
3077
+ fireEvent.change(heightInput, { target: { value: "360" } });
3078
+ expect(screen.getByTestId("canvas-size").textContent).toBe("640,180");
3079
+
3080
+ fireEvent.keyDown(heightInput, { code: "Enter", key: "Enter" });
3081
+ expect(screen.getByTestId("canvas-size").textContent).toBe("640,360");
3082
+
3083
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3084
+
3085
+ expect(screen.getByTestId("canvas-size").textContent).toBe("320,180");
3086
+ });
3087
+
3088
+ it("applies empty text input values while typing and reset restores defaults", () => {
3089
+ renderControlsPanel();
3090
+
3091
+ const promptInput = screen.getByDisplayValue("Initial prompt") as HTMLInputElement;
3092
+
3093
+ fireEvent.change(promptInput, { target: { value: "" } });
3094
+ expect(promptInput.value).toBe("");
3095
+ expect(screen.getByTestId("prompt-value").textContent).toBe("");
3096
+
3097
+ fireEvent.change(promptInput, { target: { value: "Draft prompt" } });
3098
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Draft prompt");
3099
+
3100
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3101
+
3102
+ expect(screen.getByDisplayValue("Initial prompt")).toBeTruthy();
3103
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Initial prompt");
3104
+ });
3105
+
3106
+ it("renders keyframe actions for timeline-expanded keyframe-capable controls", () => {
3107
+ renderControlsPanel(undefined, { timeline: { expanded: true, keyframeGroups: [] } });
3108
+
3109
+ expect(screen.getByRole("button", { name: "Add Anchor keyframe" })).toBeTruthy();
3110
+ expect(screen.getByRole("button", { name: "Add Opacity keyframe" })).toBeTruthy();
3111
+ expect(screen.getByRole("button", { name: "Add Static opacity keyframe" })).toBeTruthy();
3112
+ expect(screen.getByRole("button", { name: "Add Output Mix keyframe" })).toBeTruthy();
3113
+ expect(screen.getByRole("button", { name: "Add Curves keyframe" })).toBeTruthy();
3114
+ expect(screen.queryByRole("button", { name: "Add Prompt keyframe" })).toBeNull();
3115
+ expect(screen.queryByRole("button", { name: "Add Blend keyframe" })).toBeNull();
3116
+ expect(screen.queryByRole("button", { name: "Add Channels keyframe" })).toBeNull();
3117
+ expect(screen.queryByRole("button", { name: "Add Enabled keyframe" })).toBeNull();
3118
+ expect(screen.queryByRole("button", { name: "Add Canvas width keyframe" })).toBeNull();
3119
+ expect(
3120
+ screen
3121
+ .getByRole("button", { name: "Add Opacity keyframe" })
3122
+ .closest("[data-control-field-label]"),
3123
+ ).toBeTruthy();
3124
+ expect(
3125
+ screen
3126
+ .getByRole("button", { name: "Add Output Mix keyframe" })
3127
+ .closest("[data-control-field-label]"),
3128
+ ).toBeTruthy();
3129
+ expect(
3130
+ screen
3131
+ .getByRole("button", { name: "Add Curves keyframe" })
3132
+ .closest("[data-control-field-label]"),
3133
+ ).toBeTruthy();
3134
+
3135
+ fireEvent.click(screen.getByRole("button", { name: "Add Opacity keyframe" }));
3136
+
3137
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
3138
+ const opacityKeyframeButton = screen.getByRole("button", {
3139
+ name: "Disable Opacity keyframes",
3140
+ });
3141
+
3142
+ expect(opacityKeyframeButton.className).toContain(
3143
+ "!text-[color:var(--link)]",
3144
+ );
3145
+ expect(opacityKeyframeButton.className).toContain(
3146
+ "data-popup-open:!text-[color:var(--link)]",
3147
+ );
3148
+ expect(opacityKeyframeButton.className).toContain(
3149
+ "[&_svg]:!fill-[color:var(--link)]",
3150
+ );
3151
+ expect(opacityKeyframeButton.style.color).toBe("var(--link)");
3152
+ expect(screen.getByTestId("timeline-keyframes").textContent).toContain(
3153
+ '"controlId":"selectedLayer.opacity"',
3154
+ );
3155
+ expect(screen.getByTestId("timeline-keyframes").textContent).toContain(
3156
+ '"valueLabel":"75%"',
3157
+ );
3158
+ expect(screen.getByTestId("timeline-keyframes").textContent).toContain('"value":75');
3159
+
3160
+ expect(screen.queryByRole("button", { name: "Add Prompt keyframe" })).toBeNull();
3161
+ });
3162
+
3163
+ it("updates the selected control keyframe instead of the current playhead time", () => {
3164
+ const schema = defineToolcraft({
3165
+ canvas: { enabled: true },
3166
+ panels: {
3167
+ controls: {
3168
+ sections: [
3169
+ {
3170
+ controls: {
3171
+ range: {
3172
+ defaultValue: { end: "100%", start: "0%" },
3173
+ label: "Range",
3174
+ target: "shape.range",
3175
+ type: "rangeInput",
3176
+ },
3177
+ },
3178
+ },
3179
+ ],
3180
+ title: "Controls",
3181
+ },
3182
+ timeline: true,
3183
+ },
3184
+ });
3185
+
3186
+ renderControlsPanelWithSchema(schema, {}, {
3187
+ timeline: {
3188
+ currentTimeSeconds: 4,
3189
+ expanded: true,
3190
+ keyframeGroups: [
3191
+ {
3192
+ controlId: "shape.range",
3193
+ keyframes: [
3194
+ {
3195
+ controlId: "shape.range",
3196
+ controlLabel: "Range",
3197
+ id: "shape.range::2",
3198
+ timeSeconds: 2,
3199
+ value: { end: "100%", start: "0%" },
3200
+ valueLabel: "0% - 100%",
3201
+ },
3202
+ ],
3203
+ label: "Range",
3204
+ },
3205
+ ],
3206
+ selectedKeyframeId: "shape.range::2",
3207
+ },
3208
+ });
3209
+
3210
+ fireEvent.change(screen.getByLabelText("Range start"), {
3211
+ target: { value: "25%" },
3212
+ });
3213
+ fireEvent.blur(screen.getByLabelText("Range start"));
3214
+
3215
+ const keyframeGroups = JSON.parse(
3216
+ screen.getByTestId("timeline-keyframes").textContent ?? "[]",
3217
+ ) as Array<{
3218
+ keyframes: Array<{ id: string; timeSeconds: number; value: { end: string; start: string } }>;
3219
+ }>;
3220
+
3221
+ expect(keyframeGroups[0]?.keyframes).toHaveLength(1);
3222
+ expect(keyframeGroups[0]?.keyframes[0]).toMatchObject({
3223
+ id: "shape.range::2",
3224
+ timeSeconds: 2,
3225
+ value: { end: "100%", start: "25%" },
3226
+ });
3227
+ });
3228
+
3229
+ it("keeps keyframe actions hidden while the timeline is collapsed", () => {
3230
+ renderControlsPanel();
3231
+
3232
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("false");
3233
+ expect(screen.queryByRole("button", { name: "Add Prompt keyframe" })).toBeNull();
3234
+ expect(screen.queryByRole("button", { name: "Add Opacity keyframe" })).toBeNull();
3235
+ });
3236
+
3237
+ it("keeps keyframe actions hidden for playback-only timeline apps", () => {
3238
+ const schema = defineToolcraft({
3239
+ canvas: { enabled: true },
3240
+ panels: {
3241
+ controls: createSchema().panels.controls,
3242
+ timeline: { mode: "playback" },
3243
+ },
3244
+ });
3245
+
3246
+ renderControlsPanelWithSchema(schema, undefined, {
3247
+ timeline: { expanded: true, keyframeGroups: [] },
3248
+ });
3249
+
3250
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
3251
+ expect(screen.queryByRole("button", { name: "Add Prompt keyframe" })).toBeNull();
3252
+ expect(screen.queryByRole("button", { name: "Add Opacity keyframe" })).toBeNull();
3253
+ });
3254
+
3255
+ it("suppresses control transitions when keyframe controls appear", async () => {
3256
+ const { container } = renderControlsPanel();
3257
+ const content = () =>
3258
+ container.querySelector('[data-slot="toolcraft-panel-content"]');
3259
+
3260
+ await waitFor(() => {
3261
+ expect(content()?.getAttribute("data-toolcraft-controls-mounting")).toBeNull();
3262
+ });
3263
+
3264
+ fireEvent.click(screen.getByRole("button", { name: "Expand timeline" }));
3265
+
3266
+ expect(content()?.getAttribute("data-toolcraft-controls-mounting")).toBe("true");
3267
+ });
3268
+
3269
+ it("routes footer panel actions through the command bus", () => {
3270
+ renderControlsPanel();
3271
+
3272
+ fireEvent.change(screen.getByDisplayValue("Initial prompt"), {
3273
+ target: { value: "Draft" },
3274
+ });
3275
+ fireEvent.blur(screen.getByDisplayValue("Draft"));
3276
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Draft");
3277
+
3278
+ fireEvent.click(screen.getByRole("button", { name: "Reset" }));
3279
+
3280
+ expect(screen.getByTestId("prompt-value").textContent).toBe("Initial prompt");
3281
+ });
3282
+
3283
+ it("routes app-specific footer panel actions through onPanelAction", () => {
3284
+ const handledActions: string[] = [];
3285
+ const schema = defineToolcraft({
3286
+ canvas: { enabled: false },
3287
+ panels: {
3288
+ controls: {
3289
+ sections: [
3290
+ {
3291
+ controls: {
3292
+ footer: {
3293
+ actions: [
3294
+ {
3295
+ icon: "download",
3296
+ label: "Download image",
3297
+ value: "download",
3298
+ variant: "default",
3299
+ },
3300
+ ],
3301
+ target: "panel.actions",
3302
+ type: "panelActions",
3303
+ },
3304
+ },
3305
+ title: "Export",
3306
+ },
3307
+ ],
3308
+ title: "Generation Controls",
3309
+ },
3310
+ },
3311
+ });
3312
+
3313
+ renderControlsPanelWithSchema(schema, {
3314
+ onPanelAction: ({ action, state }) => {
3315
+ handledActions.push(`${action.value}:${state.schema.panels.controls?.title}`);
3316
+ },
3317
+ });
3318
+
3319
+ fireEvent.click(screen.getByRole("button", { name: "Download image" }));
3320
+
3321
+ expect(handledActions).toEqual(["download:Generation Controls"]);
3322
+ });
3323
+
3324
+ it("renders runtime settings transfer as a body section instead of sticky footer actions", () => {
3325
+ const schema = defineToolcraft({
3326
+ canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
3327
+ panels: {
3328
+ controls: {
3329
+ sections: [
3330
+ {
3331
+ controls: {
3332
+ prompt: {
3333
+ defaultValue: "Initial prompt",
3334
+ label: "Prompt",
3335
+ target: "generation.prompt",
3336
+ type: "text",
3337
+ },
3338
+ },
3339
+ title: "Prompt",
3340
+ },
3341
+ {
3342
+ actionGroup: "secondary",
3343
+ controls: {
3344
+ footer: {
3345
+ actions: [
3346
+ {
3347
+ label: "Export PNG",
3348
+ value: "export.png",
3349
+ variant: "default",
3350
+ },
3351
+ ],
3352
+ target: "panel.actions",
3353
+ type: "panelActions",
3354
+ },
3355
+ },
3356
+ },
3357
+ ],
3358
+ title: "Generation Controls",
3359
+ },
3360
+ },
3361
+ settingsTransfer: true,
3362
+ });
3363
+ const { container } = renderControlsPanelWithSchema(schema);
3364
+ const scrollContent = container.querySelector(
3365
+ '[data-slot="toolcraft-panel-content"]',
3366
+ );
3367
+ const stickyFooter = container.querySelector(
3368
+ '[data-slot="toolcraft-panel-sticky-actions"]',
3369
+ );
3370
+ const firstSection = container.querySelector("section");
3371
+ const firstInlineGroup = firstSection?.querySelector(
3372
+ '[data-control-layout="inline"][data-control-layout-columns="2"]',
3373
+ );
3374
+
3375
+ const transferButtons = screen
3376
+ .getAllByRole("button", { name: /Settings$/ })
3377
+ .map((button) => button.textContent);
3378
+
3379
+ expect(transferButtons).toEqual(["Export Settings", "Import Settings"]);
3380
+ expect(schema.panels.controls?.sections[0]?.title).toBe("Setup");
3381
+ expect(
3382
+ [...container.querySelectorAll('[data-slot="panel-title"]')].map(
3383
+ (title) => title.textContent,
3384
+ ),
3385
+ ).not.toContain("Setup");
3386
+ expect(firstSection?.querySelector('[data-slot="panel-title"]')).toBeNull();
3387
+ expect(firstSection?.querySelector("[data-control-section-collapse-button]")).toBeNull();
3388
+ expect(firstSection?.className).toContain("py-0");
3389
+ expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("py-3");
3390
+ expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pt-2");
3391
+ expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pb-5");
3392
+ expect(firstInlineGroup?.textContent).toContain("Canvas width");
3393
+ expect(firstInlineGroup?.textContent).toContain("Canvas height");
3394
+ expect(firstSection?.textContent).toContain("Canvas width");
3395
+ expect(firstSection?.textContent).toContain("Canvas height");
3396
+ expect(firstSection?.textContent).toContain("Export Settings");
3397
+ expect(firstSection?.textContent).toContain("Import Settings");
3398
+ expect((firstSection?.textContent ?? "").indexOf("Export Settings")).toBeLessThan(
3399
+ (firstSection?.textContent ?? "").indexOf("Canvas width"),
3400
+ );
3401
+ expect(scrollContent?.textContent).toContain("Import Settings");
3402
+ expect(scrollContent?.textContent).toContain("Export Settings");
3403
+ expect(stickyFooter?.textContent).toContain("Export PNG");
3404
+ expect(stickyFooter?.textContent).not.toContain("Import Settings");
3405
+ });
3406
+
3407
+ it("keeps panelActions in the sticky footer even when the schema omits actionGroup", () => {
3408
+ const schema = defineToolcraft({
3409
+ canvas: { enabled: false },
3410
+ panels: {
3411
+ controls: {
3412
+ sections: [
3413
+ {
3414
+ controls: {
3415
+ footer: {
3416
+ actions: [
3417
+ {
3418
+ command: "controls.reset",
3419
+ label: "Reset",
3420
+ value: "reset",
3421
+ variant: "outline",
3422
+ },
3423
+ {
3424
+ command: "controls.apply",
3425
+ label: "Apply",
3426
+ value: "apply",
3427
+ variant: "default",
3428
+ },
3429
+ ],
3430
+ target: "panel.actions",
3431
+ type: "panelActions",
3432
+ },
3433
+ prompt: {
3434
+ defaultValue: "Initial prompt",
3435
+ label: "Prompt",
3436
+ target: "generation.prompt",
3437
+ type: "text",
3438
+ },
3439
+ },
3440
+ title: "Export",
3441
+ },
3442
+ ],
3443
+ title: "Generation Controls",
3444
+ },
3445
+ },
3446
+ });
3447
+ const { container } = renderControlsPanelWithSchema(schema);
3448
+ const stickyFooter = container.querySelector(
3449
+ '[data-slot="toolcraft-panel-sticky-actions"]',
3450
+ );
3451
+ const scrollContent = container.querySelector(
3452
+ '[data-slot="toolcraft-panel-content"]',
3453
+ );
3454
+
3455
+ expect(stickyFooter?.textContent).toContain("Reset");
3456
+ expect(stickyFooter?.textContent).toContain("Apply");
3457
+ expect(stickyFooter?.className).toContain("before:h-px");
3458
+ expect(stickyFooter?.className).toContain("var(--accent)");
3459
+ expect(stickyFooter?.className).toContain(
3460
+ "before:scale-x-[var(--sticky-footer-progress,1)]",
3461
+ );
3462
+ expect(stickyFooter?.className).toContain("before:opacity-0");
3463
+ expect(stickyFooter?.className).toContain("before:transition-[opacity,transform]");
3464
+ expect(stickyFooter?.className).toContain(
3465
+ "data-[sticky-footer-active=true]:before:opacity-100",
3466
+ );
3467
+ expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBeNull();
3468
+ expect(stickyFooter?.querySelector('[data-toolcraft-section-actions]')).toBeTruthy();
3469
+ expect(stickyFooter?.querySelector('[data-slot="panel-title"]')).toBeNull();
3470
+ expect(stickyFooter?.querySelector("[data-control-section-collapse-button]")).toBeNull();
3471
+ expect(stickyFooter?.textContent).not.toContain("Export");
3472
+ expect(scrollContent?.textContent).toContain("Prompt");
3473
+ expect(scrollContent?.textContent).not.toContain("ResetApply");
3474
+ });
3475
+
3476
+ it("renders titled body sections as 36px collapsible header rows", () => {
3477
+ const schema = createSchema();
3478
+ const { container } = renderControlsPanelWithSchema(schema);
3479
+ const basicTitle = screen.getByText("Basic");
3480
+ const header = basicTitle.closest('[data-slot="control-section-header"]');
3481
+ const section = basicTitle.closest("section");
3482
+ const controlList = section?.querySelector("[data-control-list]");
3483
+ const body = section?.querySelector('[data-slot="panel-section-collapsible-body"]');
3484
+
3485
+ expect(header?.className).toContain("h-9");
3486
+ expect(header?.getAttribute("data-collapsible")).toBe("");
3487
+ expect(header?.getAttribute("aria-expanded")).toBe("true");
3488
+ expect(header?.getAttribute("role")).toBe("button");
3489
+ expect(body?.className).toContain("transition-[grid-template-rows,opacity]");
3490
+ expect(body?.className).toContain("grid-rows-[1fr]");
3491
+ expect(controlList?.className).toContain("pt-2");
3492
+ expect(controlList?.className).toContain("pb-6");
3493
+ expect(section?.textContent).toContain("Prompt");
3494
+ expect(section?.textContent).toContain("Opacity");
3495
+
3496
+ fireEvent.click(header!);
3497
+
3498
+ expect(section?.getAttribute("data-collapsed")).toBe("true");
3499
+ expect(header?.getAttribute("aria-expanded")).toBe("false");
3500
+ expect(body?.getAttribute("aria-hidden")).toBe("true");
3501
+ expect(body?.className).toContain("grid-rows-[0fr]");
3502
+ expect(section?.textContent).toContain("Basic");
3503
+ expect(section?.textContent).toContain("Prompt");
3504
+
3505
+ fireEvent.transitionEnd(body!);
3506
+
3507
+ expect(section?.textContent).not.toContain("Prompt");
3508
+ expect(section?.textContent).not.toContain("Opacity");
3509
+
3510
+ fireEvent.keyDown(header!, { key: "Enter" });
3511
+
3512
+ expect(section?.getAttribute("data-collapsed")).toBe("false");
3513
+ expect(header?.getAttribute("aria-expanded")).toBe("true");
3514
+ expect(section?.querySelector('[data-slot="panel-section-collapsible-body"]')).toBeTruthy();
3515
+ expect(container.querySelector('[data-slot="control-section-header"]')).toBeTruthy();
3516
+ });
3517
+
3518
+ it("persists ordinary section collapse state as a UI preference across remounts", () => {
3519
+ const schema = createSchema();
3520
+ const { unmount } = renderControlsPanelWithSchema(schema);
3521
+ const basicHeader = screen
3522
+ .getByText("Basic")
3523
+ .closest<HTMLElement>('[data-slot="control-section-header"]');
3524
+
3525
+ fireEvent.click(basicHeader!);
3526
+
3527
+ expect(basicHeader?.closest("section")?.getAttribute("data-collapsed")).toBe("true");
3528
+
3529
+ unmount();
3530
+ cleanup();
3531
+
3532
+ renderControlsPanelWithSchema(schema);
3533
+
3534
+ const restoredSection = screen.getByText("Basic").closest("section");
3535
+
3536
+ expect(restoredSection?.getAttribute("data-collapsed")).toBe("true");
3537
+
3538
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3539
+
3540
+ expect(screen.getByText("Basic").closest("section")?.getAttribute("data-collapsed")).toBe(
3541
+ "true",
3542
+ );
3543
+ });
3544
+
3545
+ it("puts ordinary implicit panel section spacing on the control list layer", () => {
3546
+ const { container } = render(
3547
+ <Panel title="Master Controls">
3548
+ <FileDrop accept="PNG, JPEG" />
3549
+ </Panel>,
3550
+ );
3551
+ const section = container.querySelector("section");
3552
+ const controlList = section?.querySelector("[data-control-list]");
3553
+
3554
+ expect(section?.className).toContain("py-0");
3555
+ expect(controlList?.className).toContain("pt-2");
3556
+ expect(controlList?.className).toContain("pb-6");
3557
+ expect(controlList?.textContent).toContain("Click to upload an image");
3558
+ });
3559
+
3560
+ it("shows the sticky footer export indicator only while an async panel action is pending", async () => {
3561
+ const schema = defineToolcraft({
3562
+ canvas: { enabled: false },
3563
+ panels: {
3564
+ controls: {
3565
+ sections: [
3566
+ {
3567
+ actionGroup: "secondary",
3568
+ controls: {
3569
+ footer: {
3570
+ actions: [
3571
+ {
3572
+ icon: "download-simple",
3573
+ label: "Export PNG",
3574
+ value: "export-png",
3575
+ variant: "default",
3576
+ },
3577
+ ],
3578
+ target: "panel.actions",
3579
+ type: "panelActions",
3580
+ },
3581
+ },
3582
+ },
3583
+ ],
3584
+ title: "Generation Controls",
3585
+ },
3586
+ },
3587
+ });
3588
+ let resolveAction: (() => void) | undefined;
3589
+ const pendingAction = new Promise<void>((resolve) => {
3590
+ resolveAction = resolve;
3591
+ });
3592
+ let reportActionProgress: ((progress: number) => void) | undefined;
3593
+ const onPanelAction = vi.fn(({ reportProgress }) => {
3594
+ reportActionProgress = reportProgress;
3595
+
3596
+ return pendingAction;
3597
+ });
3598
+ const { container } = renderControlsPanelWithSchema(schema, { onPanelAction });
3599
+ const stickyFooter = container.querySelector(
3600
+ '[data-slot="toolcraft-panel-sticky-actions"]',
3601
+ );
3602
+
3603
+ expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBeNull();
3604
+
3605
+ fireEvent.click(screen.getByRole("button", { name: "Export PNG" }));
3606
+
3607
+ expect(onPanelAction).toHaveBeenCalledTimes(1);
3608
+ await waitFor(() => {
3609
+ expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBe("true");
3610
+ });
3611
+ expect(stickyFooter?.getAttribute("data-sticky-footer-progress")).toBeNull();
3612
+
3613
+ act(() => {
3614
+ reportActionProgress?.(0.25);
3615
+ });
3616
+
3617
+ await waitFor(() => {
3618
+ expect(stickyFooter?.getAttribute("data-sticky-footer-progress")).toBe("0.25");
3619
+ expect(
3620
+ (stickyFooter as HTMLElement | null)?.style.getPropertyValue(
3621
+ "--sticky-footer-progress",
3622
+ ),
3623
+ ).toBe("0.25");
3624
+ });
3625
+
3626
+ act(() => {
3627
+ reportActionProgress?.(2);
3628
+ });
3629
+
3630
+ await waitFor(() => {
3631
+ expect(stickyFooter?.getAttribute("data-sticky-footer-progress")).toBe("1");
3632
+ });
3633
+
3634
+ await act(async () => {
3635
+ resolveAction?.();
3636
+ await pendingAction;
3637
+ });
3638
+
3639
+ await waitFor(() => {
3640
+ expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBeNull();
3641
+ });
3642
+ });
3643
+
3644
+ it("renders secondary footer actions as secondary buttons", () => {
3645
+ const schema = defineToolcraft({
3646
+ canvas: { enabled: false },
3647
+ panels: {
3648
+ controls: {
3649
+ sections: [
3650
+ {
3651
+ controls: {
3652
+ footer: {
3653
+ actions: [
3654
+ {
3655
+ label: "Copy PNG",
3656
+ value: "copy",
3657
+ variant: "secondary",
3658
+ },
3659
+ {
3660
+ label: "Export PNG",
3661
+ value: "export",
3662
+ variant: "default",
3663
+ },
3664
+ ],
3665
+ target: "panel.actions",
3666
+ type: "panelActions",
3667
+ },
3668
+ },
3669
+ },
3670
+ ],
3671
+ title: "Generation Controls",
3672
+ },
3673
+ },
3674
+ });
3675
+
3676
+ renderControlsPanelWithSchema(schema);
3677
+
3678
+ expect(screen.getByRole("button", { name: "Copy PNG" }).getAttribute("data-variant")).toBe(
3679
+ "secondary",
3680
+ );
3681
+ expect(screen.getByRole("button", { name: "Export PNG" }).getAttribute("data-variant")).toBe(
3682
+ "default",
3683
+ );
3684
+ });
3685
+
3686
+ it("spans the final odd footer action across the full action row", () => {
3687
+ const schema = defineToolcraft({
3688
+ canvas: { enabled: false },
3689
+ panels: {
3690
+ controls: {
3691
+ sections: [
3692
+ {
3693
+ controls: {
3694
+ footer: {
3695
+ actions: [
3696
+ {
3697
+ label: "Copy PNG",
3698
+ value: "copy",
3699
+ variant: "secondary",
3700
+ },
3701
+ {
3702
+ label: "Export OG",
3703
+ value: "export-og",
3704
+ variant: "secondary",
3705
+ },
3706
+ {
3707
+ label: "Export PNG",
3708
+ value: "export",
3709
+ variant: "default",
3710
+ },
3711
+ ],
3712
+ target: "panel.actions",
3713
+ type: "panelActions",
3714
+ },
3715
+ },
3716
+ },
3717
+ ],
3718
+ title: "Generation Controls",
3719
+ },
3720
+ },
3721
+ });
3722
+
3723
+ renderControlsPanelWithSchema(schema);
3724
+
3725
+ const exportPngButton = screen.getByRole("button", { name: "Export PNG" });
3726
+
3727
+ expect(exportPngButton.className).toContain("col-span-2");
3728
+ });
3729
+
3730
+ it("uses the shared panel host when rendered as a floating panel", () => {
3731
+ const { container } = renderControlsPanel({ panelPlacement: "floating" });
3732
+
3733
+ expect(container.querySelector('[data-panel-type="controls"]')).toBeTruthy();
3734
+ expect(container.querySelector('[data-snap-edges="left right"]')).toBeTruthy();
3735
+ });
3736
+ });