@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,1442 @@
1
+ import { TOOLCRAFT_COMPONENT_CONTRACTS } from "../contracts/component-contracts";
2
+ import type {
3
+ ToolcraftAssemblyCapability,
4
+ ToolcraftAssemblyCommand,
5
+ ToolcraftAssemblyComponentId,
6
+ ToolcraftAssemblyContract,
7
+ ToolcraftAssemblyPanelContract,
8
+ ToolcraftAppSchema,
9
+ ToolcraftControlLayoutGroupSchema,
10
+ ToolcraftControlSectionSchema,
11
+ ToolcraftCanvasSize,
12
+ ToolcraftCanvasSizingSchema,
13
+ ToolcraftControlSchema,
14
+ ToolcraftControlsPanelSchema,
15
+ ToolcraftSettingsTransferSchema,
16
+ ToolcraftTimelinePanelSchema,
17
+ ToolcraftToolbarSchema,
18
+ ResolvedToolcraftPanelsSchema,
19
+ ResolvedToolcraftSettingsTransferSchema,
20
+ ResolvedToolcraftTimelinePanelSchema,
21
+ ResolvedToolcraftAppSchema,
22
+ } from "./types";
23
+
24
+ const defaultCanvasSize = {
25
+ height: 1024,
26
+ unit: "px",
27
+ width: 1024,
28
+ } satisfies ToolcraftCanvasSize;
29
+
30
+ type ResolvedCanvas = ResolvedToolcraftAppSchema["canvas"];
31
+ type ResolvedExport = ResolvedToolcraftAppSchema["export"];
32
+ type ResolvedToolbar = Required<ToolcraftToolbarSchema>;
33
+ type PanelContract = {
34
+ capabilities?: readonly string[];
35
+ defaultPlacement: ToolcraftAssemblyPanelContract["defaultPlacement"];
36
+ snapEdges: ToolcraftAssemblyPanelContract["snapEdges"];
37
+ visualComponent: string;
38
+ };
39
+
40
+ const canvasSizeControlTargets = {
41
+ height: "canvas.size.height",
42
+ width: "canvas.size.width",
43
+ } as const;
44
+ const maxAutoInlineControlLabelLength = 18;
45
+ const settingsTransferTarget = "runtime.settingsTransfer";
46
+ const runtimeSetupSectionTitle = "Setup";
47
+ const settingsTransferHeavyControlTypes = new Set([
48
+ "channelMixer",
49
+ "code",
50
+ "colorOpacity",
51
+ "curves",
52
+ "fileDrop",
53
+ "fontPicker",
54
+ "gradient",
55
+ "imagePicker",
56
+ "palette",
57
+ "rangeSlider",
58
+ "vector",
59
+ ]);
60
+
61
+ export type ToolcraftSettingsTransferEligibilityReason =
62
+ | "control-count"
63
+ | "score"
64
+ | "section-count";
65
+
66
+ export type ToolcraftSettingsTransferEligibility = {
67
+ controlCount: number;
68
+ eligible: boolean;
69
+ reasons: ToolcraftSettingsTransferEligibilityReason[];
70
+ score: number;
71
+ sectionCount: number;
72
+ };
73
+
74
+ type ToolcraftControlActionSchema = NonNullable<
75
+ ToolcraftControlSchema["actions"]
76
+ >[number];
77
+
78
+ function unique<const Value extends string>(values: readonly Value[]): Value[] {
79
+ return Array.from(new Set(values));
80
+ }
81
+
82
+ function assertNever(value: never): never {
83
+ throw new Error(`Unsupported Toolcraft template persistence storage: ${String(value)}`);
84
+ }
85
+
86
+ function resolvePersistence(
87
+ persistence: ToolcraftAppSchema["persistence"],
88
+ ): ResolvedToolcraftAppSchema["persistence"] {
89
+ switch (persistence?.storage) {
90
+ case undefined:
91
+ case "none":
92
+ return { storage: "none" };
93
+ case "localStorage":
94
+ return persistence;
95
+ default:
96
+ return assertNever(persistence);
97
+ }
98
+ }
99
+
100
+ function slugifySettingsTransferAppId(value: string | undefined): string {
101
+ const slug = value
102
+ ?.trim()
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, "-")
105
+ .replace(/^-+|-+$/g, "");
106
+
107
+ return slug || "toolcraft-app";
108
+ }
109
+
110
+ function getSettingsTransferMode(
111
+ settingsTransfer: ToolcraftSettingsTransferSchema | undefined,
112
+ ): "auto" | boolean {
113
+ if (typeof settingsTransfer === "object" && settingsTransfer !== null) {
114
+ return settingsTransfer.enabled ?? "auto";
115
+ }
116
+
117
+ return settingsTransfer ?? "auto";
118
+ }
119
+
120
+ function getSettingsTransferObject(
121
+ settingsTransfer: ToolcraftSettingsTransferSchema | undefined,
122
+ ): Extract<ToolcraftSettingsTransferSchema, object> | undefined {
123
+ return typeof settingsTransfer === "object" && settingsTransfer !== null
124
+ ? settingsTransfer
125
+ : undefined;
126
+ }
127
+
128
+ function getSettingsTransferAppId({
129
+ controls,
130
+ persistence,
131
+ settingsTransfer,
132
+ }: {
133
+ controls: ToolcraftControlsPanelSchema | undefined;
134
+ persistence: ResolvedToolcraftAppSchema["persistence"];
135
+ settingsTransfer: ToolcraftSettingsTransferSchema | undefined;
136
+ }): string {
137
+ const objectSchema = getSettingsTransferObject(settingsTransfer);
138
+
139
+ if (objectSchema?.appId) {
140
+ return slugifySettingsTransferAppId(objectSchema.appId);
141
+ }
142
+
143
+ if (persistence.storage === "localStorage") {
144
+ const match = /^toolcraft:(.+):state:v\d+$/u.exec(persistence.key);
145
+
146
+ if (match?.[1]) {
147
+ return slugifySettingsTransferAppId(match[1]);
148
+ }
149
+ }
150
+
151
+ return slugifySettingsTransferAppId(controls?.title);
152
+ }
153
+
154
+ function getSettingsTransferFileName({
155
+ appId,
156
+ settingsTransfer,
157
+ }: {
158
+ appId: string;
159
+ settingsTransfer: ToolcraftSettingsTransferSchema | undefined;
160
+ }): string {
161
+ const explicitFileName = getSettingsTransferObject(settingsTransfer)?.fileName?.trim();
162
+
163
+ if (explicitFileName) {
164
+ return explicitFileName.endsWith(".json") ? explicitFileName : `${explicitFileName}.json`;
165
+ }
166
+
167
+ return `${appId}-settings.json`;
168
+ }
169
+
170
+ function resolveCanvasSizing(
171
+ canvas: ToolcraftAppSchema["canvas"],
172
+ ): ToolcraftCanvasSizingSchema {
173
+ if (canvas.sizing) {
174
+ return canvas.sizing;
175
+ }
176
+
177
+ if (canvas.size) {
178
+ return { mode: "editable-output" };
179
+ }
180
+
181
+ if (canvas.upload) {
182
+ return { mode: "intrinsic-media" };
183
+ }
184
+
185
+ return { mode: "intrinsic-media" };
186
+ }
187
+
188
+ function resolveExport(
189
+ exportSchema: ToolcraftAppSchema["export"],
190
+ ): ResolvedExport {
191
+ return {
192
+ png: {
193
+ background: exportSchema?.png?.background ?? "include",
194
+ },
195
+ };
196
+ }
197
+
198
+ function getPanelDragMode(
199
+ contract: { capabilities?: readonly string[] },
200
+ ): ToolcraftAssemblyPanelContract["dragMode"] {
201
+ return contract.capabilities?.includes("dragMode:handle") ? "handle" : "panel";
202
+ }
203
+
204
+ function createPanelAssemblyContract({
205
+ capabilities = [],
206
+ commands = [],
207
+ contract,
208
+ enabled,
209
+ }: {
210
+ capabilities?: readonly ToolcraftAssemblyCapability[];
211
+ commands?: readonly ToolcraftAssemblyCommand[];
212
+ contract: PanelContract;
213
+ enabled: boolean;
214
+ }): ToolcraftAssemblyPanelContract {
215
+ const panelCapabilities = enabled
216
+ ? unique<ToolcraftAssemblyCapability>([
217
+ "panels.draggable",
218
+ "panels.snap",
219
+ "panels.doubleClickReset",
220
+ ...capabilities,
221
+ ])
222
+ : [];
223
+ const panelCommands = enabled
224
+ ? unique<ToolcraftAssemblyCommand>(["panels.setOffset", "panels.resetOffset", ...commands])
225
+ : [];
226
+
227
+ return {
228
+ capabilities: panelCapabilities,
229
+ commands: panelCommands,
230
+ defaultPlacement: contract.defaultPlacement,
231
+ dragMode: getPanelDragMode(contract),
232
+ enabled,
233
+ requiredWrapper: "PanelHost",
234
+ snapEdges: contract.snapEdges,
235
+ visualComponent: contract.visualComponent,
236
+ };
237
+ }
238
+
239
+ function createToolcraftAssembly({
240
+ canvas,
241
+ panels,
242
+ toolbar,
243
+ }: {
244
+ canvas: ResolvedCanvas;
245
+ panels: ResolvedToolcraftPanelsSchema;
246
+ toolbar: ResolvedToolbar;
247
+ }): ToolcraftAssemblyContract {
248
+ const components: ToolcraftAssemblyComponentId[] = [];
249
+ const capabilities: ToolcraftAssemblyCapability[] = [];
250
+ const commands: ToolcraftAssemblyCommand[] = [];
251
+ const toolbarEnabled = toolbar.history || toolbar.radar || toolbar.theme || toolbar.zoom;
252
+ const canvasEditableSize = canvas.sizing.mode === "editable-output";
253
+
254
+ if (canvas.enabled) {
255
+ components.push("canvas");
256
+
257
+ if (canvasEditableSize) {
258
+ capabilities.push("canvas.editableSize");
259
+ commands.push("canvas.setSize");
260
+ }
261
+
262
+ if (canvas.draggable) {
263
+ capabilities.push("canvas.draggable");
264
+ commands.push("canvas.panBy", "canvas.setOffset", "canvas.setViewport");
265
+ }
266
+
267
+ if (canvas.upload) {
268
+ capabilities.push("canvas.upload");
269
+ commands.push("media.delete", "media.import");
270
+ }
271
+ }
272
+
273
+ const controlsPanel = panels.controls
274
+ ? createPanelAssemblyContract({
275
+ capabilities: ["controls.panel", "controls.defaults"],
276
+ commands: ["controls.apply", "controls.reset", "controls.setValue"],
277
+ contract: TOOLCRAFT_COMPONENT_CONTRACTS.controlsPanel,
278
+ enabled: true,
279
+ })
280
+ : undefined;
281
+
282
+ if (controlsPanel) {
283
+ components.push("controlsPanel");
284
+ capabilities.push(...controlsPanel.capabilities);
285
+ commands.push(...controlsPanel.commands);
286
+ }
287
+
288
+ const layersPanel = panels.layers
289
+ ? createPanelAssemblyContract({
290
+ capabilities: [
291
+ "layers.groups",
292
+ "layers.panel",
293
+ "layers.selection",
294
+ "layers.visibility",
295
+ ],
296
+ commands: [
297
+ "layers.add",
298
+ "layers.delete",
299
+ "layers.moveToGroup",
300
+ "layers.rename",
301
+ "layers.reorder",
302
+ "layers.select",
303
+ "layers.toggleCollapsed",
304
+ "layers.toggleVisibility",
305
+ ],
306
+ contract: TOOLCRAFT_COMPONENT_CONTRACTS.layersPanel,
307
+ enabled: true,
308
+ })
309
+ : undefined;
310
+
311
+ if (layersPanel) {
312
+ components.push("layersPanel");
313
+ capabilities.push(...layersPanel.capabilities);
314
+ commands.push(...layersPanel.commands);
315
+ }
316
+
317
+ const timelineKeyframesEnabled = panels.timeline?.mode === "keyframes";
318
+ const timelinePanel = panels.timeline?.enabled
319
+ ? createPanelAssemblyContract({
320
+ capabilities: [
321
+ "timeline.duration",
322
+ "timeline.panel",
323
+ "timeline.playback",
324
+ ...(timelineKeyframesEnabled ? (["timeline.keyframes"] as const) : []),
325
+ ],
326
+ commands: [
327
+ "timeline.setCurrentTime",
328
+ "timeline.setDuration",
329
+ "timeline.setPlaying",
330
+ "timeline.toggleLoop",
331
+ "timeline.togglePlayback",
332
+ ...(timelineKeyframesEnabled
333
+ ? ([
334
+ "timeline.changeKeyframeEasing",
335
+ "timeline.deleteControlKeyframes",
336
+ "timeline.deleteKeyframe",
337
+ "timeline.moveKeyframe",
338
+ "timeline.selectKeyframe",
339
+ "timeline.setExpanded",
340
+ "timeline.toggleControlKeyframes",
341
+ "timeline.toggleExpanded",
342
+ ] as const)
343
+ : []),
344
+ ],
345
+ contract: TOOLCRAFT_COMPONENT_CONTRACTS.timelinePanel,
346
+ enabled: true,
347
+ })
348
+ : undefined;
349
+
350
+ if (timelinePanel) {
351
+ components.push("timelinePanel");
352
+ capabilities.push(...timelinePanel.capabilities);
353
+ commands.push(...timelinePanel.commands);
354
+ }
355
+
356
+ const toolbarCommands: ToolcraftAssemblyCommand[] = [];
357
+ const toolbarCapabilities: ToolcraftAssemblyCapability[] = [];
358
+
359
+ if (toolbar.history) {
360
+ toolbarCapabilities.push("history.undoRedo", "toolbar.history");
361
+ toolbarCommands.push("history.redo", "history.undo");
362
+ }
363
+
364
+ if (toolbar.radar) {
365
+ toolbarCapabilities.push("toolbar.radar");
366
+ toolbarCommands.push("canvas.center");
367
+ }
368
+
369
+ if (toolbar.theme) {
370
+ toolbarCapabilities.push("toolbar.theme");
371
+ }
372
+
373
+ if (toolbar.zoom) {
374
+ toolbarCapabilities.push("toolbar.zoom");
375
+ toolbarCommands.push("canvas.zoomIn", "canvas.zoomOut", "canvas.zoomReset");
376
+ }
377
+
378
+ const toolbarPanel = createPanelAssemblyContract({
379
+ capabilities: toolbarCapabilities,
380
+ commands: toolbarCommands,
381
+ contract: TOOLCRAFT_COMPONENT_CONTRACTS.toolbar,
382
+ enabled: toolbarEnabled,
383
+ });
384
+
385
+ if (toolbarEnabled) {
386
+ components.push("toolbar");
387
+ capabilities.push(...toolbarPanel.capabilities);
388
+ commands.push(...toolbarPanel.commands);
389
+ }
390
+
391
+ return {
392
+ capabilities: unique(capabilities),
393
+ commands: unique(commands),
394
+ components: unique(components),
395
+ surfaces: {
396
+ canvas: {
397
+ capabilities: canvas.enabled
398
+ ? unique<ToolcraftAssemblyCapability>([
399
+ ...(canvasEditableSize ? (["canvas.editableSize"] as const) : []),
400
+ ...(canvas.draggable ? (["canvas.draggable"] as const) : []),
401
+ ...(canvas.upload ? (["canvas.upload"] as const) : []),
402
+ ])
403
+ : [],
404
+ commands: canvas.enabled
405
+ ? unique<ToolcraftAssemblyCommand>([
406
+ ...(canvasEditableSize ? (["canvas.setSize"] as const) : []),
407
+ ...(canvas.draggable
408
+ ? (["canvas.panBy", "canvas.setOffset", "canvas.setViewport"] as const)
409
+ : []),
410
+ ...(canvas.upload ? (["media.delete", "media.import"] as const) : []),
411
+ ])
412
+ : [],
413
+ enabled: canvas.enabled,
414
+ visualComponent: "CanvasShell",
415
+ },
416
+ panels: {
417
+ controls: controlsPanel,
418
+ layers: layersPanel,
419
+ timeline: timelinePanel,
420
+ toolbar: toolbarPanel,
421
+ },
422
+ },
423
+ };
424
+ }
425
+
426
+ function hasControlTarget(
427
+ panels: ToolcraftAppSchema["panels"],
428
+ target: string,
429
+ ): boolean {
430
+ return (panels.controls?.sections ?? []).some((section) =>
431
+ Object.values(section.controls).some((control) => control.target === target),
432
+ );
433
+ }
434
+
435
+ function getControlDefaultSectionLayout(
436
+ control: ToolcraftControlSchema,
437
+ ): "grouped" | "standalone" {
438
+ const contract = (
439
+ TOOLCRAFT_COMPONENT_CONTRACTS as Record<
440
+ string,
441
+ { defaultSectionLayout?: "grouped" | "standalone"; kind?: string } | undefined
442
+ >
443
+ )[control.type];
444
+
445
+ return contract?.kind === "control" && contract.defaultSectionLayout
446
+ ? contract.defaultSectionLayout
447
+ : "grouped";
448
+ }
449
+
450
+ function getControlSectionLayout(
451
+ control: ToolcraftControlSchema,
452
+ entries: readonly [string, ToolcraftControlSchema][],
453
+ ): "grouped" | "standalone" {
454
+ if (
455
+ (control.type === "color" || control.type === "colorOpacity") &&
456
+ entries.some(
457
+ ([, entryControl]) =>
458
+ entryControl.type !== "color" &&
459
+ entryControl.type !== "colorOpacity" &&
460
+ getControlDefaultSectionLayout(entryControl) === "grouped",
461
+ )
462
+ ) {
463
+ return "grouped";
464
+ }
465
+
466
+ return getControlDefaultSectionLayout(control);
467
+ }
468
+
469
+ function createControlsRecord(
470
+ entries: readonly [string, ToolcraftControlSchema][],
471
+ ): Record<string, ToolcraftControlSchema> {
472
+ return Object.fromEntries(
473
+ entries.map(([id, control]) => [id, normalizeControlSchema(control)]),
474
+ );
475
+ }
476
+
477
+ function isSliderLikeControl(control: ToolcraftControlSchema): boolean {
478
+ return control.type === "slider" || control.type === "rangeSlider";
479
+ }
480
+
481
+ function getStepMarkerCount(control: ToolcraftControlSchema): number | undefined {
482
+ if (
483
+ typeof control.step !== "number" ||
484
+ typeof control.min !== "number" ||
485
+ typeof control.max !== "number" ||
486
+ !Number.isFinite(control.step) ||
487
+ !Number.isFinite(control.min) ||
488
+ !Number.isFinite(control.max) ||
489
+ control.step <= 0 ||
490
+ control.max <= control.min
491
+ ) {
492
+ return undefined;
493
+ }
494
+
495
+ const rawStepCount = (control.max - control.min) / control.step;
496
+ const roundedStepCount = Math.round(rawStepCount);
497
+ const stepCount =
498
+ Math.abs(rawStepCount - roundedStepCount) < Number.EPSILON * 100
499
+ ? roundedStepCount
500
+ : Math.floor(rawStepCount) + 1;
501
+
502
+ return Math.max(2, stepCount + 1);
503
+ }
504
+
505
+ function normalizeControlSchema(
506
+ control: ToolcraftControlSchema,
507
+ ): ToolcraftControlSchema {
508
+ if (
509
+ !isSliderLikeControl(control) ||
510
+ typeof control.step !== "number" ||
511
+ control.variant !== "discrete"
512
+ ) {
513
+ return control;
514
+ }
515
+
516
+ return {
517
+ ...control,
518
+ markerCount: getStepMarkerCount(control) ?? control.markerCount,
519
+ variant: "discrete",
520
+ };
521
+ }
522
+
523
+ function filterLayoutGroupsForControlIds(
524
+ layoutGroups: readonly ToolcraftControlLayoutGroupSchema[] | undefined,
525
+ controlIds: ReadonlySet<string>,
526
+ ): ToolcraftControlLayoutGroupSchema[] {
527
+ return (layoutGroups ?? [])
528
+ .map((layoutGroup) => ({
529
+ ...layoutGroup,
530
+ controls: layoutGroup.controls.filter((controlId) => controlIds.has(controlId)),
531
+ }))
532
+ .filter((layoutGroup) => layoutGroup.controls.length > 1);
533
+ }
534
+
535
+ function hasControlEntries(
536
+ entries: readonly [string, ToolcraftControlSchema][],
537
+ ): boolean {
538
+ return entries.length > 0;
539
+ }
540
+
541
+ function isPanelActionsControl(control: ToolcraftControlSchema): boolean {
542
+ return control.type === "panelActions";
543
+ }
544
+
545
+ function isSettingsTransferControl(control: ToolcraftControlSchema): boolean {
546
+ return control.type === "settingsTransfer";
547
+ }
548
+
549
+ function isRuntimeOnlyActionControl(control: ToolcraftControlSchema): boolean {
550
+ return isPanelActionsControl(control) || isSettingsTransferControl(control);
551
+ }
552
+
553
+ function isSettingsTransferEligibilityControl(control: ToolcraftControlSchema): boolean {
554
+ return (
555
+ !isRuntimeOnlyActionControl(control) &&
556
+ control.target !== canvasSizeControlTargets.width &&
557
+ control.target !== canvasSizeControlTargets.height
558
+ );
559
+ }
560
+
561
+ function getSettingsTransferComplexityScore({
562
+ panels,
563
+ }: {
564
+ panels: ToolcraftAppSchema["panels"];
565
+ }): {
566
+ controlCount: number;
567
+ score: number;
568
+ sectionCount: number;
569
+ } {
570
+ const sections = panels.controls?.sections ?? [];
571
+ let controlCount = 0;
572
+ let score = 0;
573
+
574
+ for (const section of sections) {
575
+ for (const control of Object.values(section.controls)) {
576
+ if (!isSettingsTransferEligibilityControl(control)) {
577
+ continue;
578
+ }
579
+
580
+ controlCount += 1;
581
+ score += settingsTransferHeavyControlTypes.has(control.type) ? 3 : 1;
582
+ }
583
+ }
584
+
585
+ const sectionCount = sections.filter((section) =>
586
+ Object.values(section.controls).some((control) =>
587
+ isSettingsTransferEligibilityControl(control),
588
+ ),
589
+ ).length;
590
+
591
+ score += Math.max(0, sectionCount - 4);
592
+
593
+ if (
594
+ panels.timeline === true ||
595
+ (typeof panels.timeline === "object" && panels.timeline.enabled !== false)
596
+ ) {
597
+ score += 2;
598
+ }
599
+
600
+ if (panels.layers) {
601
+ score += 2;
602
+ }
603
+
604
+ return { controlCount, score, sectionCount };
605
+ }
606
+
607
+ export function getToolcraftSettingsTransferEligibility({
608
+ panels,
609
+ }: {
610
+ panels: ToolcraftAppSchema["panels"];
611
+ }): ToolcraftSettingsTransferEligibility {
612
+ const { controlCount, score, sectionCount } = getSettingsTransferComplexityScore({
613
+ panels,
614
+ });
615
+ const reasons: ToolcraftSettingsTransferEligibilityReason[] = [];
616
+
617
+ if (controlCount >= 12) {
618
+ reasons.push("control-count");
619
+ }
620
+
621
+ if (sectionCount >= 5) {
622
+ reasons.push("section-count");
623
+ }
624
+
625
+ if (score >= 18) {
626
+ reasons.push("score");
627
+ }
628
+
629
+ return {
630
+ controlCount,
631
+ eligible: reasons.length > 0,
632
+ reasons,
633
+ score,
634
+ sectionCount,
635
+ };
636
+ }
637
+
638
+ function shouldAutoEnableSettingsTransfer({
639
+ panels,
640
+ }: {
641
+ panels: ToolcraftAppSchema["panels"];
642
+ }): boolean {
643
+ return getToolcraftSettingsTransferEligibility({ panels }).eligible;
644
+ }
645
+
646
+ function resolveSettingsTransfer({
647
+ controls,
648
+ panels,
649
+ persistence,
650
+ settingsTransfer,
651
+ }: {
652
+ controls: ToolcraftControlsPanelSchema | undefined;
653
+ panels: ToolcraftAppSchema["panels"];
654
+ persistence: ResolvedToolcraftAppSchema["persistence"];
655
+ settingsTransfer: ToolcraftSettingsTransferSchema | undefined;
656
+ }): ResolvedToolcraftSettingsTransferSchema {
657
+ const mode = getSettingsTransferMode(settingsTransfer);
658
+ const appId = getSettingsTransferAppId({
659
+ controls,
660
+ persistence,
661
+ settingsTransfer,
662
+ });
663
+
664
+ return {
665
+ appId,
666
+ enabled:
667
+ Boolean(controls) &&
668
+ (mode === "auto" ? shouldAutoEnableSettingsTransfer({ panels }) : mode),
669
+ fileName: getSettingsTransferFileName({ appId, settingsTransfer }),
670
+ mode,
671
+ };
672
+ }
673
+
674
+ function createSettingsTransferSection(
675
+ settingsTransfer: ResolvedToolcraftSettingsTransferSchema,
676
+ ): ToolcraftControlSectionSchema | null {
677
+ if (!settingsTransfer.enabled) {
678
+ return null;
679
+ }
680
+
681
+ return {
682
+ controls: {
683
+ settingsTransfer: {
684
+ label: false,
685
+ target: settingsTransferTarget,
686
+ type: "settingsTransfer",
687
+ },
688
+ },
689
+ layout: "standalone",
690
+ title: runtimeSetupSectionTitle,
691
+ };
692
+ }
693
+
694
+ function getCanvasSizeLayoutGroups(
695
+ sizeControlIds: readonly string[],
696
+ ): ToolcraftControlSectionSchema["layoutGroups"] {
697
+ return sizeControlIds.length > 1
698
+ ? [{ columns: 2, controls: [...sizeControlIds], layout: "inline" }]
699
+ : undefined;
700
+ }
701
+
702
+ function createCanvasSizeSection({
703
+ sizeControlIds,
704
+ sizeControls,
705
+ }: {
706
+ sizeControlIds: readonly string[];
707
+ sizeControls: ToolcraftControlSectionSchema["controls"];
708
+ }): ToolcraftControlSectionSchema {
709
+ return {
710
+ controls: sizeControls,
711
+ layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
712
+ title: runtimeSetupSectionTitle,
713
+ };
714
+ }
715
+
716
+ function mergeCanvasSizeControlsIntoSettingsTransferSection({
717
+ settingsTransferSection,
718
+ sizeControlIds,
719
+ sizeControls,
720
+ }: {
721
+ settingsTransferSection: ToolcraftControlSectionSchema;
722
+ sizeControlIds: readonly string[];
723
+ sizeControls: ToolcraftControlSectionSchema["controls"];
724
+ }): ToolcraftControlSectionSchema {
725
+ const canvasSizeLayoutGroups = getCanvasSizeLayoutGroups(sizeControlIds) ?? [];
726
+
727
+ return {
728
+ ...settingsTransferSection,
729
+ controls: {
730
+ ...settingsTransferSection.controls,
731
+ ...sizeControls,
732
+ },
733
+ layoutGroups:
734
+ canvasSizeLayoutGroups.length > 0 || settingsTransferSection.layoutGroups?.length
735
+ ? [
736
+ ...(settingsTransferSection.layoutGroups ?? []),
737
+ ...canvasSizeLayoutGroups,
738
+ ]
739
+ : undefined,
740
+ };
741
+ }
742
+
743
+ function isPrimaryPanelAction(action: ToolcraftControlActionSchema): boolean {
744
+ return typeof action !== "string" && action.variant !== "outline";
745
+ }
746
+
747
+ function orderPanelActions(
748
+ actions: readonly ToolcraftControlActionSchema[],
749
+ ): ToolcraftControlActionSchema[] {
750
+ if (actions.length !== 2) {
751
+ return [...actions];
752
+ }
753
+
754
+ return [...actions].sort(
755
+ (left, right) => Number(isPrimaryPanelAction(left)) - Number(isPrimaryPanelAction(right)),
756
+ );
757
+ }
758
+
759
+ function createMergedPanelActionsControl(
760
+ entries: readonly [string, ToolcraftControlSchema][],
761
+ ): ToolcraftControlSchema | null {
762
+ const firstControl = entries[0]?.[1];
763
+
764
+ if (!firstControl) {
765
+ return null;
766
+ }
767
+
768
+ const actions = entries.flatMap(([, control]) => [...(control.actions ?? [])]);
769
+
770
+ return {
771
+ ...firstControl,
772
+ actions: orderPanelActions(actions),
773
+ target: firstControl.target || "panel.actions",
774
+ type: "panelActions",
775
+ };
776
+ }
777
+
778
+ function splitControlsPanelActionSections(
779
+ sections: readonly ToolcraftControlSectionSchema[],
780
+ ): {
781
+ bodySections: ToolcraftControlSectionSchema[];
782
+ stickyFooterSections: ToolcraftControlSectionSchema[];
783
+ } {
784
+ const bodySections: ToolcraftControlSectionSchema[] = [];
785
+ const stickyFooterSections: ToolcraftControlSectionSchema[] = [];
786
+ const stickyFooterActionEntries: [string, ToolcraftControlSchema][] = [];
787
+
788
+ for (const section of sections) {
789
+ if (section.actionGroup) {
790
+ const entries = Object.entries(section.controls);
791
+ const actionEntries = entries.filter(([, control]) => isPanelActionsControl(control));
792
+ const passthroughEntries = entries.filter(([, control]) => !isPanelActionsControl(control));
793
+
794
+ stickyFooterActionEntries.push(...actionEntries);
795
+
796
+ if (hasControlEntries(passthroughEntries)) {
797
+ stickyFooterSections.push({
798
+ ...section,
799
+ controls: createControlsRecord(passthroughEntries),
800
+ });
801
+ }
802
+
803
+ continue;
804
+ }
805
+
806
+ const bodyEntries: [string, ToolcraftControlSchema][] = [];
807
+ const actionEntries: [string, ToolcraftControlSchema][] = [];
808
+
809
+ for (const entry of Object.entries(section.controls)) {
810
+ const [, control] = entry;
811
+
812
+ if (isPanelActionsControl(control)) {
813
+ actionEntries.push(entry);
814
+ } else {
815
+ bodyEntries.push(entry);
816
+ }
817
+ }
818
+
819
+ if (hasControlEntries(bodyEntries)) {
820
+ const controlIds = new Set(bodyEntries.map(([id]) => id));
821
+ const layoutGroups = filterLayoutGroupsForControlIds(section.layoutGroups, controlIds);
822
+ const title = getBodySectionTitleAfterActionSplit(section.title);
823
+
824
+ bodySections.push({
825
+ ...section,
826
+ controls: createControlsRecord(bodyEntries),
827
+ layoutGroups: layoutGroups.length > 0 ? layoutGroups : undefined,
828
+ title,
829
+ });
830
+ }
831
+
832
+ if (hasControlEntries(actionEntries)) {
833
+ stickyFooterActionEntries.push(...actionEntries);
834
+ }
835
+ }
836
+
837
+ const mergedActionsControl = createMergedPanelActionsControl(stickyFooterActionEntries);
838
+
839
+ if (mergedActionsControl) {
840
+ stickyFooterSections.unshift({
841
+ actionGroup: "secondary",
842
+ controls: { footer: mergedActionsControl },
843
+ layout: "standalone",
844
+ title: "Export",
845
+ });
846
+ }
847
+
848
+ return { bodySections, stickyFooterSections };
849
+ }
850
+
851
+ function getBodySectionTitleAfterActionSplit(
852
+ title: ToolcraftControlSectionSchema["title"],
853
+ ): ToolcraftControlSectionSchema["title"] {
854
+ if (!title) {
855
+ return title;
856
+ }
857
+
858
+ return isActionOrExportSectionTitle(title) ? undefined : title;
859
+ }
860
+
861
+ function isActionOrExportSectionTitle(title: string): boolean {
862
+ const normalizedTitle = title.trim().toLowerCase();
863
+
864
+ return normalizedTitle === "action" ||
865
+ normalizedTitle === "actions" ||
866
+ normalizedTitle === "export" ||
867
+ normalizedTitle === "exports";
868
+ }
869
+
870
+ function isShortControlLabel(id: string, control: ToolcraftControlSchema): boolean {
871
+ const label = typeof control.label === "string" ? control.label : id;
872
+
873
+ return label.length <= maxAutoInlineControlLabelLength;
874
+ }
875
+
876
+ function isNumericTextControl(control: ToolcraftControlSchema): boolean {
877
+ if (control.type !== "text") {
878
+ return false;
879
+ }
880
+
881
+ if (typeof control.defaultValue === "number") {
882
+ return Number.isFinite(control.defaultValue);
883
+ }
884
+
885
+ return (
886
+ typeof control.defaultValue === "string" &&
887
+ /^-?\d+(?:\.\d+)?(?:px|%|s)?$/u.test(control.defaultValue.trim())
888
+ );
889
+ }
890
+
891
+ function isColorValueControl(control: ToolcraftControlSchema): boolean {
892
+ return control.type === "color" || control.type === "colorOpacity";
893
+ }
894
+
895
+ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
896
+ return typeof control.label === "string" && control.label.trim().length > 0;
897
+ }
898
+
899
+ function shouldAutoInlineMixedFieldControls(
900
+ first: [string, ToolcraftControlSchema],
901
+ second: [string, ToolcraftControlSchema],
902
+ ): boolean {
903
+ const [firstId, firstControl] = first;
904
+ const [secondId, secondControl] = second;
905
+ const isNumericColorPair =
906
+ (isNumericTextControl(firstControl) && isColorValueControl(secondControl)) ||
907
+ (isColorValueControl(firstControl) && isNumericTextControl(secondControl));
908
+
909
+ return (
910
+ isNumericColorPair &&
911
+ hasVisibleControlLabel(firstControl) &&
912
+ hasVisibleControlLabel(secondControl) &&
913
+ isShortControlLabel(firstId, firstControl) &&
914
+ isShortControlLabel(secondId, secondControl)
915
+ );
916
+ }
917
+
918
+ function shouldAutoInlineControls(
919
+ first: [string, ToolcraftControlSchema],
920
+ second: [string, ToolcraftControlSchema],
921
+ ): boolean {
922
+ const [firstId, firstControl] = first;
923
+ const [secondId, secondControl] = second;
924
+
925
+ if (
926
+ isNumericTextControl(firstControl) &&
927
+ isNumericTextControl(secondControl) &&
928
+ isShortControlLabel(firstId, firstControl) &&
929
+ isShortControlLabel(secondId, secondControl)
930
+ ) {
931
+ return true;
932
+ }
933
+
934
+ return shouldAutoInlineMixedFieldControls(first, second);
935
+ }
936
+
937
+ function addAutoLayoutGroupsToSection(
938
+ section: ToolcraftControlSectionSchema,
939
+ ): ToolcraftControlSectionSchema {
940
+ if (section.layout === "standalone" || section.actionGroup) {
941
+ return section;
942
+ }
943
+
944
+ const entries = Object.entries(section.controls);
945
+ const explicitLayoutGroups = section.layoutGroups ?? [];
946
+ const groupedControlIds = new Set<string>();
947
+
948
+ for (const layoutGroup of explicitLayoutGroups) {
949
+ for (const controlId of layoutGroup.controls) {
950
+ groupedControlIds.add(controlId);
951
+ }
952
+ }
953
+
954
+ const autoLayoutGroups: ToolcraftControlLayoutGroupSchema[] = [];
955
+
956
+ for (let index = 0; index < entries.length - 1; index += 1) {
957
+ const firstEntry = entries[index];
958
+ const secondEntry = entries[index + 1];
959
+
960
+ if (!firstEntry || !secondEntry) {
961
+ continue;
962
+ }
963
+
964
+ const [firstId] = firstEntry;
965
+ const [secondId] = secondEntry;
966
+
967
+ if (groupedControlIds.has(firstId) || groupedControlIds.has(secondId)) {
968
+ continue;
969
+ }
970
+
971
+ if (!shouldAutoInlineControls(firstEntry, secondEntry)) {
972
+ continue;
973
+ }
974
+
975
+ autoLayoutGroups.push({
976
+ columns: 2,
977
+ controls: [firstId, secondId],
978
+ layout: "inline",
979
+ });
980
+ groupedControlIds.add(firstId);
981
+ groupedControlIds.add(secondId);
982
+ index += 1;
983
+ }
984
+
985
+ const layoutGroups = [...explicitLayoutGroups, ...autoLayoutGroups];
986
+
987
+ return layoutGroups.length > 0
988
+ ? {
989
+ ...section,
990
+ layoutGroups,
991
+ }
992
+ : section;
993
+ }
994
+
995
+ function getImplicitStandaloneSectionTitle(
996
+ entries: readonly [string, ToolcraftControlSchema][],
997
+ ): string {
998
+ const names = entries
999
+ .map(([id, control]) => getSectionTitlePart(id, control, entries))
1000
+ .filter((name): name is string => Boolean(name));
1001
+
1002
+ if (names.length === 1) {
1003
+ return names[0];
1004
+ }
1005
+
1006
+ if (names.length === 2) {
1007
+ return `${names[0]} & ${names[1]}`;
1008
+ }
1009
+
1010
+ return getCommonTargetSectionTitle(entries) ?? "Appearance";
1011
+ }
1012
+
1013
+ function getSectionTitlePart(
1014
+ id: string,
1015
+ control: ToolcraftControlSchema,
1016
+ entries: readonly [string, ToolcraftControlSchema][],
1017
+ ): string | undefined {
1018
+ if (typeof control.label === "string" && control.label.trim()) {
1019
+ const label = control.label.trim();
1020
+
1021
+ if (!isGenericSectionTitle(label)) {
1022
+ return label;
1023
+ }
1024
+ }
1025
+
1026
+ const title = titleizeControlId(id);
1027
+
1028
+ if (title && !isGenericSectionTitle(title)) {
1029
+ return title;
1030
+ }
1031
+
1032
+ if (entries.length === 1) {
1033
+ return getControlTypeSectionTitle(control);
1034
+ }
1035
+
1036
+ return undefined;
1037
+ }
1038
+
1039
+ function getControlTypeSectionTitle(
1040
+ control: ToolcraftControlSchema,
1041
+ ): string | undefined {
1042
+ switch (control.type) {
1043
+ case "channelMixer":
1044
+ return "Channels";
1045
+ case "curves":
1046
+ return "Curves";
1047
+ case "fontPicker":
1048
+ return "Typography";
1049
+ case "gradient":
1050
+ return "Gradient";
1051
+ case "palette":
1052
+ return "Palette";
1053
+ case "settingsTransfer":
1054
+ return "Settings";
1055
+ default:
1056
+ return undefined;
1057
+ }
1058
+ }
1059
+
1060
+ function getCommonTargetSectionTitle(
1061
+ entries: readonly [string, ToolcraftControlSchema][],
1062
+ ): string | undefined {
1063
+ const targetPrefixes = new Set(
1064
+ entries
1065
+ .map(([, control]) => control.target.split(".")[0]?.trim())
1066
+ .filter((prefix): prefix is string => Boolean(prefix)),
1067
+ );
1068
+
1069
+ if (targetPrefixes.size !== 1) {
1070
+ return undefined;
1071
+ }
1072
+
1073
+ const [targetPrefix] = targetPrefixes;
1074
+ const title = titlePrefixToSectionTitle(targetPrefix);
1075
+
1076
+ return title && !isGenericSectionTitle(title) ? title : undefined;
1077
+ }
1078
+
1079
+ function titlePrefixToSectionTitle(prefix: string | undefined): string | undefined {
1080
+ if (!prefix) {
1081
+ return undefined;
1082
+ }
1083
+
1084
+ switch (prefix) {
1085
+ case "canvas":
1086
+ return runtimeSetupSectionTitle;
1087
+ case "runtime":
1088
+ return "Settings";
1089
+ case "style":
1090
+ return "Appearance";
1091
+ default:
1092
+ return titleizeControlId(prefix);
1093
+ }
1094
+ }
1095
+
1096
+ function isGenericColorSectionTitle(title: string): boolean {
1097
+ const normalizedTitle = title.trim().toLowerCase();
1098
+
1099
+ return normalizedTitle === "color" || normalizedTitle === "colors";
1100
+ }
1101
+
1102
+ function isGenericSectionTitle(title: string): boolean {
1103
+ const normalizedTitle = title.trim().toLowerCase();
1104
+
1105
+ return (
1106
+ normalizedTitle === "control" ||
1107
+ normalizedTitle === "controls" ||
1108
+ normalizedTitle === "setting" ||
1109
+ normalizedTitle === "settings" ||
1110
+ isGenericColorSectionTitle(title)
1111
+ );
1112
+ }
1113
+
1114
+ function isColorOnlySectionEntries(
1115
+ entries: readonly [string, ToolcraftControlSchema][],
1116
+ ): boolean {
1117
+ return entries.length > 0 && entries.every(([, control]) => control.type === "color");
1118
+ }
1119
+
1120
+ function titleizeControlId(id: string): string | undefined {
1121
+ const title = id
1122
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
1123
+ .replace(/[_-]+/g, " ")
1124
+ .trim()
1125
+ .replace(/\s+/g, " ");
1126
+
1127
+ if (!title) {
1128
+ return undefined;
1129
+ }
1130
+
1131
+ return title.replace(/\b\w/g, (letter) => letter.toUpperCase());
1132
+ }
1133
+
1134
+ function withImplicitStandaloneSectionTitle(
1135
+ section: ToolcraftControlSectionSchema,
1136
+ entries: readonly [string, ToolcraftControlSchema][],
1137
+ ): ToolcraftControlSectionSchema {
1138
+ if (section.title) {
1139
+ if (isColorOnlySectionEntries(entries) && isGenericColorSectionTitle(section.title)) {
1140
+ return { ...section, title: "Appearance" };
1141
+ }
1142
+
1143
+ return section;
1144
+ }
1145
+
1146
+ const title = getImplicitStandaloneSectionTitle(entries);
1147
+
1148
+ return { ...section, title };
1149
+ }
1150
+
1151
+ function normalizeMixedSectionLayout(
1152
+ section: ToolcraftControlSectionSchema,
1153
+ ): ToolcraftControlSectionSchema[] {
1154
+ const entries = Object.entries(section.controls);
1155
+
1156
+ if (section.layout === "standalone") {
1157
+ return [
1158
+ addAutoLayoutGroupsToSection(
1159
+ withImplicitStandaloneSectionTitle(section, entries),
1160
+ ),
1161
+ ];
1162
+ }
1163
+
1164
+ if (entries.length <= 1) {
1165
+ return [addAutoLayoutGroupsToSection(withImplicitStandaloneSectionTitle(section, entries))];
1166
+ }
1167
+
1168
+ const layouts = entries.map(([, control]) => getControlSectionLayout(control, entries));
1169
+ const uniqueLayouts = new Set(layouts);
1170
+
1171
+ if (uniqueLayouts.size <= 1) {
1172
+ return [addAutoLayoutGroupsToSection(withImplicitStandaloneSectionTitle(section, entries))];
1173
+ }
1174
+
1175
+ const normalizedSections: ToolcraftControlSectionSchema[] = [];
1176
+ let currentLayout = layouts[0];
1177
+ let currentEntries: [string, ToolcraftControlSchema][] = [];
1178
+
1179
+ const pushCurrentSection = (): void => {
1180
+ if (!currentLayout || currentEntries.length === 0) {
1181
+ return;
1182
+ }
1183
+
1184
+ const controlIds = new Set(currentEntries.map(([id]) => id));
1185
+
1186
+ if (currentLayout === "standalone") {
1187
+ normalizedSections.push(
1188
+ withImplicitStandaloneSectionTitle(
1189
+ {
1190
+ controls: createControlsRecord(currentEntries),
1191
+ layout: "standalone",
1192
+ },
1193
+ currentEntries,
1194
+ ),
1195
+ );
1196
+ } else {
1197
+ normalizedSections.push(
1198
+ addAutoLayoutGroupsToSection({
1199
+ ...section,
1200
+ controls: createControlsRecord(currentEntries),
1201
+ layoutGroups: filterLayoutGroupsForControlIds(section.layoutGroups, controlIds),
1202
+ }),
1203
+ );
1204
+ }
1205
+ };
1206
+
1207
+ for (const [index, entry] of entries.entries()) {
1208
+ const layout = layouts[index] ?? "grouped";
1209
+
1210
+ if (layout !== currentLayout) {
1211
+ pushCurrentSection();
1212
+ currentLayout = layout;
1213
+ currentEntries = [];
1214
+ }
1215
+
1216
+ currentEntries.push(entry);
1217
+ }
1218
+
1219
+ pushCurrentSection();
1220
+
1221
+ return normalizedSections;
1222
+ }
1223
+
1224
+ function normalizeControlsPanelLayout(
1225
+ controls: ToolcraftControlsPanelSchema,
1226
+ ): ToolcraftControlsPanelSchema {
1227
+ const { bodySections, stickyFooterSections } = splitControlsPanelActionSections(
1228
+ controls.sections,
1229
+ );
1230
+
1231
+ return {
1232
+ ...controls,
1233
+ sections: [
1234
+ ...bodySections.flatMap(normalizeMixedSectionLayout),
1235
+ ...stickyFooterSections.flatMap(normalizeMixedSectionLayout),
1236
+ ],
1237
+ };
1238
+ }
1239
+
1240
+ function normalizePanels({
1241
+ canvas,
1242
+ panels,
1243
+ settingsTransfer,
1244
+ }: {
1245
+ canvas: ResolvedCanvas;
1246
+ panels: ToolcraftAppSchema["panels"];
1247
+ settingsTransfer: ResolvedToolcraftSettingsTransferSchema;
1248
+ }): ResolvedToolcraftPanelsSchema {
1249
+ const normalizedTimeline = resolveTimelinePanel(panels.timeline);
1250
+ const normalizedPanels: ResolvedToolcraftPanelsSchema = {
1251
+ ...(panels.controls ? { controls: panels.controls } : {}),
1252
+ ...(panels.layers ? { layers: panels.layers } : {}),
1253
+ ...(normalizedTimeline ? { timeline: normalizedTimeline } : {}),
1254
+ };
1255
+
1256
+ if (!panels.controls) {
1257
+ return normalizedPanels;
1258
+ }
1259
+
1260
+ const controls = { ...panels.controls };
1261
+ const settingsTransferSection = createSettingsTransferSection(settingsTransfer);
1262
+
1263
+ if (!canvas.enabled || canvas.sizing.mode !== "editable-output") {
1264
+ return {
1265
+ ...normalizedPanels,
1266
+ controls: normalizeControlsPanelLayout({
1267
+ ...controls,
1268
+ sections: [
1269
+ ...(settingsTransferSection ? [settingsTransferSection] : []),
1270
+ ...controls.sections,
1271
+ ],
1272
+ }),
1273
+ };
1274
+ }
1275
+
1276
+ const sizeControls: ToolcraftControlSectionSchema["controls"] = {};
1277
+ const sizeControlIds: string[] = [];
1278
+
1279
+ if (!hasControlTarget(panels, canvasSizeControlTargets.width)) {
1280
+ sizeControls.canvasWidth = {
1281
+ defaultValue: canvas.size.width,
1282
+ label: "Canvas width",
1283
+ performanceReason: "Canvas width changes output dimensions and renderer workload.",
1284
+ performanceRole: "workload",
1285
+ target: canvasSizeControlTargets.width,
1286
+ type: "text",
1287
+ };
1288
+ sizeControlIds.push("canvasWidth");
1289
+ }
1290
+
1291
+ if (!hasControlTarget(panels, canvasSizeControlTargets.height)) {
1292
+ sizeControls.canvasHeight = {
1293
+ defaultValue: canvas.size.height,
1294
+ label: "Canvas height",
1295
+ performanceReason: "Canvas height changes output dimensions and renderer workload.",
1296
+ performanceRole: "workload",
1297
+ target: canvasSizeControlTargets.height,
1298
+ type: "text",
1299
+ };
1300
+ sizeControlIds.push("canvasHeight");
1301
+ }
1302
+
1303
+ if (Object.keys(sizeControls).length === 0) {
1304
+ return {
1305
+ ...normalizedPanels,
1306
+ controls: normalizeControlsPanelLayout({
1307
+ ...controls,
1308
+ sections: [
1309
+ ...(settingsTransferSection ? [settingsTransferSection] : []),
1310
+ ...controls.sections,
1311
+ ],
1312
+ }),
1313
+ };
1314
+ }
1315
+
1316
+ const runtimeSettingsSection = settingsTransferSection
1317
+ ? mergeCanvasSizeControlsIntoSettingsTransferSection({
1318
+ settingsTransferSection,
1319
+ sizeControlIds,
1320
+ sizeControls,
1321
+ })
1322
+ : createCanvasSizeSection({ sizeControlIds, sizeControls });
1323
+
1324
+ return {
1325
+ ...normalizedPanels,
1326
+ controls: {
1327
+ ...normalizeControlsPanelLayout({
1328
+ ...controls,
1329
+ sections: [
1330
+ runtimeSettingsSection,
1331
+ ...controls.sections,
1332
+ ],
1333
+ }),
1334
+ },
1335
+ };
1336
+ }
1337
+
1338
+ function resolveTimelinePanel(
1339
+ timeline: ToolcraftTimelinePanelSchema | undefined,
1340
+ ): ResolvedToolcraftTimelinePanelSchema | undefined {
1341
+ if (timeline === true) {
1342
+ return { enabled: true, mode: "keyframes" };
1343
+ }
1344
+
1345
+ if (!timeline || timeline.enabled === false) {
1346
+ return undefined;
1347
+ }
1348
+
1349
+ return {
1350
+ enabled: true,
1351
+ mode: timeline.mode ?? "keyframes",
1352
+ };
1353
+ }
1354
+
1355
+ function hasVisibleRuntimePanel({
1356
+ panels,
1357
+ toolbar,
1358
+ }: {
1359
+ panels: ResolvedToolcraftPanelsSchema;
1360
+ toolbar: ResolvedToolbar;
1361
+ }): boolean {
1362
+ return Boolean(
1363
+ panels.controls ||
1364
+ panels.layers ||
1365
+ panels.timeline ||
1366
+ toolbar.history ||
1367
+ toolbar.radar ||
1368
+ toolbar.theme ||
1369
+ toolbar.zoom,
1370
+ );
1371
+ }
1372
+
1373
+ function assertPanelPersistenceContract({
1374
+ panels,
1375
+ persistence,
1376
+ toolbar,
1377
+ }: {
1378
+ panels: ResolvedToolcraftPanelsSchema;
1379
+ persistence: ResolvedToolcraftAppSchema["persistence"];
1380
+ toolbar: ResolvedToolbar;
1381
+ }): void {
1382
+ if (persistence.storage !== "localStorage" || !hasVisibleRuntimePanel({ panels, toolbar })) {
1383
+ return;
1384
+ }
1385
+
1386
+ if (persistence.include.includes("panels")) {
1387
+ return;
1388
+ }
1389
+
1390
+ throw new Error(
1391
+ 'Toolcraft apps with visible runtime panels and localStorage persistence must include "panels" so dragged panel positions survive reload.',
1392
+ );
1393
+ }
1394
+
1395
+ export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAppSchema {
1396
+ const canvasEnabled = schema.canvas.enabled;
1397
+ const canvasSize = schema.canvas.size;
1398
+ const canvasSizing = resolveCanvasSizing(schema.canvas);
1399
+ const persistence = resolvePersistence(schema.persistence);
1400
+ const settingsTransfer = resolveSettingsTransfer({
1401
+ controls: schema.panels.controls,
1402
+ panels: schema.panels,
1403
+ persistence,
1404
+ settingsTransfer: schema.settingsTransfer,
1405
+ });
1406
+ const canvas = {
1407
+ ...schema.canvas,
1408
+ draggable: canvasEnabled ? (schema.canvas.draggable ?? true) : false,
1409
+ size: canvasSize ?? defaultCanvasSize,
1410
+ sizeSource: canvasSize ? ("app" as const) : ("runtime-default" as const),
1411
+ sizing: canvasSizing,
1412
+ upload: schema.canvas.upload ?? false,
1413
+ };
1414
+ const panels = normalizePanels({
1415
+ canvas,
1416
+ panels: schema.panels,
1417
+ settingsTransfer,
1418
+ });
1419
+ const toolbar = {
1420
+ history: schema.toolbar?.history ?? canvasEnabled,
1421
+ radar: schema.toolbar?.radar ?? canvasEnabled,
1422
+ theme: schema.toolbar?.theme ?? true,
1423
+ zoom: schema.toolbar?.zoom ?? canvasEnabled,
1424
+ };
1425
+ const exportSchema = resolveExport(schema.export);
1426
+
1427
+ assertPanelPersistenceContract({ panels, persistence, toolbar });
1428
+
1429
+ return {
1430
+ assembly: createToolcraftAssembly({
1431
+ canvas,
1432
+ panels,
1433
+ toolbar,
1434
+ }),
1435
+ canvas,
1436
+ export: exportSchema,
1437
+ panels,
1438
+ persistence,
1439
+ settingsTransfer,
1440
+ toolbar,
1441
+ };
1442
+ }