@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,1524 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { toolcraftRuntimeCommandTypes } from "../state/types";
4
+ import {
5
+ defineToolcraft,
6
+ getToolcraftSettingsTransferEligibility,
7
+ } from "./define-toolcraft";
8
+ import {
9
+ toolcraftReservedTargets,
10
+ toolcraftRuntimeOwnedTargets,
11
+ getToolcraftCanvasSizeTargetDimension,
12
+ isToolcraftReservedTarget,
13
+ } from "./runtime-targets";
14
+
15
+ describe("defineToolcraft", () => {
16
+ function createSliderControls(count: number) {
17
+ return Object.fromEntries(
18
+ Array.from({ length: count }, (_, index) => [
19
+ `control${index}`,
20
+ {
21
+ defaultValue: index,
22
+ target: `values.control${index}`,
23
+ type: "slider",
24
+ },
25
+ ]),
26
+ );
27
+ }
28
+
29
+ function createSections(count: number) {
30
+ return Array.from({ length: count }, (_, index) => ({
31
+ controls: {
32
+ opacity: {
33
+ defaultValue: index,
34
+ target: `section${index}.opacity`,
35
+ type: "slider",
36
+ },
37
+ },
38
+ title: `Section ${index + 1}`,
39
+ }));
40
+ }
41
+
42
+ it("defaults enabled canvas to draggable with canvas toolbar features", () => {
43
+ const app = defineToolcraft({
44
+ canvas: { enabled: true },
45
+ panels: {},
46
+ });
47
+
48
+ expect(app.canvas.draggable).toBe(true);
49
+ expect(app.canvas.size).toEqual({ height: 1024, unit: "px", width: 1024 });
50
+ expect(app.canvas.sizeSource).toBe("runtime-default");
51
+ expect(app.canvas.sizing).toEqual({ mode: "intrinsic-media" });
52
+ expect(app.toolbar).toEqual({ history: true, radar: true, theme: true, zoom: true });
53
+ });
54
+
55
+ it("disables app state persistence by default", () => {
56
+ const app = defineToolcraft({
57
+ canvas: { enabled: true },
58
+ panels: {},
59
+ });
60
+
61
+ expect(app.persistence).toEqual({ storage: "none" });
62
+ });
63
+
64
+ it("preserves explicit localStorage persistence policy without writing storage", () => {
65
+ const app = defineToolcraft({
66
+ canvas: { enabled: true },
67
+ panels: {},
68
+ persistence: {
69
+ include: ["values", "canvas", "panels"],
70
+ key: "toolcraft:test:state:v1",
71
+ storage: "localStorage",
72
+ version: 1,
73
+ },
74
+ });
75
+
76
+ expect(app.persistence).toEqual({
77
+ include: ["values", "canvas", "panels"],
78
+ key: "toolcraft:test:state:v1",
79
+ storage: "localStorage",
80
+ version: 1,
81
+ });
82
+ });
83
+
84
+ it("keeps settings transfer disabled for small auto schemas", () => {
85
+ const app = defineToolcraft({
86
+ canvas: { enabled: true, size: { height: 320, unit: "px", width: 320 } },
87
+ panels: {
88
+ controls: {
89
+ sections: [
90
+ {
91
+ controls: {
92
+ opacity: {
93
+ defaultValue: 75,
94
+ label: "Opacity",
95
+ target: "style.opacity",
96
+ type: "slider",
97
+ },
98
+ },
99
+ title: "Style",
100
+ },
101
+ ],
102
+ title: "Mini App",
103
+ },
104
+ },
105
+ });
106
+
107
+ expect(app.settingsTransfer).toMatchObject({
108
+ appId: "mini-app",
109
+ enabled: false,
110
+ fileName: "mini-app-settings.json",
111
+ mode: "auto",
112
+ });
113
+ expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
114
+ expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
115
+ });
116
+
117
+ it("reports settings transfer eligibility from control count, sections, and weighted score", () => {
118
+ const small = getToolcraftSettingsTransferEligibility({
119
+ panels: {
120
+ controls: {
121
+ sections: [
122
+ {
123
+ controls: createSliderControls(1),
124
+ title: "Small",
125
+ },
126
+ ],
127
+ title: "Small App",
128
+ },
129
+ },
130
+ });
131
+ const manyControls = getToolcraftSettingsTransferEligibility({
132
+ panels: {
133
+ controls: {
134
+ sections: [
135
+ {
136
+ controls: createSliderControls(12),
137
+ title: "Many Controls",
138
+ },
139
+ ],
140
+ title: "Many Controls App",
141
+ },
142
+ },
143
+ });
144
+ const manySections = getToolcraftSettingsTransferEligibility({
145
+ panels: {
146
+ controls: {
147
+ sections: createSections(5),
148
+ title: "Many Sections App",
149
+ },
150
+ },
151
+ });
152
+ const heavyControls = getToolcraftSettingsTransferEligibility({
153
+ panels: {
154
+ controls: {
155
+ sections: [
156
+ {
157
+ controls: Object.fromEntries(
158
+ Array.from({ length: 6 }, (_, index) => [
159
+ `gradient${index}`,
160
+ {
161
+ label: `Gradient ${index + 1}`,
162
+ target: `gradient${index}`,
163
+ type: "gradient",
164
+ },
165
+ ]),
166
+ ),
167
+ title: "Gradients",
168
+ },
169
+ ],
170
+ title: "Heavy Controls App",
171
+ },
172
+ },
173
+ });
174
+ const timelineLayersScore = getToolcraftSettingsTransferEligibility({
175
+ panels: {
176
+ controls: {
177
+ sections: [
178
+ {
179
+ controls: Object.fromEntries(
180
+ Array.from({ length: 5 }, (_, index) => [
181
+ `gradient${index}`,
182
+ {
183
+ label: `Gradient ${index + 1}`,
184
+ target: `gradient${index}`,
185
+ type: "gradient",
186
+ },
187
+ ]),
188
+ ),
189
+ title: "Gradients",
190
+ },
191
+ ],
192
+ title: "Timeline Layers App",
193
+ },
194
+ layers: true,
195
+ timeline: { mode: "playback" },
196
+ },
197
+ });
198
+
199
+ expect(small).toEqual({
200
+ controlCount: 1,
201
+ eligible: false,
202
+ reasons: [],
203
+ score: 1,
204
+ sectionCount: 1,
205
+ });
206
+ expect(manyControls).toMatchObject({
207
+ controlCount: 12,
208
+ eligible: true,
209
+ reasons: ["control-count"],
210
+ sectionCount: 1,
211
+ });
212
+ expect(manySections).toMatchObject({
213
+ controlCount: 5,
214
+ eligible: true,
215
+ reasons: ["section-count"],
216
+ sectionCount: 5,
217
+ });
218
+ expect(heavyControls).toMatchObject({
219
+ controlCount: 6,
220
+ eligible: true,
221
+ reasons: ["score"],
222
+ score: 18,
223
+ sectionCount: 1,
224
+ });
225
+ expect(timelineLayersScore).toMatchObject({
226
+ controlCount: 5,
227
+ eligible: true,
228
+ reasons: ["score"],
229
+ score: 19,
230
+ sectionCount: 1,
231
+ });
232
+ });
233
+
234
+ it("ignores runtime canvas size controls in settings transfer eligibility", () => {
235
+ const app = defineToolcraft({
236
+ canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
237
+ panels: {
238
+ controls: {
239
+ sections: [
240
+ {
241
+ controls: createSliderControls(10),
242
+ title: "Product Controls",
243
+ },
244
+ ],
245
+ title: "Runtime Size App",
246
+ },
247
+ },
248
+ settingsTransfer: false,
249
+ });
250
+
251
+ expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
252
+ expect(app.panels.controls?.sections[0]?.controls.canvasHeight).toBeTruthy();
253
+ expect(getToolcraftSettingsTransferEligibility({ panels: app.panels })).toEqual({
254
+ controlCount: 10,
255
+ eligible: false,
256
+ reasons: [],
257
+ score: 10,
258
+ sectionCount: 1,
259
+ });
260
+ });
261
+
262
+ it("injects settings transfer as the first section for complex auto schemas", () => {
263
+ const app = defineToolcraft({
264
+ canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
265
+ panels: {
266
+ controls: {
267
+ sections: [
268
+ {
269
+ controls: {
270
+ prompt: {
271
+ defaultValue: "Prompt",
272
+ label: "Prompt",
273
+ target: "generation.prompt",
274
+ type: "text",
275
+ },
276
+ mask: {
277
+ defaultValue: "Mask",
278
+ label: "Mask",
279
+ target: "generation.mask",
280
+ type: "code",
281
+ },
282
+ font: {
283
+ defaultValue: { fontId: "inter" },
284
+ label: "Font",
285
+ target: "typography.font",
286
+ type: "fontPicker",
287
+ },
288
+ gradient: {
289
+ label: "Gradient",
290
+ target: "style.gradient",
291
+ type: "gradient",
292
+ },
293
+ },
294
+ title: "Text",
295
+ },
296
+ {
297
+ controls: {
298
+ opacity: { defaultValue: 80, target: "style.opacity", type: "slider" },
299
+ blur: { defaultValue: 4, target: "style.blur", type: "slider" },
300
+ threshold: { defaultValue: 50, target: "style.threshold", type: "slider" },
301
+ seed: { defaultValue: 123, target: "style.seed", type: "text" },
302
+ spacing: { defaultValue: 8, target: "style.spacing", type: "slider" },
303
+ density: { defaultValue: 16, target: "style.density", type: "slider" },
304
+ speed: { defaultValue: 1, target: "style.speed", type: "slider" },
305
+ },
306
+ title: "Style",
307
+ },
308
+ {
309
+ controls: {
310
+ anchor: { defaultValue: "center", target: "layout.anchor", type: "anchorGrid" },
311
+ },
312
+ layout: "standalone",
313
+ title: "Layout",
314
+ },
315
+ ],
316
+ title: "Complex App",
317
+ },
318
+ timeline: { mode: "playback" },
319
+ },
320
+ });
321
+
322
+ const [runtimeSettingsSection, firstProductSection] =
323
+ app.panels.controls?.sections ?? [];
324
+
325
+ expect(app.settingsTransfer.enabled).toBe(true);
326
+ expect(runtimeSettingsSection?.title).toBe("Setup");
327
+ expect(Object.keys(runtimeSettingsSection?.controls ?? {})).toEqual([
328
+ "settingsTransfer",
329
+ "canvasWidth",
330
+ "canvasHeight",
331
+ ]);
332
+ expect(runtimeSettingsSection?.controls.canvasWidth).toBeTruthy();
333
+ expect(runtimeSettingsSection?.controls.canvasHeight).toBeTruthy();
334
+ expect(runtimeSettingsSection?.controls.settingsTransfer?.type).toBe(
335
+ "settingsTransfer",
336
+ );
337
+ expect(runtimeSettingsSection?.layout).toBe("standalone");
338
+ expect(runtimeSettingsSection?.layoutGroups).toEqual([
339
+ {
340
+ columns: 2,
341
+ controls: ["canvasWidth", "canvasHeight"],
342
+ layout: "inline",
343
+ },
344
+ ]);
345
+ expect(firstProductSection?.title).toBe("Text");
346
+ });
347
+
348
+ it("respects explicit settings transfer overrides", () => {
349
+ const forced = defineToolcraft({
350
+ canvas: { enabled: false },
351
+ panels: {
352
+ controls: {
353
+ sections: [],
354
+ title: "Tiny Tool",
355
+ },
356
+ },
357
+ settingsTransfer: {
358
+ appId: "Tiny Tool Presets",
359
+ enabled: true,
360
+ fileName: "tiny-presets",
361
+ },
362
+ });
363
+ const disabled = defineToolcraft({
364
+ canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
365
+ panels: {
366
+ controls: {
367
+ sections: [
368
+ {
369
+ controls: Object.fromEntries(
370
+ Array.from({ length: 12 }, (_, index) => [
371
+ `control${index}`,
372
+ {
373
+ defaultValue: index,
374
+ target: `values.control${index}`,
375
+ type: "slider",
376
+ },
377
+ ]),
378
+ ),
379
+ title: "Many Controls",
380
+ },
381
+ ],
382
+ title: "Heavy Tool",
383
+ },
384
+ },
385
+ settingsTransfer: false,
386
+ });
387
+
388
+ expect(forced.settingsTransfer).toMatchObject({
389
+ appId: "tiny-tool-presets",
390
+ enabled: true,
391
+ fileName: "tiny-presets.json",
392
+ });
393
+ expect(forced.panels.controls?.sections[0]?.controls.settingsTransfer?.type).toBe(
394
+ "settingsTransfer",
395
+ );
396
+ expect(disabled.settingsTransfer.enabled).toBe(false);
397
+ expect(
398
+ disabled.panels.controls?.sections.some((section) => section.controls.settingsTransfer),
399
+ ).toBe(false);
400
+ });
401
+
402
+ it("preserves conditional visibility on sections and controls", () => {
403
+ const app = defineToolcraft({
404
+ canvas: { enabled: true },
405
+ panels: {
406
+ controls: {
407
+ sections: [
408
+ {
409
+ controls: {
410
+ partnerLogo: {
411
+ target: "coBrand.partnerLogo",
412
+ type: "fileDrop",
413
+ visibleWhen: {
414
+ equals: "logo",
415
+ target: "coBrand.identityMode",
416
+ },
417
+ },
418
+ },
419
+ title: "Partner Logo",
420
+ visibleWhen: {
421
+ equals: "co-brand",
422
+ target: "cover.templateId",
423
+ },
424
+ },
425
+ ],
426
+ title: "Controls",
427
+ },
428
+ },
429
+ });
430
+
431
+ const [section] = app.panels.controls?.sections ?? [];
432
+
433
+ expect(section?.visibleWhen).toEqual({
434
+ equals: "co-brand",
435
+ target: "cover.templateId",
436
+ });
437
+ expect(section?.controls.partnerLogo?.visibleWhen).toEqual({
438
+ equals: "logo",
439
+ target: "coBrand.identityMode",
440
+ });
441
+ });
442
+
443
+ it("requires panel persistence when localStorage apps render draggable runtime panels", () => {
444
+ expect(() =>
445
+ defineToolcraft({
446
+ canvas: { enabled: true },
447
+ panels: {
448
+ controls: {
449
+ sections: [
450
+ {
451
+ controls: {
452
+ opacity: {
453
+ defaultValue: 75,
454
+ target: "selectedLayer.opacity",
455
+ type: "slider",
456
+ },
457
+ },
458
+ },
459
+ ],
460
+ title: "Controls",
461
+ },
462
+ },
463
+ persistence: {
464
+ include: ["values"],
465
+ key: "toolcraft:panel-test:state:v1",
466
+ storage: "localStorage",
467
+ version: 1,
468
+ },
469
+ }),
470
+ ).toThrow(
471
+ 'Toolcraft apps with visible runtime panels and localStorage persistence must include "panels" so dragged panel positions survive reload.',
472
+ );
473
+ });
474
+
475
+ it("derives an AI assembly contract from app surfaces", () => {
476
+ const app = defineToolcraft({
477
+ canvas: { enabled: true, upload: true },
478
+ panels: {
479
+ controls: {
480
+ sections: [
481
+ {
482
+ controls: {
483
+ opacity: {
484
+ defaultValue: 75,
485
+ target: "selectedLayer.opacity",
486
+ type: "slider",
487
+ },
488
+ },
489
+ },
490
+ ],
491
+ title: "Controls",
492
+ },
493
+ layers: true,
494
+ timeline: true,
495
+ },
496
+ });
497
+
498
+ expect(app.assembly.components).toEqual([
499
+ "canvas",
500
+ "controlsPanel",
501
+ "layersPanel",
502
+ "timelinePanel",
503
+ "toolbar",
504
+ ]);
505
+ expect(app.assembly.capabilities).toEqual(
506
+ expect.arrayContaining([
507
+ "canvas.draggable",
508
+ "canvas.upload",
509
+ "controls.defaults",
510
+ "layers.selection",
511
+ "timeline.keyframes",
512
+ "toolbar.history",
513
+ "toolbar.radar",
514
+ "toolbar.theme",
515
+ "toolbar.zoom",
516
+ ]),
517
+ );
518
+ expect(app.assembly.commands).toEqual(
519
+ expect.arrayContaining([
520
+ "controls.reset",
521
+ "history.undo",
522
+ "media.delete",
523
+ "media.import",
524
+ "layers.reorder",
525
+ "timeline.setCurrentTime",
526
+ "canvas.center",
527
+ "canvas.setViewport",
528
+ ]),
529
+ );
530
+ expect(app.assembly.surfaces.panels.layers?.defaultPlacement).toBe("left");
531
+ expect(app.assembly.surfaces.panels.timeline?.snapEdges).toEqual(["top", "bottom"]);
532
+ expect(app.panels.timeline).toEqual({ enabled: true, mode: "keyframes" });
533
+ expect(app.assembly.surfaces.panels.toolbar.enabled).toBe(true);
534
+ });
535
+
536
+ it("supports playback-only timeline apps without keyframe editing commands", () => {
537
+ const app = defineToolcraft({
538
+ canvas: { enabled: true },
539
+ panels: {
540
+ timeline: { mode: "playback" },
541
+ },
542
+ });
543
+
544
+ expect(app.panels.timeline).toEqual({ enabled: true, mode: "playback" });
545
+ expect(app.assembly.components).toContain("timelinePanel");
546
+ expect(app.assembly.capabilities).toEqual(
547
+ expect.arrayContaining([
548
+ "timeline.duration",
549
+ "timeline.panel",
550
+ "timeline.playback",
551
+ ]),
552
+ );
553
+ expect(app.assembly.capabilities).not.toContain("timeline.keyframes");
554
+ expect(app.assembly.commands).toEqual(
555
+ expect.arrayContaining([
556
+ "timeline.setCurrentTime",
557
+ "timeline.setDuration",
558
+ "timeline.setPlaying",
559
+ "timeline.toggleLoop",
560
+ "timeline.togglePlayback",
561
+ ]),
562
+ );
563
+ expect(app.assembly.commands).not.toContain("timeline.toggleExpanded");
564
+ expect(app.assembly.commands).not.toContain("timeline.toggleControlKeyframes");
565
+ expect(app.assembly.commands).not.toContain("timeline.moveKeyframe");
566
+ });
567
+
568
+ it("only publishes commands supported by the runtime command bus", () => {
569
+ const app = defineToolcraft({
570
+ canvas: { enabled: true, upload: true },
571
+ panels: {
572
+ controls: {
573
+ sections: [
574
+ {
575
+ controls: {
576
+ opacity: {
577
+ defaultValue: 75,
578
+ target: "selectedLayer.opacity",
579
+ type: "slider",
580
+ },
581
+ },
582
+ },
583
+ ],
584
+ title: "Controls",
585
+ },
586
+ layers: true,
587
+ timeline: true,
588
+ },
589
+ });
590
+ const supportedCommands = new Set<string>(toolcraftRuntimeCommandTypes);
591
+
592
+ expect(app.assembly.commands.filter((command) => !supportedCommands.has(command))).toEqual([]);
593
+ });
594
+
595
+ it("publishes reserved targets for AI assembly boundaries", () => {
596
+ expect(toolcraftRuntimeOwnedTargets).toEqual([
597
+ "canvas.size.width",
598
+ "canvas.size.height",
599
+ ]);
600
+ expect(toolcraftReservedTargets).toEqual([
601
+ "canvas.size.width",
602
+ "canvas.size.height",
603
+ "selectedLayer.opacity",
604
+ "selectedLayer.visible",
605
+ ]);
606
+ expect(getToolcraftCanvasSizeTargetDimension("canvas.size.width")).toBe("width");
607
+ expect(getToolcraftCanvasSizeTargetDimension("canvas.size.height")).toBe("height");
608
+ expect(getToolcraftCanvasSizeTargetDimension("generation.prompt")).toBeNull();
609
+ expect(isToolcraftReservedTarget("selectedLayer.opacity")).toBe(true);
610
+ expect(isToolcraftReservedTarget("generation.prompt")).toBe(false);
611
+ });
612
+
613
+ it("removes disabled toolbar capabilities from the assembly contract", () => {
614
+ const app = defineToolcraft({
615
+ canvas: { enabled: true },
616
+ panels: {},
617
+ toolbar: {
618
+ history: false,
619
+ radar: false,
620
+ theme: false,
621
+ zoom: false,
622
+ },
623
+ });
624
+
625
+ expect(app.assembly.components).toEqual(["canvas"]);
626
+ expect(app.assembly.commands).not.toContain("history.undo");
627
+ expect(app.assembly.commands).not.toContain("canvas.center");
628
+ expect(app.assembly.commands).not.toContain("canvas.zoomIn");
629
+ expect(app.assembly.surfaces.panels.toolbar.enabled).toBe(false);
630
+ });
631
+
632
+ it("does not enable the layers panel implicitly for single-layer or upload apps", () => {
633
+ const app = defineToolcraft({
634
+ canvas: { enabled: true, upload: true },
635
+ panels: {
636
+ controls: {
637
+ sections: [
638
+ {
639
+ controls: {
640
+ opacity: {
641
+ defaultValue: 75,
642
+ target: "selectedLayer.opacity",
643
+ type: "slider",
644
+ },
645
+ },
646
+ },
647
+ ],
648
+ title: "Controls",
649
+ },
650
+ },
651
+ });
652
+
653
+ expect(app.panels.layers).toBeUndefined();
654
+ expect(app.assembly.components).not.toContain("layersPanel");
655
+ expect(app.assembly.capabilities).not.toContain("layers.panel");
656
+ expect(app.assembly.commands).not.toContain("layers.reorder");
657
+ });
658
+
659
+ it("defaults explicit canvas size to editable output", () => {
660
+ const size = { width: 1365, height: 768, unit: "px" } as const;
661
+
662
+ const app = defineToolcraft({
663
+ canvas: { enabled: true, size },
664
+ panels: {
665
+ controls: {
666
+ sections: [],
667
+ title: "Controls",
668
+ },
669
+ },
670
+ });
671
+
672
+ expect(app.canvas.size).toBe(size);
673
+ expect(app.canvas.size).toEqual({ width: 1365, height: 768, unit: "px" });
674
+ expect(app.canvas.sizeSource).toBe("app");
675
+ expect(app.canvas.sizing).toEqual({ mode: "editable-output" });
676
+ expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
677
+ expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toMatchObject({
678
+ defaultValue: 1365,
679
+ performanceRole: "workload",
680
+ target: "canvas.size.width",
681
+ });
682
+ expect(app.panels.controls?.sections[0]?.controls.canvasHeight).toMatchObject({
683
+ defaultValue: 768,
684
+ performanceRole: "workload",
685
+ target: "canvas.size.height",
686
+ });
687
+ });
688
+
689
+ it("preserves app-chosen canvas widths below the app shell minimum", () => {
690
+ const app = defineToolcraft({
691
+ canvas: { enabled: true, size: { width: 640, height: 768, unit: "px" } },
692
+ panels: {},
693
+ });
694
+
695
+ expect(app.canvas.size).toEqual({ width: 640, height: 768, unit: "px" });
696
+ expect(app.canvas.sizeSource).toBe("app");
697
+ });
698
+
699
+ it("prepends canvas size controls to controls panels", () => {
700
+ const app = defineToolcraft({
701
+ canvas: {
702
+ enabled: true,
703
+ size: { height: 900, unit: "px", width: 1440 },
704
+ sizing: { mode: "editable-output" },
705
+ },
706
+ panels: {
707
+ controls: {
708
+ sections: [
709
+ {
710
+ controls: {
711
+ prompt: {
712
+ target: "generation.prompt",
713
+ type: "text",
714
+ },
715
+ },
716
+ title: "Generation",
717
+ },
718
+ ],
719
+ title: "Controls",
720
+ },
721
+ },
722
+ });
723
+
724
+ const [canvasSection, generationSection] = app.panels.controls?.sections ?? [];
725
+
726
+ expect(canvasSection?.title).toBe("Setup");
727
+ expect(canvasSection?.controls.canvasWidth).toMatchObject({
728
+ defaultValue: 1440,
729
+ label: "Canvas width",
730
+ performanceRole: "workload",
731
+ target: "canvas.size.width",
732
+ type: "text",
733
+ });
734
+ expect(canvasSection?.controls.canvasHeight).toMatchObject({
735
+ defaultValue: 900,
736
+ label: "Canvas height",
737
+ performanceRole: "workload",
738
+ target: "canvas.size.height",
739
+ type: "text",
740
+ });
741
+ expect(canvasSection?.layoutGroups).toEqual([
742
+ {
743
+ columns: 2,
744
+ controls: ["canvasWidth", "canvasHeight"],
745
+ layout: "inline",
746
+ },
747
+ ]);
748
+ expect(generationSection?.title).toBe("Generation");
749
+ });
750
+
751
+ it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
752
+ const intrinsicApp = defineToolcraft({
753
+ canvas: {
754
+ enabled: true,
755
+ upload: true,
756
+ },
757
+ panels: {
758
+ controls: {
759
+ sections: [
760
+ {
761
+ controls: {
762
+ prompt: {
763
+ target: "generation.prompt",
764
+ type: "text",
765
+ },
766
+ },
767
+ title: "Generation",
768
+ },
769
+ ],
770
+ title: "Controls",
771
+ },
772
+ },
773
+ });
774
+ const fixedApp = defineToolcraft({
775
+ canvas: {
776
+ enabled: true,
777
+ size: { height: 900, unit: "px", width: 1440 },
778
+ sizing: { mode: "fixed-output" },
779
+ },
780
+ panels: intrinsicApp.panels,
781
+ });
782
+
783
+ expect(intrinsicApp.canvas.sizing).toEqual({ mode: "intrinsic-media" });
784
+ expect(fixedApp.canvas.sizing).toEqual({ mode: "fixed-output" });
785
+ expect(intrinsicApp.panels.controls?.sections[0]?.title).toBe("Generation");
786
+ expect(fixedApp.panels.controls?.sections[0]?.title).toBe("Generation");
787
+ expect(intrinsicApp.panels.controls?.sections[0]?.controls.canvasWidth).toBeUndefined();
788
+ expect(fixedApp.panels.controls?.sections[0]?.controls.canvasWidth).toBeUndefined();
789
+ });
790
+
791
+ it("adds runtime layout groups only for compact numeric text pairs", () => {
792
+ const app = defineToolcraft({
793
+ canvas: { enabled: false },
794
+ panels: {
795
+ controls: {
796
+ sections: [
797
+ {
798
+ controls: {
799
+ format: {
800
+ defaultValue: "png",
801
+ label: "Format",
802
+ options: [
803
+ { label: "PNG", value: "png" },
804
+ { label: "JPEG", value: "jpeg" },
805
+ ],
806
+ target: "export.format",
807
+ type: "select",
808
+ },
809
+ quality: {
810
+ defaultValue: "balanced",
811
+ label: "Quality",
812
+ options: [
813
+ { label: "Balanced", value: "balanced" },
814
+ { label: "High", value: "high" },
815
+ ],
816
+ target: "export.quality",
817
+ type: "select",
818
+ },
819
+ width: {
820
+ defaultValue: "1024",
821
+ label: "Width",
822
+ target: "export.width",
823
+ type: "text",
824
+ },
825
+ height: {
826
+ defaultValue: "768",
827
+ label: "Height",
828
+ target: "export.height",
829
+ type: "text",
830
+ },
831
+ prompt: {
832
+ defaultValue: "Describe the generated asset",
833
+ label: "Prompt",
834
+ target: "generation.prompt",
835
+ type: "text",
836
+ },
837
+ seed: {
838
+ defaultValue: "42",
839
+ label: "Seed",
840
+ target: "generation.seed",
841
+ type: "text",
842
+ },
843
+ },
844
+ title: "Output",
845
+ },
846
+ ],
847
+ title: "Controls",
848
+ },
849
+ },
850
+ });
851
+
852
+ const outputSection = app.panels.controls?.sections[0];
853
+
854
+ expect(outputSection?.layoutGroups).toEqual([
855
+ {
856
+ columns: 2,
857
+ controls: ["width", "height"],
858
+ layout: "inline",
859
+ },
860
+ ]);
861
+ });
862
+
863
+ it("adds runtime layout groups for compact numeric and color opacity pairs", () => {
864
+ const app = defineToolcraft({
865
+ canvas: { enabled: false },
866
+ panels: {
867
+ controls: {
868
+ sections: [
869
+ {
870
+ controls: {
871
+ maskSize: {
872
+ defaultValue: "180",
873
+ label: "Mask size",
874
+ target: "mask.size",
875
+ type: "text",
876
+ },
877
+ maskColor: {
878
+ defaultValue: { hex: "#0EA5E9", opacity: 82 },
879
+ label: "Color",
880
+ target: "mask.color",
881
+ type: "colorOpacity",
882
+ },
883
+ },
884
+ title: "Mask",
885
+ },
886
+ ],
887
+ title: "Controls",
888
+ },
889
+ },
890
+ });
891
+
892
+ const maskSection = app.panels.controls?.sections[0];
893
+
894
+ expect(maskSection?.layoutGroups).toEqual([
895
+ {
896
+ columns: 2,
897
+ controls: ["maskSize", "maskColor"],
898
+ layout: "inline",
899
+ },
900
+ ]);
901
+ });
902
+
903
+ it("does not auto-pair mixed compact fields without visible labels", () => {
904
+ const app = defineToolcraft({
905
+ canvas: { enabled: false },
906
+ panels: {
907
+ controls: {
908
+ sections: [
909
+ {
910
+ controls: {
911
+ maskSize: {
912
+ defaultValue: "180",
913
+ label: "Mask size",
914
+ target: "mask.size",
915
+ type: "text",
916
+ },
917
+ maskColor: {
918
+ defaultValue: { hex: "#0EA5E9", opacity: 82 },
919
+ target: "mask.color",
920
+ type: "colorOpacity",
921
+ },
922
+ },
923
+ title: "Mask",
924
+ },
925
+ ],
926
+ title: "Controls",
927
+ },
928
+ },
929
+ });
930
+
931
+ expect(app.panels.controls?.sections[0]?.layoutGroups).toBeUndefined();
932
+ });
933
+
934
+ it("preserves explicit inline select combinations without auto-pairing ordinary selects", () => {
935
+ const app = defineToolcraft({
936
+ canvas: { enabled: false },
937
+ panels: {
938
+ controls: {
939
+ sections: [
940
+ {
941
+ controls: {
942
+ colorSpace: {
943
+ defaultValue: "srgb",
944
+ label: "Color space",
945
+ options: [
946
+ { label: "sRGB", value: "srgb" },
947
+ { label: "Display P3", value: "display-p3" },
948
+ ],
949
+ target: "export.colorSpace",
950
+ type: "select",
951
+ },
952
+ bitDepth: {
953
+ defaultValue: "8",
954
+ label: "Bit depth",
955
+ options: [
956
+ { label: "8-bit", value: "8" },
957
+ { label: "16-bit", value: "16" },
958
+ ],
959
+ target: "export.bitDepth",
960
+ type: "select",
961
+ },
962
+ },
963
+ layoutGroups: [
964
+ {
965
+ columns: 2,
966
+ controls: ["colorSpace", "bitDepth"],
967
+ layout: "inline",
968
+ },
969
+ ],
970
+ title: "Format Pair",
971
+ },
972
+ {
973
+ controls: {
974
+ format: {
975
+ defaultValue: "png",
976
+ label: "Format",
977
+ options: [
978
+ { label: "PNG", value: "png" },
979
+ { label: "JPEG", value: "jpeg" },
980
+ ],
981
+ target: "export.format",
982
+ type: "select",
983
+ },
984
+ quality: {
985
+ defaultValue: "balanced",
986
+ label: "Quality",
987
+ options: [
988
+ { label: "Balanced", value: "balanced" },
989
+ { label: "High", value: "high" },
990
+ ],
991
+ target: "export.quality",
992
+ type: "select",
993
+ },
994
+ },
995
+ title: "Output",
996
+ },
997
+ ],
998
+ title: "Controls",
999
+ },
1000
+ },
1001
+ });
1002
+
1003
+ const [formatPairSection, outputSection] = app.panels.controls?.sections ?? [];
1004
+
1005
+ expect(formatPairSection?.layoutGroups).toEqual([
1006
+ {
1007
+ columns: 2,
1008
+ controls: ["colorSpace", "bitDepth"],
1009
+ layout: "inline",
1010
+ },
1011
+ ]);
1012
+ expect(outputSection?.layoutGroups).toBeUndefined();
1013
+ });
1014
+
1015
+ it("splits standalone controls out of grouped control sections", () => {
1016
+ const app = defineToolcraft({
1017
+ canvas: { enabled: false },
1018
+ panels: {
1019
+ controls: {
1020
+ sections: [
1021
+ {
1022
+ controls: {
1023
+ opacity: {
1024
+ defaultValue: 75,
1025
+ label: "Opacity",
1026
+ target: "selectedLayer.opacity",
1027
+ type: "slider",
1028
+ },
1029
+ threshold: {
1030
+ defaultValue: 50,
1031
+ label: "Threshold",
1032
+ target: "selectedLayer.threshold",
1033
+ type: "slider",
1034
+ },
1035
+ anchor: {
1036
+ defaultValue: "center",
1037
+ label: "Anchor",
1038
+ target: "selectedLayer.anchor",
1039
+ type: "anchorGrid",
1040
+ },
1041
+ },
1042
+ title: "Basic",
1043
+ },
1044
+ ],
1045
+ title: "Controls",
1046
+ },
1047
+ },
1048
+ });
1049
+
1050
+ const [basicSection, anchorSection] = app.panels.controls?.sections ?? [];
1051
+
1052
+ expect(Object.keys(basicSection?.controls ?? {})).toEqual(["opacity", "threshold"]);
1053
+ expect(basicSection?.title).toBe("Basic");
1054
+ expect(anchorSection?.layout).toBe("standalone");
1055
+ expect(anchorSection?.title).toBe("Anchor");
1056
+ expect(Object.keys(anchorSection?.controls ?? {})).toEqual(["anchor"]);
1057
+ });
1058
+
1059
+ it("forces disabled canvas to non-draggable while preserving explicit toolbar choices", () => {
1060
+ const app = defineToolcraft({
1061
+ canvas: { draggable: true, enabled: false },
1062
+ panels: {},
1063
+ toolbar: { history: true },
1064
+ });
1065
+
1066
+ expect(app.canvas.draggable).toBe(false);
1067
+ expect(app.toolbar).toEqual({ history: true, radar: false, theme: true, zoom: false });
1068
+ });
1069
+
1070
+ it("adds an implicit title while preserving standalone section layout intent", () => {
1071
+ const sections = [
1072
+ {
1073
+ controls: {
1074
+ palette: {
1075
+ target: "style.palette",
1076
+ type: "palette",
1077
+ },
1078
+ },
1079
+ layout: "standalone",
1080
+ },
1081
+ ] as const;
1082
+
1083
+ const app = defineToolcraft({
1084
+ canvas: { enabled: true },
1085
+ panels: {
1086
+ controls: {
1087
+ sections,
1088
+ title: "Controls",
1089
+ },
1090
+ },
1091
+ });
1092
+
1093
+ expect(app.panels.controls?.sections[0]?.layout).toBe("standalone");
1094
+ expect(app.panels.controls?.sections[0]?.title).toBe("Palette");
1095
+ });
1096
+
1097
+ it("adds semantic implicit titles to standalone color sections", () => {
1098
+ const app = defineToolcraft({
1099
+ canvas: { enabled: true },
1100
+ panels: {
1101
+ controls: {
1102
+ sections: [
1103
+ {
1104
+ controls: {
1105
+ fill: {
1106
+ defaultValue: { hex: "#C1FF00" },
1107
+ label: "Fill",
1108
+ target: "style.fill",
1109
+ type: "color",
1110
+ },
1111
+ stroke: {
1112
+ defaultValue: { hex: "#FF6A00" },
1113
+ label: "Stroke",
1114
+ target: "style.stroke",
1115
+ type: "color",
1116
+ },
1117
+ },
1118
+ layout: "standalone",
1119
+ },
1120
+ ],
1121
+ title: "Controls",
1122
+ },
1123
+ },
1124
+ });
1125
+
1126
+ expect(app.panels.controls?.sections[0]?.layout).toBe("standalone");
1127
+ expect(app.panels.controls?.sections[0]?.title).toBe("Fill & Stroke");
1128
+
1129
+ const inferredLayoutApp = defineToolcraft({
1130
+ canvas: { enabled: true },
1131
+ panels: {
1132
+ controls: {
1133
+ sections: [
1134
+ {
1135
+ controls: {
1136
+ fill: {
1137
+ defaultValue: { hex: "#C1FF00" },
1138
+ label: "Fill",
1139
+ target: "style.fill",
1140
+ type: "color",
1141
+ },
1142
+ },
1143
+ },
1144
+ ],
1145
+ title: "Controls",
1146
+ },
1147
+ },
1148
+ });
1149
+
1150
+ expect(inferredLayoutApp.panels.controls?.sections[0]?.title).toBe("Fill");
1151
+ });
1152
+
1153
+ it("replaces generic color section titles with a semantic fallback", () => {
1154
+ const genericColorApp = defineToolcraft({
1155
+ canvas: { enabled: true },
1156
+ panels: {
1157
+ controls: {
1158
+ sections: [
1159
+ {
1160
+ controls: {
1161
+ color: {
1162
+ defaultValue: { hex: "#C1FF00" },
1163
+ label: "Color",
1164
+ target: "style.color",
1165
+ type: "color",
1166
+ },
1167
+ },
1168
+ layout: "standalone",
1169
+ },
1170
+ ],
1171
+ title: "Controls",
1172
+ },
1173
+ },
1174
+ });
1175
+
1176
+ const genericColorsApp = defineToolcraft({
1177
+ canvas: { enabled: true },
1178
+ panels: {
1179
+ controls: {
1180
+ sections: [
1181
+ {
1182
+ controls: {
1183
+ color: {
1184
+ defaultValue: { hex: "#C1FF00" },
1185
+ label: "Color",
1186
+ target: "style.color",
1187
+ type: "color",
1188
+ },
1189
+ colors: {
1190
+ defaultValue: { hex: "#FF6A00" },
1191
+ label: "Colors",
1192
+ target: "style.colors",
1193
+ type: "color",
1194
+ },
1195
+ },
1196
+ layout: "standalone",
1197
+ },
1198
+ ],
1199
+ title: "Controls",
1200
+ },
1201
+ },
1202
+ });
1203
+ const explicitGenericTitleApp = defineToolcraft({
1204
+ canvas: { enabled: true },
1205
+ panels: {
1206
+ controls: {
1207
+ sections: [
1208
+ {
1209
+ controls: {
1210
+ accent: {
1211
+ defaultValue: { hex: "#C1FF00" },
1212
+ label: "Accent",
1213
+ target: "style.accent",
1214
+ type: "color",
1215
+ },
1216
+ },
1217
+ layout: "standalone",
1218
+ title: "Colors",
1219
+ },
1220
+ ],
1221
+ title: "Controls",
1222
+ },
1223
+ },
1224
+ });
1225
+
1226
+ expect(genericColorApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1227
+ expect(genericColorsApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1228
+ expect(explicitGenericTitleApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1229
+ });
1230
+
1231
+ it("keeps color controls inside semantic mixed sections", () => {
1232
+ const app = defineToolcraft({
1233
+ canvas: { enabled: true },
1234
+ panels: {
1235
+ controls: {
1236
+ sections: [
1237
+ {
1238
+ controls: {
1239
+ connections: {
1240
+ defaultValue: 10,
1241
+ label: "Connections",
1242
+ target: "animation.square1.connections",
1243
+ type: "text",
1244
+ },
1245
+ hoverRadius: {
1246
+ defaultValue: 200,
1247
+ label: "Hover radius",
1248
+ max: 500,
1249
+ min: 50,
1250
+ target: "animation.square1.hoverRadius",
1251
+ type: "slider",
1252
+ unit: "px",
1253
+ },
1254
+ color: {
1255
+ defaultValue: { hex: "#DEF135" },
1256
+ label: "Color",
1257
+ target: "animation.square1.color",
1258
+ type: "color",
1259
+ },
1260
+ },
1261
+ title: "Square 1 (Right)",
1262
+ },
1263
+ ],
1264
+ title: "Controls",
1265
+ },
1266
+ },
1267
+ });
1268
+
1269
+ expect(app.panels.controls?.sections).toHaveLength(1);
1270
+ expect(app.panels.controls?.sections[0]?.title).toBe("Square 1 (Right)");
1271
+ expect(Object.keys(app.panels.controls?.sections[0]?.controls ?? {})).toEqual([
1272
+ "connections",
1273
+ "hoverRadius",
1274
+ "color",
1275
+ ]);
1276
+ });
1277
+
1278
+ it("preserves controls panel action sections and render metadata", () => {
1279
+ const app = defineToolcraft({
1280
+ canvas: { enabled: true },
1281
+ panels: {
1282
+ controls: {
1283
+ sections: [
1284
+ {
1285
+ actionGroup: "secondary",
1286
+ controls: {
1287
+ footer: {
1288
+ actions: [
1289
+ {
1290
+ command: "controls.reset",
1291
+ label: "Reset",
1292
+ value: "reset",
1293
+ variant: "outline",
1294
+ },
1295
+ {
1296
+ command: "controls.apply",
1297
+ label: "Apply",
1298
+ value: "apply",
1299
+ variant: "default",
1300
+ },
1301
+ ],
1302
+ target: "panel.actions",
1303
+ type: "panelActions",
1304
+ },
1305
+ },
1306
+ layout: "standalone",
1307
+ },
1308
+ {
1309
+ controls: {
1310
+ opacity: {
1311
+ defaultValue: 75,
1312
+ markerCount: 11,
1313
+ max: 100,
1314
+ min: 0,
1315
+ step: 1,
1316
+ target: "selectedLayer.opacity",
1317
+ type: "slider",
1318
+ unit: "%",
1319
+ variant: "discrete",
1320
+ },
1321
+ },
1322
+ title: "Sliders",
1323
+ },
1324
+ ],
1325
+ title: "Controls",
1326
+ },
1327
+ },
1328
+ });
1329
+
1330
+ const [slidersSection, actionsSection] = app.panels.controls?.sections ?? [];
1331
+
1332
+ expect(slidersSection?.controls.opacity?.markerCount).toBe(101);
1333
+ expect(slidersSection?.controls.opacity?.variant).toBe("discrete");
1334
+ expect(actionsSection?.actionGroup).toBe("secondary");
1335
+ expect(actionsSection?.controls.footer?.actions?.[0]).toMatchObject({
1336
+ command: "controls.reset",
1337
+ value: "reset",
1338
+ });
1339
+ });
1340
+
1341
+ it("keeps stepped continuous sliders plain and normalizes explicit discrete markers", () => {
1342
+ const app = defineToolcraft({
1343
+ canvas: { enabled: true },
1344
+ panels: {
1345
+ controls: {
1346
+ sections: [
1347
+ {
1348
+ controls: {
1349
+ speed: {
1350
+ defaultValue: 118,
1351
+ label: "Reveal speed",
1352
+ markerCount: 6,
1353
+ max: 150,
1354
+ min: 0,
1355
+ step: 1,
1356
+ target: "ascii.speed",
1357
+ type: "slider",
1358
+ },
1359
+ toneSteps: {
1360
+ defaultValue: 2,
1361
+ label: "Tone steps",
1362
+ markerCount: 4,
1363
+ max: 4,
1364
+ min: 0,
1365
+ step: 1,
1366
+ target: "ascii.toneSteps",
1367
+ type: "slider",
1368
+ variant: "discrete",
1369
+ },
1370
+ duration: {
1371
+ defaultValue: 0.6,
1372
+ label: "Flip duration",
1373
+ markerCount: 6,
1374
+ max: 5,
1375
+ min: 0,
1376
+ step: 0.1,
1377
+ target: "ascii.flipDurationSec",
1378
+ type: "slider",
1379
+ variant: "continuous",
1380
+ },
1381
+ range: {
1382
+ defaultValue: [2, 8],
1383
+ label: "Range",
1384
+ markerCount: 3,
1385
+ max: 10,
1386
+ min: 0,
1387
+ step: 0.5,
1388
+ target: "ascii.range",
1389
+ type: "rangeSlider",
1390
+ },
1391
+ },
1392
+ title: "Sliders",
1393
+ },
1394
+ ],
1395
+ title: "Controls",
1396
+ },
1397
+ },
1398
+ });
1399
+
1400
+ const controls = app.panels.controls?.sections[0]?.controls;
1401
+
1402
+ expect(controls?.speed?.variant).toBeUndefined();
1403
+ expect(controls?.speed?.markerCount).toBe(6);
1404
+ expect(controls?.toneSteps?.variant).toBe("discrete");
1405
+ expect(controls?.toneSteps?.markerCount).toBe(5);
1406
+ expect(controls?.duration?.variant).toBe("continuous");
1407
+ expect(controls?.duration?.markerCount).toBe(6);
1408
+ expect(controls?.range?.variant).toBeUndefined();
1409
+ expect(controls?.range?.markerCount).toBe(3);
1410
+ });
1411
+
1412
+ it("hoists panel action controls into the sticky footer even without actionGroup", () => {
1413
+ const app = defineToolcraft({
1414
+ canvas: { enabled: true },
1415
+ panels: {
1416
+ controls: {
1417
+ sections: [
1418
+ {
1419
+ controls: {
1420
+ footer: {
1421
+ actions: [
1422
+ {
1423
+ command: "controls.reset",
1424
+ label: "Reset",
1425
+ value: "reset",
1426
+ variant: "outline",
1427
+ },
1428
+ {
1429
+ command: "controls.apply",
1430
+ label: "Apply",
1431
+ value: "apply",
1432
+ variant: "default",
1433
+ },
1434
+ ],
1435
+ target: "panel.actions",
1436
+ type: "panelActions",
1437
+ },
1438
+ prompt: {
1439
+ defaultValue: "Describe the output",
1440
+ label: "Prompt",
1441
+ target: "generation.prompt",
1442
+ type: "text",
1443
+ },
1444
+ },
1445
+ title: "Export",
1446
+ },
1447
+ ],
1448
+ title: "Controls",
1449
+ },
1450
+ },
1451
+ });
1452
+
1453
+ const sections = app.panels.controls?.sections ?? [];
1454
+ const promptSection = sections.find((section) => section.controls.prompt);
1455
+ const footerSection = sections.at(-1);
1456
+
1457
+ expect(promptSection?.title).toBe("Prompt");
1458
+ expect(promptSection?.controls.footer).toBeUndefined();
1459
+ expect(footerSection?.actionGroup).toBe("secondary");
1460
+ expect(footerSection?.layout).toBe("standalone");
1461
+ expect(footerSection?.title).toBe("Export");
1462
+ expect(footerSection?.controls.footer?.type).toBe("panelActions");
1463
+ });
1464
+
1465
+ it("merges split footer action sections into one compact footer row", () => {
1466
+ const app = defineToolcraft({
1467
+ canvas: { enabled: true },
1468
+ panels: {
1469
+ controls: {
1470
+ sections: [
1471
+ {
1472
+ actionGroup: "primary",
1473
+ controls: {
1474
+ export: {
1475
+ actions: [
1476
+ {
1477
+ label: "Export PNG",
1478
+ value: "export",
1479
+ variant: "default",
1480
+ },
1481
+ ],
1482
+ target: "panel.export",
1483
+ type: "panelActions",
1484
+ },
1485
+ },
1486
+ },
1487
+ {
1488
+ actionGroup: "secondary",
1489
+ controls: {
1490
+ copy: {
1491
+ actions: [
1492
+ {
1493
+ label: "Copy PNG",
1494
+ value: "copy",
1495
+ variant: "outline",
1496
+ },
1497
+ ],
1498
+ target: "panel.copy",
1499
+ type: "panelActions",
1500
+ },
1501
+ },
1502
+ },
1503
+ ],
1504
+ title: "Controls",
1505
+ },
1506
+ },
1507
+ });
1508
+
1509
+ const sections = app.panels.controls?.sections ?? [];
1510
+ const footerSections = sections.filter((section) => section.actionGroup);
1511
+ const footerActions = footerSections[0]?.controls.footer?.actions ?? [];
1512
+
1513
+ expect(footerSections).toHaveLength(1);
1514
+ expect(footerActions).toHaveLength(2);
1515
+ expect(footerActions[0]).toMatchObject({
1516
+ value: "copy",
1517
+ variant: "outline",
1518
+ });
1519
+ expect(footerActions[1]).toMatchObject({
1520
+ value: "export",
1521
+ variant: "default",
1522
+ });
1523
+ });
1524
+ });