@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,2646 @@
1
+ import {
2
+ getToolcraftControlKeyframeCapability,
3
+ getToolcraftSettingsTransferEligibility,
4
+ } from "@repo/toolcraft-runtime";
5
+ import type {
6
+ ToolcraftActionSchema,
7
+ ToolcraftControlConditionSchema,
8
+ ToolcraftControlOrderRole,
9
+ ToolcraftControlSchema,
10
+ ResolvedToolcraftAppSchema,
11
+ } from "@repo/toolcraft-runtime";
12
+
13
+ import { starterSchema } from "./starter-schema";
14
+
15
+ export type ToolcraftAcceptanceEvidence =
16
+ | "command-side-effect"
17
+ | "exported-bytes"
18
+ | "media-lifecycle"
19
+ | "persistence-state"
20
+ | "product-output"
21
+ | "rendered-pixels"
22
+ | "timeline-output"
23
+ | "viewport-side-effect";
24
+
25
+ export type ToolcraftReferenceCoverage =
26
+ | "canvas-sizing"
27
+ | "control-mapping"
28
+ | "export-at-time"
29
+ | "export-copy"
30
+ | "media-lifecycle"
31
+ | "pause-resume"
32
+ | "renderer-loop"
33
+ | "renderer-state"
34
+ | "restart"
35
+ | "spawn-update-cadence"
36
+ | "time-progress";
37
+
38
+ export type ToolcraftReferenceTimelineCoverage =
39
+ | "all-range"
40
+ | "duration"
41
+ | "export-at-time"
42
+ | "export-range"
43
+ | "jump-to-trim-start"
44
+ | "keyframes"
45
+ | "loop"
46
+ | "playback"
47
+ | "range-playback"
48
+ | "restart"
49
+ | "scrub"
50
+ | "state-jump"
51
+ | "time-progress"
52
+ | "trim-range";
53
+
54
+ export type ToolcraftTimelinePlaybackCoverage =
55
+ | "duration"
56
+ | "loop"
57
+ | "pause-resume"
58
+ | "rendered-frame"
59
+ | "scrub";
60
+
61
+ export type ToolcraftCanvasSizingCoverage = "fixed-output-size";
62
+
63
+ export type ToolcraftPersistenceCoverage = "reload";
64
+
65
+ export type ToolcraftSettingsTransferCoverage = "opt-out";
66
+
67
+ export type ToolcraftAutonomousAnimationCoverage =
68
+ | "no-duration-control"
69
+ | "no-export-at-time"
70
+ | "no-loop-control"
71
+ | "no-play-pause"
72
+ | "no-scrub"
73
+ | "no-user-facing-transport";
74
+
75
+ export type ToolcraftAnimationIntent =
76
+ | {
77
+ mode: "none";
78
+ }
79
+ | {
80
+ behaviorCoverage: readonly ToolcraftAutonomousAnimationCoverage[];
81
+ mode: "autonomous";
82
+ reason: string;
83
+ }
84
+ | {
85
+ mode: "timeline-keyframes";
86
+ }
87
+ | {
88
+ mode: "timeline-playback";
89
+ };
90
+
91
+ export type ToolcraftReferenceTimelineMode =
92
+ | "custom-reference-timeline"
93
+ | "none"
94
+ | "toolcraft-keyframes"
95
+ | "toolcraft-playback";
96
+
97
+ export type ToolcraftReferenceTimelineContract = {
98
+ behaviorCoverage: readonly ToolcraftReferenceTimelineCoverage[];
99
+ mode: ToolcraftReferenceTimelineMode;
100
+ };
101
+
102
+ export type ToolcraftLayerCoverage =
103
+ | "grouping"
104
+ | "media-lifecycle"
105
+ | "reorder"
106
+ | "selected-layer-controls"
107
+ | "selection"
108
+ | "visibility";
109
+
110
+ export type ToolcraftControlPartCoverage =
111
+ | "anchorGrid.position"
112
+ | "channelMixer.activeChannel"
113
+ | "channelMixer.values"
114
+ | "curves.activeChannel"
115
+ | "curves.points"
116
+ | "colorOpacity.hex"
117
+ | "colorOpacity.opacity"
118
+ | "fontPicker.color"
119
+ | "fontPicker.fontId"
120
+ | "fontPicker.fontSize"
121
+ | "fontPicker.fontWeight"
122
+ | "fontPicker.letterSpacing"
123
+ | "fontPicker.lineHeight"
124
+ | "fontPicker.opacity"
125
+ | "fontPicker.textCase"
126
+ | "gradient.angle"
127
+ | "gradient.gradientType"
128
+ | "gradient.stops.color"
129
+ | "gradient.stops.opacity"
130
+ | "gradient.stops.position"
131
+ | "palette.family"
132
+ | "palette.shade"
133
+ | "rangeInput.end"
134
+ | "rangeInput.start"
135
+ | "rangeSlider.lower"
136
+ | "rangeSlider.upper"
137
+ | "vector.x"
138
+ | "vector.y";
139
+
140
+ export type ToolcraftCustomControlCoverage =
141
+ | "built-in-gap"
142
+ | "kit-primitives"
143
+ | "minimal-ui"
144
+ | "product-output"
145
+ | "runtime-state";
146
+
147
+ const builtInToolcraftControlTypeValues = [
148
+ "actions",
149
+ "anchorGrid",
150
+ "channelMixer",
151
+ "checkbox",
152
+ "code",
153
+ "color",
154
+ "colorOpacity",
155
+ "curves",
156
+ "fileDrop",
157
+ "fontPicker",
158
+ "gradient",
159
+ "imagePicker",
160
+ "palette",
161
+ "panelActions",
162
+ "rangeInput",
163
+ "rangeSlider",
164
+ "segmented",
165
+ "select",
166
+ "settingsTransfer",
167
+ "slider",
168
+ "switch",
169
+ "text",
170
+ "vector",
171
+ ] as const;
172
+ const settingsTransferOptOutReasonPattern =
173
+ /\b(ephemeral|temporary|one-off|not portable|session-only)\b/i;
174
+
175
+ export type ToolcraftBuiltInControlType =
176
+ (typeof builtInToolcraftControlTypeValues)[number];
177
+
178
+ export type ToolcraftBuiltInFitCheck = {
179
+ checkedBuiltIns: readonly ToolcraftBuiltInControlType[];
180
+ closestBuiltIn: ToolcraftBuiltInControlType | "none";
181
+ productObservable: string;
182
+ whyInsufficient: string;
183
+ };
184
+
185
+ export type ToolcraftTransferMode =
186
+ | {
187
+ animationIntent?: ToolcraftAnimationIntent;
188
+ mode: "new-toolcraft-app";
189
+ }
190
+ | {
191
+ animationIntent?: ToolcraftAnimationIntent;
192
+ behaviorCoverage: readonly ToolcraftReferenceCoverage[];
193
+ mode: "reference-runtime-clone";
194
+ referenceName: string;
195
+ referenceTimeline: ToolcraftReferenceTimelineContract;
196
+ sourceOfTruth: "reference-runtime";
197
+ };
198
+
199
+ export type ToolcraftProductReadiness =
200
+ | {
201
+ mode: "starter";
202
+ reason: string;
203
+ }
204
+ | {
205
+ mode: "product";
206
+ productName: string;
207
+ productSummary: string;
208
+ requestedBehavior: string;
209
+ };
210
+
211
+ export type ToolcraftComponentAcceptance = {
212
+ actionCoverage?: readonly string[];
213
+ automated: boolean;
214
+ automatedTestName: string;
215
+ browser: boolean;
216
+ browserTestName: string;
217
+ componentType: string;
218
+ evidence: ToolcraftAcceptanceEvidence;
219
+ expectedObservable: string;
220
+ fixture: string;
221
+ id: string;
222
+ canvasHandle?: {
223
+ exportCleanTestName: string;
224
+ outputObservable: string;
225
+ testId: string;
226
+ writesTarget: string;
227
+ };
228
+ kind: "canvas-handle" | "control" | "runtime";
229
+ canvasSizingCoverage?: ToolcraftCanvasSizingCoverage;
230
+ layerCoverage?: ToolcraftLayerCoverage;
231
+ optionCoverage?: "each-visible-item" | readonly string[];
232
+ persistenceCoverage?: ToolcraftPersistenceCoverage;
233
+ referenceCoverage?: ToolcraftReferenceCoverage;
234
+ referenceTimelineCoverage?: ToolcraftReferenceTimelineCoverage;
235
+ settingsTransferCoverage?: ToolcraftSettingsTransferCoverage;
236
+ target?: string;
237
+ timelineCoverage?: "keyframes" | "playback";
238
+ timelinePlaybackCoverage?:
239
+ | "all-playback-behavior"
240
+ | readonly ToolcraftTimelinePlaybackCoverage[];
241
+ controlPartCoverage?:
242
+ | "all-visible-parts"
243
+ | readonly ToolcraftControlPartCoverage[];
244
+ customControlCoverage?:
245
+ | "all-custom-control-behavior"
246
+ | readonly ToolcraftCustomControlCoverage[];
247
+ builtInFitCheck?: ToolcraftBuiltInFitCheck;
248
+ userAction: string;
249
+ };
250
+
251
+ export type ToolcraftVisibleControl = {
252
+ control: ToolcraftControlSchema;
253
+ controlId: string;
254
+ sectionTitle?: string;
255
+ };
256
+
257
+ export type ToolcraftControlOrderItem = {
258
+ controlId: string;
259
+ rank: number;
260
+ role: ToolcraftControlOrderRole;
261
+ sectionTitle?: string;
262
+ target: string;
263
+ type: string;
264
+ };
265
+
266
+ export const starterTransferMode: ToolcraftTransferMode = {
267
+ animationIntent: { mode: "none" },
268
+ mode: "new-toolcraft-app",
269
+ };
270
+
271
+ export const starterProductReadiness: ToolcraftProductReadiness = {
272
+ mode: "starter",
273
+ reason:
274
+ "Neutral Toolcraft template before a product schema, renderer, and acceptance matrix are authored.",
275
+ };
276
+
277
+ export const starterAcceptance: readonly ToolcraftComponentAcceptance[] = [];
278
+
279
+ function getActionValue(action: ToolcraftActionSchema | string): string {
280
+ return typeof action === "string" ? action : action.value;
281
+ }
282
+
283
+ function getActionSearchText(action: ToolcraftActionSchema | string): string {
284
+ return typeof action === "string" ? action : `${action.label} ${action.value} ${action.command ?? ""}`;
285
+ }
286
+
287
+ function isCanvasSizeTarget(target: string): boolean {
288
+ return target === "canvas.size.width" || target === "canvas.size.height";
289
+ }
290
+
291
+ function isResetPanelAction(action: ToolcraftActionSchema | string): boolean {
292
+ return /\breset\b/i.test(getActionSearchText(action));
293
+ }
294
+
295
+ function getControlOptionValues(control: ToolcraftControlSchema): readonly string[] {
296
+ if (control.type === "imagePicker") {
297
+ return control.items?.map((item) => item.value) ?? [];
298
+ }
299
+
300
+ return control.options?.map((option) => option.value) ?? [];
301
+ }
302
+
303
+ function hasCoverageForValues(
304
+ coverage: ToolcraftComponentAcceptance["actionCoverage"] | ToolcraftComponentAcceptance["optionCoverage"],
305
+ values: readonly string[],
306
+ ): boolean {
307
+ if (values.length === 0) {
308
+ return true;
309
+ }
310
+
311
+ if (coverage === "each-visible-item") {
312
+ return true;
313
+ }
314
+
315
+ if (!Array.isArray(coverage)) {
316
+ return false;
317
+ }
318
+
319
+ return values.every((value) => coverage.includes(value));
320
+ }
321
+
322
+ function hasControlPartCoverage(
323
+ coverage: ToolcraftComponentAcceptance["controlPartCoverage"],
324
+ requiredParts: readonly ToolcraftControlPartCoverage[],
325
+ ): boolean {
326
+ if (requiredParts.length === 0) {
327
+ return true;
328
+ }
329
+
330
+ if (coverage === "all-visible-parts") {
331
+ return true;
332
+ }
333
+
334
+ if (!Array.isArray(coverage)) {
335
+ return false;
336
+ }
337
+
338
+ return requiredParts.every((part) => coverage.includes(part));
339
+ }
340
+
341
+ function hasCustomControlCoverage(
342
+ coverage: ToolcraftComponentAcceptance["customControlCoverage"],
343
+ requiredParts: readonly ToolcraftCustomControlCoverage[],
344
+ ): boolean {
345
+ if (coverage === "all-custom-control-behavior") {
346
+ return true;
347
+ }
348
+
349
+ if (!Array.isArray(coverage)) {
350
+ return false;
351
+ }
352
+
353
+ return requiredParts.every((part) => coverage.includes(part));
354
+ }
355
+
356
+ function hasTimelinePlaybackCoverage(
357
+ coverage: ToolcraftComponentAcceptance["timelinePlaybackCoverage"],
358
+ requiredParts: readonly ToolcraftTimelinePlaybackCoverage[],
359
+ ): boolean {
360
+ if (coverage === "all-playback-behavior") {
361
+ return true;
362
+ }
363
+
364
+ if (!Array.isArray(coverage)) {
365
+ return false;
366
+ }
367
+
368
+ return requiredParts.every((part) => coverage.includes(part));
369
+ }
370
+
371
+ function hasTimelinePlaybackCoveragePart(
372
+ coverage: ToolcraftComponentAcceptance["timelinePlaybackCoverage"],
373
+ part: ToolcraftTimelinePlaybackCoverage,
374
+ ): boolean {
375
+ return coverage === "all-playback-behavior" || (Array.isArray(coverage) && coverage.includes(part));
376
+ }
377
+
378
+ function getAcceptanceEvidenceText(entry: ToolcraftComponentAcceptance): string {
379
+ return [
380
+ entry.automatedTestName,
381
+ entry.browserTestName,
382
+ entry.expectedObservable,
383
+ entry.fixture,
384
+ entry.userAction,
385
+ ].join(" ");
386
+ }
387
+
388
+ const conditionOperatorLabels = [
389
+ "equals",
390
+ "notEquals",
391
+ "oneOf",
392
+ "notOneOf",
393
+ "greaterThan",
394
+ "greaterThanOrEqual",
395
+ "lessThan",
396
+ "lessThanOrEqual",
397
+ ] as const satisfies readonly (keyof ToolcraftControlConditionSchema)[];
398
+
399
+ function hasConditionOperator(condition: ToolcraftControlConditionSchema): boolean {
400
+ return conditionOperatorLabels.some((operator) => operator in condition);
401
+ }
402
+
403
+ function getConditionValidationErrors({
404
+ condition,
405
+ conditionName,
406
+ controlTargets,
407
+ label,
408
+ }: {
409
+ condition: ToolcraftControlConditionSchema;
410
+ conditionName: "disabledWhen" | "visibleWhen";
411
+ controlTargets: ReadonlySet<string>;
412
+ label: string;
413
+ }): string[] {
414
+ const errors: string[] = [];
415
+
416
+ if (!hasConditionOperator(condition)) {
417
+ errors.push(
418
+ `${label} ${conditionName} must declare one of equals, notEquals, oneOf, notOneOf, greaterThan, greaterThanOrEqual, lessThan, or lessThanOrEqual so the dependent state is deterministic.`,
419
+ );
420
+ }
421
+
422
+ for (const arrayOperator of ["oneOf", "notOneOf"] as const) {
423
+ if (
424
+ arrayOperator in condition &&
425
+ (!Array.isArray(condition[arrayOperator]) ||
426
+ condition[arrayOperator]?.length === 0)
427
+ ) {
428
+ errors.push(
429
+ `${label} ${conditionName}.${arrayOperator} must be a non-empty array.`,
430
+ );
431
+ }
432
+ }
433
+
434
+ for (const numericOperator of [
435
+ "greaterThan",
436
+ "greaterThanOrEqual",
437
+ "lessThan",
438
+ "lessThanOrEqual",
439
+ ] as const) {
440
+ if (
441
+ numericOperator in condition &&
442
+ (typeof condition[numericOperator] !== "number" ||
443
+ !Number.isFinite(condition[numericOperator]))
444
+ ) {
445
+ errors.push(
446
+ `${label} ${conditionName}.${numericOperator} must be a finite number.`,
447
+ );
448
+ }
449
+ }
450
+
451
+ if (
452
+ !controlTargets.has(condition.target) &&
453
+ !isCanvasSizeTarget(condition.target)
454
+ ) {
455
+ errors.push(
456
+ `${label} ${conditionName} target ${condition.target} does not match another schema control target or canvas size target.`,
457
+ );
458
+ }
459
+
460
+ return errors;
461
+ }
462
+
463
+ export function getRequiredToolcraftControlPartCoverage(
464
+ control: ToolcraftControlSchema,
465
+ ): readonly ToolcraftControlPartCoverage[] {
466
+ switch (control.type) {
467
+ case "anchorGrid":
468
+ return ["anchorGrid.position"];
469
+ case "channelMixer":
470
+ return ["channelMixer.activeChannel", "channelMixer.values"];
471
+ case "curves":
472
+ return control.variant === "single"
473
+ ? ["curves.points"]
474
+ : ["curves.activeChannel", "curves.points"];
475
+ case "fontPicker":
476
+ return [
477
+ "fontPicker.fontId",
478
+ "fontPicker.fontWeight",
479
+ "fontPicker.fontSize",
480
+ "fontPicker.letterSpacing",
481
+ "fontPicker.lineHeight",
482
+ "fontPicker.textCase",
483
+ "fontPicker.color",
484
+ "fontPicker.opacity",
485
+ ];
486
+ case "gradient":
487
+ return [
488
+ "gradient.gradientType",
489
+ "gradient.angle",
490
+ "gradient.stops.position",
491
+ "gradient.stops.color",
492
+ "gradient.stops.opacity",
493
+ ];
494
+ case "palette":
495
+ return ["palette.family", "palette.shade"];
496
+ case "rangeInput":
497
+ return ["rangeInput.start", "rangeInput.end"];
498
+ case "rangeSlider":
499
+ return ["rangeSlider.lower", "rangeSlider.upper"];
500
+ case "vector":
501
+ return ["vector.x", "vector.y"];
502
+ default:
503
+ return [];
504
+ }
505
+ }
506
+
507
+ const builtInToolcraftControlTypes = new Set<string>(
508
+ builtInToolcraftControlTypeValues,
509
+ );
510
+
511
+ const requiredCustomControlCoverage: readonly ToolcraftCustomControlCoverage[] = [
512
+ "built-in-gap",
513
+ "kit-primitives",
514
+ "minimal-ui",
515
+ "product-output",
516
+ "runtime-state",
517
+ ];
518
+
519
+ function isCustomToolcraftControl(control: ToolcraftControlSchema): boolean {
520
+ return !builtInToolcraftControlTypes.has(control.type);
521
+ }
522
+
523
+ function getBuiltInFitCheckErrors(
524
+ label: string,
525
+ entry: ToolcraftComponentAcceptance,
526
+ ): string[] {
527
+ const fitCheck = entry.builtInFitCheck;
528
+
529
+ if (!fitCheck) {
530
+ return [
531
+ `${label} is a custom control and must declare builtInFitCheck with checkedBuiltIns, closestBuiltIn, whyInsufficient, and productObservable.`,
532
+ ];
533
+ }
534
+
535
+ const errors: string[] = [];
536
+ const checkedBuiltIns = Array.isArray(fitCheck.checkedBuiltIns)
537
+ ? fitCheck.checkedBuiltIns
538
+ : [];
539
+
540
+ if (checkedBuiltIns.length === 0) {
541
+ errors.push(
542
+ `${label} builtInFitCheck.checkedBuiltIns must name at least one checked built-in control.`,
543
+ );
544
+ }
545
+
546
+ const unknownCheckedBuiltIns = checkedBuiltIns.filter(
547
+ (builtIn) => !builtInToolcraftControlTypes.has(builtIn),
548
+ );
549
+
550
+ if (unknownCheckedBuiltIns.length > 0) {
551
+ errors.push(
552
+ `${label} builtInFitCheck.checkedBuiltIns contains unknown built-in controls: ${unknownCheckedBuiltIns.join(", ")}.`,
553
+ );
554
+ }
555
+
556
+ if (
557
+ fitCheck.closestBuiltIn !== "none" &&
558
+ !checkedBuiltIns.includes(fitCheck.closestBuiltIn)
559
+ ) {
560
+ errors.push(
561
+ `${label} builtInFitCheck.closestBuiltIn must be one of the checked built-ins or "none".`,
562
+ );
563
+ }
564
+
565
+ if (fitCheck.whyInsufficient.trim().length < 24) {
566
+ errors.push(
567
+ `${label} builtInFitCheck.whyInsufficient must explain why the closest built-in cannot express the product interaction.`,
568
+ );
569
+ }
570
+
571
+ if (fitCheck.productObservable.trim().length < 24) {
572
+ errors.push(
573
+ `${label} builtInFitCheck.productObservable must name the product output or side effect that proves the custom control is necessary.`,
574
+ );
575
+ }
576
+
577
+ return errors;
578
+ }
579
+
580
+ function isSliderLikeControl(control: ToolcraftControlSchema): boolean {
581
+ return control.type === "slider" || control.type === "rangeSlider";
582
+ }
583
+
584
+ const SMALL_SEMANTIC_DISCRETE_POSITION_LIMIT = 13;
585
+ const MAX_VISUAL_DISCRETE_POSITION_COUNT = 32;
586
+ const SEMANTIC_DISCRETE_SLIDER_RE =
587
+ /\b(anchor|band|bands|cell|cells|col|cols|column|columns|count|gap|grid|jitter|level|levels|octave|octaves|pass|passes|point|points|row|rows|segment|segments|step|steps|tile|tiles)\b/i;
588
+ const FINITE_ANIMATION_STEP_SLIDER_RE =
589
+ /\b(char|chars|character|characters|flip|flips|glyph|glyphs|frame|frames|letter|letters)\b/i;
590
+ const FINITE_ANIMATION_STEP_VALUE_RE = /\b(count|depth|step|steps)\b/i;
591
+ const SEMANTIC_CONTINUOUS_SLIDER_RE =
592
+ /\b(duration|fps|frame rate|frames per second|rate|speed|time|seconds?|ms|milliseconds?|hz|cols\/s|ch\/s)\b/i;
593
+
594
+ function getStepPositionCount(control: ToolcraftControlSchema): number | undefined {
595
+ if (
596
+ typeof control.step !== "number" ||
597
+ typeof control.min !== "number" ||
598
+ typeof control.max !== "number" ||
599
+ !Number.isFinite(control.step) ||
600
+ !Number.isFinite(control.min) ||
601
+ !Number.isFinite(control.max) ||
602
+ control.step <= 0 ||
603
+ control.max <= control.min
604
+ ) {
605
+ return undefined;
606
+ }
607
+
608
+ const rawStepCount = (control.max - control.min) / control.step;
609
+ const roundedStepCount = Math.round(rawStepCount);
610
+ const intervalCount =
611
+ Math.abs(rawStepCount - roundedStepCount) < Number.EPSILON * 100
612
+ ? roundedStepCount
613
+ : Math.floor(rawStepCount) + 1;
614
+
615
+ return Math.max(2, intervalCount + 1);
616
+ }
617
+
618
+ function getStepMarkerCount(control: ToolcraftControlSchema): number | undefined {
619
+ return getStepPositionCount(control);
620
+ }
621
+
622
+ function isIntegerStepDomain(control: ToolcraftControlSchema): boolean {
623
+ return (
624
+ typeof control.min === "number" &&
625
+ typeof control.max === "number" &&
626
+ typeof control.step === "number" &&
627
+ Number.isInteger(control.min) &&
628
+ Number.isInteger(control.max) &&
629
+ Number.isInteger(control.step)
630
+ );
631
+ }
632
+
633
+ function getSliderSemanticText(
634
+ controlId: string,
635
+ control: ToolcraftControlSchema,
636
+ ): string {
637
+ return [
638
+ controlId,
639
+ control.target,
640
+ getControlLabelText(control),
641
+ typeof control.unit === "string" ? control.unit : "",
642
+ ].join(" ");
643
+ }
644
+
645
+ function shouldUseVisualDiscreteSlider(
646
+ controlId: string,
647
+ control: ToolcraftControlSchema,
648
+ ): boolean {
649
+ const positionCount = getStepPositionCount(control);
650
+
651
+ if (!positionCount || !isIntegerStepDomain(control)) {
652
+ return false;
653
+ }
654
+
655
+ const semanticText = getSliderSemanticText(controlId, control);
656
+
657
+ if (SEMANTIC_CONTINUOUS_SLIDER_RE.test(semanticText)) {
658
+ return false;
659
+ }
660
+
661
+ const hasFiniteAnimationStepSemantics =
662
+ FINITE_ANIMATION_STEP_SLIDER_RE.test(semanticText) &&
663
+ FINITE_ANIMATION_STEP_VALUE_RE.test(semanticText);
664
+
665
+ if (hasFiniteAnimationStepSemantics) {
666
+ return positionCount <= MAX_VISUAL_DISCRETE_POSITION_COUNT;
667
+ }
668
+
669
+ if (positionCount > SMALL_SEMANTIC_DISCRETE_POSITION_LIMIT) {
670
+ return false;
671
+ }
672
+
673
+ return SEMANTIC_DISCRETE_SLIDER_RE.test(semanticText);
674
+ }
675
+
676
+ function getSliderVariantClassificationErrors({
677
+ control,
678
+ controlId,
679
+ label,
680
+ }: {
681
+ control: ToolcraftControlSchema;
682
+ controlId: string;
683
+ label: string;
684
+ }): string[] {
685
+ const errors: string[] = [];
686
+ const positionCount = getStepPositionCount(control);
687
+
688
+ if (!positionCount) {
689
+ return errors;
690
+ }
691
+
692
+ if (
693
+ shouldUseVisualDiscreteSlider(controlId, control) &&
694
+ control.variant !== "discrete"
695
+ ) {
696
+ errors.push(
697
+ `${label} has ${positionCount} semantic integer positions and must use variant "discrete" so Toolcraft renders tick markers.`,
698
+ );
699
+ }
700
+
701
+ if (
702
+ control.variant === "discrete" &&
703
+ positionCount > MAX_VISUAL_DISCRETE_POSITION_COUNT
704
+ ) {
705
+ errors.push(
706
+ `${label} declares variant "discrete" with ${positionCount} positions, which would overload tick markers. Keep it stepped continuous or use a different control.`,
707
+ );
708
+ }
709
+
710
+ return errors;
711
+ }
712
+
713
+ function getControlLabelText(control: ToolcraftControlSchema): string {
714
+ return typeof control.label === "string" ? control.label : "";
715
+ }
716
+
717
+ const singleCurveSemanticPattern =
718
+ /\b(acceleration|accel|bend|easing|ease|response|depth|mask|opacity|alpha|motion|velocity|threshold|falloff|remap|remapping)\b|speed\s+profile|mapping\s+curve|curve\s+mapping/i;
719
+ const rgbCurveSemanticPattern =
720
+ /\b(rgb|rgba|channel|channels|red|green|blue|color\s*correction|colour\s*correction|color\s*grading|colour\s*grading|color\s*grade|colour\s*grade|color\s*curve|colour\s*curve|tone\s*mapping|hue|saturation|chroma)\b/i;
721
+
722
+ function getCurveSemanticText(
723
+ controlId: string,
724
+ control: ToolcraftControlSchema,
725
+ ): string {
726
+ return [
727
+ controlId,
728
+ control.target,
729
+ getControlLabelText(control),
730
+ control.description ?? "",
731
+ ]
732
+ .join(" ")
733
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2");
734
+ }
735
+
736
+ function shouldUseSingleCurveVariant(
737
+ controlId: string,
738
+ control: ToolcraftControlSchema,
739
+ ): boolean {
740
+ if (control.type !== "curves" || control.variant === "single") {
741
+ return false;
742
+ }
743
+
744
+ const text = getCurveSemanticText(controlId, control);
745
+
746
+ return singleCurveSemanticPattern.test(text) && !rgbCurveSemanticPattern.test(text);
747
+ }
748
+
749
+ function getToggleControlLabelError(
750
+ control: ToolcraftControlSchema,
751
+ sectionTitle?: string,
752
+ ): string | undefined {
753
+ if (control.type !== "switch" && control.type !== "checkbox") {
754
+ return undefined;
755
+ }
756
+
757
+ const label = getControlLabelText(control).trim();
758
+
759
+ if (/^(enable|disable)\b/i.test(label)) {
760
+ return `toggle labels must name the setting context only; use "CRT", "Background", "Glow", or "Loop" instead of "${label}".`;
761
+ }
762
+
763
+ if (
764
+ label &&
765
+ sectionTitle &&
766
+ normalizeToolcraftSemanticText(label) ===
767
+ normalizeToolcraftSemanticText(sectionTitle)
768
+ ) {
769
+ return `toggle label "${label}" duplicates section title "${sectionTitle}". Use label false for a visual-only toggle or rename the toggle to a more specific setting.`;
770
+ }
771
+
772
+ return undefined;
773
+ }
774
+
775
+ const maxInlineSwitchLabelLength = 16;
776
+ const maxInlineSwitchLabelWordCount = 2;
777
+
778
+ function getInlineSwitchLabelText(
779
+ controlId: string,
780
+ control: ToolcraftControlSchema,
781
+ ): string {
782
+ if (control.label === false) {
783
+ return "";
784
+ }
785
+
786
+ const label = getControlLabelText(control).trim();
787
+
788
+ return label || controlId;
789
+ }
790
+
791
+ function isInlineSwitchLabelSafe(
792
+ controlId: string,
793
+ control: ToolcraftControlSchema,
794
+ ): boolean {
795
+ const label = getInlineSwitchLabelText(controlId, control);
796
+
797
+ if (!label) {
798
+ return true;
799
+ }
800
+
801
+ const wordCount = label.split(/\s+/u).filter(Boolean).length;
802
+
803
+ return label.length <= maxInlineSwitchLabelLength && wordCount <= maxInlineSwitchLabelWordCount;
804
+ }
805
+
806
+ function isBooleanControl(control: ToolcraftControlSchema | undefined): boolean {
807
+ return control?.type === "checkbox" || control?.type === "switch";
808
+ }
809
+
810
+ function controlsShareToolcraftTargetEntity(
811
+ firstControl: ToolcraftControlSchema,
812
+ secondControl: ToolcraftControlSchema,
813
+ ): boolean {
814
+ const firstPrefix = getToolcraftLooseTargetPrefix(firstControl.target);
815
+ const secondPrefix = getToolcraftLooseTargetPrefix(secondControl.target);
816
+
817
+ return Boolean(firstPrefix && firstPrefix === secondPrefix);
818
+ }
819
+
820
+ function sectionHasInlineLayoutGroupForPair(
821
+ section: NonNullable<ResolvedToolcraftAppSchema["panels"]["controls"]>["sections"][number],
822
+ firstControlId: string,
823
+ secondControlId: string,
824
+ ): boolean {
825
+ return (section.layoutGroups ?? []).some(
826
+ (layoutGroup) =>
827
+ layoutGroup.layout === "inline" &&
828
+ layoutGroup.columns === 2 &&
829
+ layoutGroup.controls.length === 2 &&
830
+ layoutGroup.controls.includes(firstControlId) &&
831
+ layoutGroup.controls.includes(secondControlId),
832
+ );
833
+ }
834
+
835
+ function getControlActions(
836
+ control: ToolcraftControlSchema,
837
+ ): readonly (ToolcraftActionSchema | string)[] {
838
+ const maybeControlWithActions = control as {
839
+ actions?: readonly (ToolcraftActionSchema | string)[];
840
+ };
841
+
842
+ return Array.isArray(maybeControlWithActions.actions)
843
+ ? maybeControlWithActions.actions
844
+ : [];
845
+ }
846
+
847
+ function getTimelineTransportControlText(
848
+ controlId: string,
849
+ control: ToolcraftControlSchema,
850
+ ): string {
851
+ return [
852
+ controlId,
853
+ control.target,
854
+ getControlLabelText(control),
855
+ ...getControlActions(control).map(getActionSearchText),
856
+ ].join(" ");
857
+ }
858
+
859
+ function getAnimationIntentControlText({
860
+ control,
861
+ controlId,
862
+ sectionTitle,
863
+ }: ToolcraftVisibleControl): string {
864
+ return [
865
+ sectionTitle ?? "",
866
+ controlId,
867
+ control.target,
868
+ getControlLabelText(control),
869
+ ].join(" ");
870
+ }
871
+
872
+ function getSearchableControlText({
873
+ control,
874
+ controlId,
875
+ sectionTitle,
876
+ }: ToolcraftVisibleControl): string {
877
+ return [
878
+ sectionTitle ?? "",
879
+ controlId,
880
+ control.target,
881
+ getControlLabelText(control),
882
+ ]
883
+ .join(" ")
884
+ .replace(/([a-z])([A-Z])/g, "$1 $2");
885
+ }
886
+
887
+ function actionLooksLikePngExport(action: ToolcraftActionSchema | string): boolean {
888
+ const text = getActionSearchText(action).replace(/([a-z])([A-Z])/g, "$1 $2");
889
+
890
+ return (
891
+ (/\b(export|download)\b/i.test(text) && /\b(png|image)\b/i.test(text)) ||
892
+ /\bexport\.png\b/i.test(text)
893
+ );
894
+ }
895
+
896
+ function schemaHasPngExportPanelAction(schema: ResolvedToolcraftAppSchema): boolean {
897
+ return (schema.panels.controls?.sections ?? []).some((section) =>
898
+ Object.values(section.controls).some(
899
+ (control) =>
900
+ control.type === "panelActions" &&
901
+ getControlActions(control).some(actionLooksLikePngExport),
902
+ ),
903
+ );
904
+ }
905
+
906
+ function schemaHasOutputBackgroundColorControl(
907
+ controls: readonly ToolcraftVisibleControl[],
908
+ ): boolean {
909
+ return controls.some((visibleControl) => {
910
+ const { control } = visibleControl;
911
+
912
+ if (control.type !== "color") {
913
+ return false;
914
+ }
915
+
916
+ return /\b(background|backdrop|scene|canvas)\b/i.test(
917
+ getSearchableControlText(visibleControl),
918
+ );
919
+ });
920
+ }
921
+
922
+ function schemaHasOutputBackgroundToggleControl(
923
+ controls: readonly ToolcraftVisibleControl[],
924
+ ): boolean {
925
+ return controls.some(isOutputBackgroundToggleControl);
926
+ }
927
+
928
+ function isOutputBackgroundToggleControl(visibleControl: ToolcraftVisibleControl): boolean {
929
+ const { control } = visibleControl;
930
+
931
+ if (
932
+ control.type !== "switch" &&
933
+ control.type !== "checkbox" &&
934
+ control.type !== "select" &&
935
+ control.type !== "segmented"
936
+ ) {
937
+ return false;
938
+ }
939
+
940
+ return /\b(background|backdrop|transparent|transparency|alpha)\b/i.test(
941
+ getSearchableControlText(visibleControl),
942
+ );
943
+ }
944
+
945
+ const SEGMENTED_CONTROL_MAX_OPTIONS = 4;
946
+ const SEGMENTED_CONTROL_MAX_OPTION_LABEL_LENGTH = 9;
947
+ const SEGMENTED_CONTROL_MAX_TOTAL_LABEL_LENGTH = 24;
948
+
949
+ function getSegmentedControlLayoutError(
950
+ control: ToolcraftControlSchema,
951
+ ): string | null {
952
+ if (control.type !== "segmented") {
953
+ return null;
954
+ }
955
+
956
+ const labels = control.options?.map((option) => option.label.trim()) ?? [];
957
+ const totalLabelLength = labels.reduce((total, label) => total + label.length, 0);
958
+ const longLabels = labels.filter(
959
+ (label) => label.length > SEGMENTED_CONTROL_MAX_OPTION_LABEL_LENGTH,
960
+ );
961
+
962
+ if (
963
+ labels.length > SEGMENTED_CONTROL_MAX_OPTIONS ||
964
+ longLabels.length > 0 ||
965
+ totalLabelLength > SEGMENTED_CONTROL_MAX_TOTAL_LABEL_LENGTH
966
+ ) {
967
+ return [
968
+ `segmented controls must preserve cell padding: use at most ${SEGMENTED_CONTROL_MAX_OPTIONS} short options`,
969
+ `(max ${SEGMENTED_CONTROL_MAX_OPTION_LABEL_LENGTH} characters per label and ${SEGMENTED_CONTROL_MAX_TOTAL_LABEL_LENGTH} total)`,
970
+ "or shorten labels first; if the compact names still exceed the budget, use a select dropdown instead.",
971
+ ].join(" ");
972
+ }
973
+
974
+ return null;
975
+ }
976
+
977
+ const controlOrderRoleRanks = {
978
+ input: 0,
979
+ mode: 1,
980
+ primary: 2,
981
+ spatial: 2,
982
+ color: 2,
983
+ strength: 3,
984
+ detail: 4,
985
+ advanced: 5,
986
+ action: 6,
987
+ } satisfies Record<ToolcraftControlOrderRole, number>;
988
+
989
+ const requiredReferenceCloneCoverage = [
990
+ "canvas-sizing",
991
+ "control-mapping",
992
+ "renderer-state",
993
+ ] satisfies readonly ToolcraftReferenceCoverage[];
994
+
995
+ const referenceTransportCoverage = new Set<ToolcraftReferenceCoverage>([
996
+ "export-at-time",
997
+ "pause-resume",
998
+ "restart",
999
+ "time-progress",
1000
+ ]);
1001
+
1002
+ const toolcraftReferenceTimelineCoverage = new Set<ToolcraftReferenceTimelineCoverage>([
1003
+ "duration",
1004
+ "export-at-time",
1005
+ "keyframes",
1006
+ "loop",
1007
+ "playback",
1008
+ "restart",
1009
+ "scrub",
1010
+ "time-progress",
1011
+ ]);
1012
+
1013
+ const customReferenceTimelineCoverage = new Set<ToolcraftReferenceTimelineCoverage>([
1014
+ "all-range",
1015
+ "export-range",
1016
+ "jump-to-trim-start",
1017
+ "range-playback",
1018
+ "state-jump",
1019
+ "trim-range",
1020
+ ]);
1021
+
1022
+ const timelineTransportControlPattern =
1023
+ /\b(play|pause|paused|resume|animate|restart)\b/i;
1024
+
1025
+ const animationIntentControlPattern =
1026
+ /\b(animation|animate|motion|playback)\b/i;
1027
+
1028
+ const requiredAutonomousAnimationCoverage = [
1029
+ "no-user-facing-transport",
1030
+ "no-play-pause",
1031
+ "no-scrub",
1032
+ "no-duration-control",
1033
+ "no-loop-control",
1034
+ "no-export-at-time",
1035
+ ] satisfies readonly ToolcraftAutonomousAnimationCoverage[];
1036
+
1037
+ const requiredLayerCoverage = [
1038
+ "selection",
1039
+ "visibility",
1040
+ "reorder",
1041
+ "grouping",
1042
+ ] satisfies readonly ToolcraftLayerCoverage[];
1043
+
1044
+ const requiredTimelinePlaybackCoverage = [
1045
+ "pause-resume",
1046
+ "scrub",
1047
+ "duration",
1048
+ "loop",
1049
+ "rendered-frame",
1050
+ ] satisfies readonly ToolcraftTimelinePlaybackCoverage[];
1051
+
1052
+ function isModeSelectorControl(
1053
+ controlId: string,
1054
+ control: ToolcraftControlSchema,
1055
+ ): boolean {
1056
+ if (control.type !== "select" && control.type !== "segmented") {
1057
+ return false;
1058
+ }
1059
+
1060
+ return /mode|type|filter|blend|style|preset|variant/i.test(
1061
+ `${controlId} ${control.target} ${getControlLabelText(control)}`,
1062
+ );
1063
+ }
1064
+
1065
+ function matchesControlMeaning(
1066
+ controlId: string,
1067
+ control: ToolcraftControlSchema,
1068
+ pattern: RegExp,
1069
+ ): boolean {
1070
+ return pattern.test(`${controlId} ${control.target} ${getControlLabelText(control)}`);
1071
+ }
1072
+
1073
+ export function inferToolcraftControlOrderRole(
1074
+ controlId: string,
1075
+ control: ToolcraftControlSchema,
1076
+ ): ToolcraftControlOrderRole {
1077
+ if (control.orderRole) {
1078
+ return control.orderRole;
1079
+ }
1080
+
1081
+ if (control.type === "panelActions") {
1082
+ return "action";
1083
+ }
1084
+
1085
+ if (
1086
+ control.type === "fileDrop" ||
1087
+ control.target.startsWith("media.") ||
1088
+ control.target === "canvas.size.width" ||
1089
+ control.target === "canvas.size.height"
1090
+ ) {
1091
+ return "input";
1092
+ }
1093
+
1094
+ if (isModeSelectorControl(controlId, control)) {
1095
+ return "mode";
1096
+ }
1097
+
1098
+ if (control.type === "vector") {
1099
+ return "spatial";
1100
+ }
1101
+
1102
+ if (control.type === "color" || control.type === "gradient") {
1103
+ return "color";
1104
+ }
1105
+
1106
+ if (
1107
+ matchesControlMeaning(
1108
+ controlId,
1109
+ control,
1110
+ /grain|noise|texture|detail|blur|threshold|sample|quality|density|iteration|radius/i,
1111
+ )
1112
+ ) {
1113
+ return "detail";
1114
+ }
1115
+
1116
+ if (
1117
+ isSliderLikeControl(control) ||
1118
+ matchesControlMeaning(
1119
+ controlId,
1120
+ control,
1121
+ /amount|brightness|contrast|depth|highlight|intensity|mix|opacity|saturation|scale|spread|strength/i,
1122
+ )
1123
+ ) {
1124
+ return "strength";
1125
+ }
1126
+
1127
+ return "primary";
1128
+ }
1129
+
1130
+ function getToolcraftControlOrderErrors(schema: ResolvedToolcraftAppSchema): string[] {
1131
+ const errors: string[] = [];
1132
+
1133
+ for (const section of schema.panels.controls?.sections ?? []) {
1134
+ let previousItem: ToolcraftControlOrderItem | undefined;
1135
+
1136
+ for (const [controlId, control] of Object.entries(section.controls)) {
1137
+ if (control.type === "panelActions") {
1138
+ continue;
1139
+ }
1140
+
1141
+ const role = inferToolcraftControlOrderRole(controlId, control);
1142
+ const item: ToolcraftControlOrderItem = {
1143
+ controlId,
1144
+ rank: controlOrderRoleRanks[role],
1145
+ role,
1146
+ sectionTitle: section.title,
1147
+ target: control.target,
1148
+ type: control.type,
1149
+ };
1150
+
1151
+ if (previousItem && item.rank < previousItem.rank) {
1152
+ const sectionLabel = section.title ? `${section.title} / ` : "";
1153
+
1154
+ errors.push(
1155
+ `${sectionLabel}${controlId} (${control.target}) has orderRole "${role}" after ${previousItem.controlId} (${previousItem.target}) with orderRole "${previousItem.role}". Move mode/input/primary controls before dependent strength/detail/advanced controls or split them into an earlier section.`,
1156
+ );
1157
+ }
1158
+
1159
+ previousItem = item;
1160
+ }
1161
+ }
1162
+
1163
+ return errors;
1164
+ }
1165
+
1166
+ const genericControlSectionTitlePattern =
1167
+ /^(controls?|settings?|parameters?|options?|configuration|config|adjustments?)$/i;
1168
+
1169
+ const controlTypeSectionTitlePattern =
1170
+ /^(sliders?|colors?|colours?|inputs?|selects?|switches?|checkboxes?|toggles?|buttons?|actions?)$/i;
1171
+
1172
+ const weakControlLabelContextSectionTitlePattern =
1173
+ /^(appearance|look|looks|properties?|style|styles|values?|visuals?)$/i;
1174
+
1175
+ const broadControlSectionTitlePattern =
1176
+ /^(animation|export|flow|icon|logo|motion|output|scene|shape|shapes|text|typography|visual|visuals)$/i;
1177
+
1178
+ const genericControlLabelPattern =
1179
+ /^(angle|amount|blur|brightness|color|contrast|count|density|depth|frequency|height|hue|intensity|offset|opacity|phase|position|quality|radius|rotation|saturation|scale|size|spacing|speed|strength|threshold|tint|width)$/i;
1180
+
1181
+ const maxPreferredControlsPerSection = 7;
1182
+ const maxHardControlsPerSection = 10;
1183
+
1184
+ const controlSemanticClusterPatterns: ReadonlyArray<readonly [string, RegExp]> = [
1185
+ ["input", /\b(upload|source|prompt|content|text|phrase|copy|message|file|media|image)\b/i],
1186
+ ["mode", /\b(mode|type|preset|style|variant|blend|filter|layout|format|quality)\b/i],
1187
+ ["motion", /\b(animation|speed|velocity|accel|acceleration|correlation|duration|timing|loop|phase|fps|rate)\b/i],
1188
+ ["geometry", /\b(width|height|size|scale|position|offset|anchor|origin|target|radius|distance|spread|bend|curve|curves|path|shape|grid|gap)\b/i],
1189
+ ["density", /\b(fill|density|amount|count|ratio|word|words|letter|letters|particle|particles|layer|layers|island|islands)\b/i],
1190
+ ["color", /\b(color|colour|gradient|shade|tint|background|halo|glow|opacity|alpha|stroke|fillColor|fillOpacity)\b/i],
1191
+ ["typography", /\b(font|weight|case|leading|tracking|lineHeight|letterSpacing|typeface)\b/i],
1192
+ ["export", /\b(export|copy|download|video|png|webm|mp4|mov|bitrate|resolution)\b/i],
1193
+ ];
1194
+
1195
+ const fontPickerOwnedTypographyPartLabels = new Map<string, string>([
1196
+ ["case", "case"],
1197
+ ["color", "color"],
1198
+ ["colour", "color"],
1199
+ ["family", "font family"],
1200
+ ["fill", "color"],
1201
+ ["fillcolor", "color"],
1202
+ ["fillopacity", "opacity"],
1203
+ ["font", "font family"],
1204
+ ["fontcolor", "color"],
1205
+ ["fontfamily", "font family"],
1206
+ ["fontid", "font family"],
1207
+ ["fontsize", "font size"],
1208
+ ["fontweight", "font weight"],
1209
+ ["foreground", "color"],
1210
+ ["foregroundcolor", "color"],
1211
+ ["leading", "line height"],
1212
+ ["letterspacing", "letter spacing"],
1213
+ ["lineheight", "line height"],
1214
+ ["opacity", "opacity"],
1215
+ ["size", "font size"],
1216
+ ["textcase", "case"],
1217
+ ["textcolor", "color"],
1218
+ ["textfill", "color"],
1219
+ ["textopacity", "opacity"],
1220
+ ["tracking", "letter spacing"],
1221
+ ["typeface", "font family"],
1222
+ ["weight", "font weight"],
1223
+ ]);
1224
+
1225
+ const fontPickerDescriptionOwnedPartPatterns: ReadonlyArray<readonly [string, RegExp]> = [
1226
+ ["font family", /\b(?:font\s+family|family|typeface)\b/i],
1227
+ ["font weight", /\b(?:font\s+weight|weight)\b/i],
1228
+ ["font size", /\b(?:font\s+size|size)\b/i],
1229
+ ["case", /\b(?:text\s+case|case|uppercase|lowercase|capitalize|title\s+case)\b/i],
1230
+ ["color", /\b(?:text\s+color|font\s+color|color|colour|fill)\b/i],
1231
+ ["opacity", /\b(?:text\s+opacity|font\s+opacity|opacity|alpha)\b/i],
1232
+ ["letter spacing", /\b(?:letter\s+spacing|tracking)\b/i],
1233
+ ["line height", /\b(?:line\s+height|leading)\b/i],
1234
+ ];
1235
+
1236
+ function getToolcraftSectionLabel(sectionTitle: string | undefined, sectionIndex: number): string {
1237
+ return sectionTitle?.trim() || `untitled section ${sectionIndex + 1}`;
1238
+ }
1239
+
1240
+ function humanizeToolcraftLabelPart(value: string): string {
1241
+ const text = value
1242
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
1243
+ .replace(/[_-]+/g, " ")
1244
+ .replace(/\s+/g, " ")
1245
+ .trim();
1246
+
1247
+ if (!text) {
1248
+ return "";
1249
+ }
1250
+
1251
+ return text.replace(/\b([a-z])/g, (match) => match.toUpperCase());
1252
+ }
1253
+
1254
+ function lowerCaseToolcraftLabelStart(value: string): string {
1255
+ return value ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : value;
1256
+ }
1257
+
1258
+ function normalizeToolcraftSemanticText(value: string | undefined): string {
1259
+ return humanizeToolcraftLabelPart(value ?? "")
1260
+ .toLowerCase()
1261
+ .replace(/[^a-z0-9]+/g, "");
1262
+ }
1263
+
1264
+ function getToolcraftTargetParts(target: string): string[] {
1265
+ return target.split(".").filter(Boolean);
1266
+ }
1267
+
1268
+ function getToolcraftTargetProperty(target: string): string {
1269
+ return getToolcraftTargetParts(target).at(-1) ?? "";
1270
+ }
1271
+
1272
+ function getToolcraftStrictTargetPrefix(target: string): string | null {
1273
+ const parts = getToolcraftTargetParts(target);
1274
+
1275
+ if (parts.length < 3) {
1276
+ return null;
1277
+ }
1278
+
1279
+ const prefix = parts.slice(0, -1).join(".");
1280
+
1281
+ if (prefix === "canvas.size") {
1282
+ return null;
1283
+ }
1284
+
1285
+ return prefix;
1286
+ }
1287
+
1288
+ function getToolcraftLooseTargetPrefix(target: string): string | null {
1289
+ const parts = getToolcraftTargetParts(target);
1290
+
1291
+ if (parts.length < 2) {
1292
+ return null;
1293
+ }
1294
+
1295
+ const prefix = parts.slice(0, -1).join(".");
1296
+
1297
+ if (prefix === "canvas.size") {
1298
+ return null;
1299
+ }
1300
+
1301
+ return prefix;
1302
+ }
1303
+
1304
+ function isToolcraftWeakSectionContext(sectionTitle: string | undefined): boolean {
1305
+ if (!sectionTitle) {
1306
+ return true;
1307
+ }
1308
+
1309
+ return (
1310
+ genericControlSectionTitlePattern.test(sectionTitle) ||
1311
+ controlTypeSectionTitlePattern.test(sectionTitle) ||
1312
+ weakControlLabelContextSectionTitlePattern.test(sectionTitle)
1313
+ );
1314
+ }
1315
+
1316
+ function doesToolcraftSectionMatchTarget(
1317
+ sectionTitle: string | undefined,
1318
+ target: string,
1319
+ ): boolean {
1320
+ const sectionText = normalizeToolcraftSemanticText(sectionTitle);
1321
+
1322
+ if (!sectionText) {
1323
+ return false;
1324
+ }
1325
+
1326
+ return getToolcraftTargetParts(target).some((part) => {
1327
+ const targetText = normalizeToolcraftSemanticText(part);
1328
+ return (
1329
+ targetText.length > 0 &&
1330
+ (targetText === sectionText ||
1331
+ targetText.includes(sectionText) ||
1332
+ sectionText.includes(targetText))
1333
+ );
1334
+ });
1335
+ }
1336
+
1337
+ function getToolcraftSuggestedControlLabel(
1338
+ control: ToolcraftControlSchema,
1339
+ sectionTitle: string | undefined,
1340
+ ): string {
1341
+ const label = getControlLabelText(control).trim();
1342
+ const targetProperty = humanizeToolcraftLabelPart(control.target.split(".").at(-1) ?? "");
1343
+ const normalizedLabel = normalizeToolcraftSemanticText(label);
1344
+ const normalizedTargetProperty = normalizeToolcraftSemanticText(targetProperty);
1345
+
1346
+ if (
1347
+ label &&
1348
+ normalizedTargetProperty &&
1349
+ normalizedTargetProperty !== normalizedLabel &&
1350
+ normalizedTargetProperty.endsWith(normalizedLabel)
1351
+ ) {
1352
+ return targetProperty;
1353
+ }
1354
+
1355
+ const property = label || targetProperty;
1356
+ const loosePrefix = getToolcraftLooseTargetPrefix(control.target);
1357
+ const prefixParts = loosePrefix ? getToolcraftTargetParts(loosePrefix) : [];
1358
+ const prefixEntity = humanizeToolcraftLabelPart(prefixParts.at(-1) ?? "");
1359
+ const sectionEntity =
1360
+ sectionTitle && !isToolcraftWeakSectionContext(sectionTitle)
1361
+ ? humanizeToolcraftLabelPart(sectionTitle)
1362
+ : "";
1363
+ const entity = prefixEntity || sectionEntity;
1364
+
1365
+ if (!entity) {
1366
+ return property;
1367
+ }
1368
+
1369
+ const normalizedEntity = normalizeToolcraftSemanticText(entity);
1370
+ const normalizedProperty = normalizeToolcraftSemanticText(property);
1371
+
1372
+ if (normalizedEntity && normalizedProperty.includes(normalizedEntity)) {
1373
+ return property;
1374
+ }
1375
+
1376
+ return `${entity} ${lowerCaseToolcraftLabelStart(property)}`;
1377
+ }
1378
+
1379
+ function getToolcraftFontPickerOwnedTypographyPart(
1380
+ control: ToolcraftControlSchema,
1381
+ ): string | undefined {
1382
+ if (control.type === "fontPicker") {
1383
+ return undefined;
1384
+ }
1385
+
1386
+ const normalizedCandidates = [
1387
+ getToolcraftTargetProperty(control.target),
1388
+ getControlLabelText(control),
1389
+ ].map(normalizeToolcraftSemanticText);
1390
+
1391
+ for (const candidate of normalizedCandidates) {
1392
+ const ownedPart = fontPickerOwnedTypographyPartLabels.get(candidate);
1393
+
1394
+ if (ownedPart) {
1395
+ return ownedPart;
1396
+ }
1397
+ }
1398
+
1399
+ return undefined;
1400
+ }
1401
+
1402
+ function getToolcraftControlSemanticCluster(
1403
+ controlId: string,
1404
+ control: ToolcraftControlSchema,
1405
+ ): string {
1406
+ const text = [
1407
+ controlId,
1408
+ getToolcraftTargetProperty(control.target),
1409
+ getControlLabelText(control),
1410
+ control.description ?? "",
1411
+ ]
1412
+ .filter(Boolean)
1413
+ .join(" ");
1414
+
1415
+ for (const [cluster, pattern] of controlSemanticClusterPatterns) {
1416
+ if (pattern.test(text)) {
1417
+ return cluster;
1418
+ }
1419
+ }
1420
+
1421
+ return inferToolcraftControlOrderRole(controlId, control);
1422
+ }
1423
+
1424
+ function getToolcraftGenericControlLabelError({
1425
+ control,
1426
+ controlId,
1427
+ sectionLabel,
1428
+ sectionLoosePrefixCount,
1429
+ sectionTitle,
1430
+ }: {
1431
+ control: ToolcraftControlSchema;
1432
+ controlId: string;
1433
+ sectionLabel: string;
1434
+ sectionLoosePrefixCount: number;
1435
+ sectionTitle: string | undefined;
1436
+ }): string | undefined {
1437
+ const label = getControlLabelText(control).trim();
1438
+
1439
+ if (!genericControlLabelPattern.test(label)) {
1440
+ return undefined;
1441
+ }
1442
+
1443
+ const hasWeakContext =
1444
+ isToolcraftWeakSectionContext(sectionTitle) ||
1445
+ (sectionLoosePrefixCount > 1 &&
1446
+ !doesToolcraftSectionMatchTarget(sectionTitle, control.target));
1447
+
1448
+ if (!hasWeakContext) {
1449
+ return undefined;
1450
+ }
1451
+
1452
+ const suggestedLabel = getToolcraftSuggestedControlLabel(control, sectionTitle);
1453
+
1454
+ return `${sectionLabel} / ${controlId} label "${label}" is too generic in this context. Short labels are allowed when the nearest visible section or group clearly names the affected product entity. Rename it to "${suggestedLabel}".`;
1455
+ }
1456
+
1457
+ function getToolcraftControlDescriptionError({
1458
+ control,
1459
+ controlId,
1460
+ sectionLabel,
1461
+ }: {
1462
+ control: ToolcraftControlSchema;
1463
+ controlId: string;
1464
+ sectionLabel: string;
1465
+ }): string | undefined {
1466
+ const description = control.description?.trim();
1467
+
1468
+ if (!description || control.type !== "fontPicker") {
1469
+ return undefined;
1470
+ }
1471
+
1472
+ const repeatedParts = fontPickerDescriptionOwnedPartPatterns
1473
+ .filter(([, pattern]) => pattern.test(description))
1474
+ .map(([part]) => part);
1475
+
1476
+ if (repeatedParts.length < 2) {
1477
+ return undefined;
1478
+ }
1479
+
1480
+ return `${sectionLabel} / ${controlId} description repeats FontPicker-owned fields (${repeatedParts.join(", ")}). FontPicker help must explain only non-obvious product behavior; use section titles and visible field labels for font family, weight, size, case, color, opacity, letter spacing, and line height, or omit description.`;
1481
+ }
1482
+
1483
+ function getToolcraftControlSectionGroupingErrors(
1484
+ schema: ResolvedToolcraftAppSchema,
1485
+ ): string[] {
1486
+ const errors: string[] = [];
1487
+ const visibleControls: Array<{
1488
+ control: ToolcraftControlSchema;
1489
+ controlId: string;
1490
+ loosePrefix: string | null;
1491
+ sectionLabel: string;
1492
+ }> = [];
1493
+ const strictPrefixSections = new Map<string, Set<string>>();
1494
+ const loosePrefixSections = new Map<string, Set<string>>();
1495
+ const colorSectionLoosePrefixes = new Map<string, string>();
1496
+ const sectionTitleCounts = new Map<string, { count: number; label: string }>();
1497
+
1498
+ for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1499
+ const sectionTitle = section.title?.trim();
1500
+ const sectionLabel = getToolcraftSectionLabel(sectionTitle, sectionIndex);
1501
+ const controls = Object.entries(section.controls).filter(
1502
+ ([, control]) => control.type !== "panelActions",
1503
+ );
1504
+
1505
+ if (controls.length === 0) {
1506
+ continue;
1507
+ }
1508
+
1509
+ if (!sectionTitle) {
1510
+ errors.push(
1511
+ `${sectionLabel} is missing a controls section title. Every visible controls-panel section must name the product entity, workflow stage, or behavior it edits.`,
1512
+ );
1513
+ }
1514
+
1515
+ if (sectionTitle) {
1516
+ const normalizedSectionTitle = normalizeToolcraftSemanticText(sectionTitle);
1517
+ const titleCount = sectionTitleCounts.get(normalizedSectionTitle);
1518
+ sectionTitleCounts.set(normalizedSectionTitle, {
1519
+ count: (titleCount?.count ?? 0) + 1,
1520
+ label: titleCount?.label ?? sectionTitle,
1521
+ });
1522
+ }
1523
+
1524
+ if (sectionTitle && genericControlSectionTitlePattern.test(sectionTitle)) {
1525
+ errors.push(
1526
+ `${sectionLabel} is too generic for a controls section. Name the product entity, workflow stage, or behavior it edits instead of using a bucket title.`,
1527
+ );
1528
+ }
1529
+
1530
+ if (sectionTitle && controlTypeSectionTitlePattern.test(sectionTitle)) {
1531
+ errors.push(
1532
+ `${sectionLabel} names a UI control type instead of the product entity. Group controls by product meaning, not by Slider, Color, Input, Button, or similar component type.`,
1533
+ );
1534
+ }
1535
+
1536
+ const sectionLoosePrefixes = new Set(
1537
+ controls
1538
+ .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
1539
+ .filter((prefix): prefix is string => Boolean(prefix)),
1540
+ );
1541
+ const productControls = controls.filter(
1542
+ ([, control]) =>
1543
+ control.type !== "settingsTransfer" &&
1544
+ control.target !== "canvas.size.width" &&
1545
+ control.target !== "canvas.size.height",
1546
+ );
1547
+ const semanticClusters = new Set(
1548
+ productControls.map(([controlId, control]) =>
1549
+ getToolcraftControlSemanticCluster(controlId, control),
1550
+ ),
1551
+ );
1552
+ const clusterList = [...semanticClusters].join(", ");
1553
+ const hasBroadSectionTitle =
1554
+ sectionTitle !== undefined && broadControlSectionTitlePattern.test(sectionTitle);
1555
+
1556
+ if (
1557
+ productControls.length > maxPreferredControlsPerSection &&
1558
+ hasBroadSectionTitle &&
1559
+ semanticClusters.size >= 3
1560
+ ) {
1561
+ errors.push(
1562
+ `${sectionLabel} has ${productControls.length} controls across multiple semantic clusters (${clusterList}). Broad section titles are only valid for small cohesive groups; split this into discrete sections with specific titles such as motion, geometry, density, color, typography, or export sub-entities.`,
1563
+ );
1564
+ }
1565
+
1566
+ if (productControls.length > maxHardControlsPerSection && semanticClusters.size > 1) {
1567
+ errors.push(
1568
+ `${sectionLabel} has ${productControls.length} controls across ${semanticClusters.size} semantic clusters (${clusterList}). Controls-panel sections should stay discrete; split sections that grow past ${maxHardControlsPerSection} controls unless every control edits one tightly scoped entity.`,
1569
+ );
1570
+ }
1571
+
1572
+ for (const [controlId, control] of controls) {
1573
+ const strictPrefix = getToolcraftStrictTargetPrefix(control.target);
1574
+ const loosePrefix = getToolcraftLooseTargetPrefix(control.target);
1575
+ const genericLabelError = getToolcraftGenericControlLabelError({
1576
+ control,
1577
+ controlId,
1578
+ sectionLabel,
1579
+ sectionLoosePrefixCount: sectionLoosePrefixes.size,
1580
+ sectionTitle,
1581
+ });
1582
+
1583
+ if (genericLabelError) {
1584
+ errors.push(genericLabelError);
1585
+ }
1586
+
1587
+ const descriptionError = getToolcraftControlDescriptionError({
1588
+ control,
1589
+ controlId,
1590
+ sectionLabel,
1591
+ });
1592
+
1593
+ if (descriptionError) {
1594
+ errors.push(descriptionError);
1595
+ }
1596
+
1597
+ visibleControls.push({
1598
+ control,
1599
+ controlId,
1600
+ loosePrefix,
1601
+ sectionLabel,
1602
+ });
1603
+
1604
+ if (strictPrefix) {
1605
+ const sections = strictPrefixSections.get(strictPrefix) ?? new Set<string>();
1606
+ sections.add(sectionLabel);
1607
+ strictPrefixSections.set(strictPrefix, sections);
1608
+ }
1609
+
1610
+ if (loosePrefix) {
1611
+ const sections = loosePrefixSections.get(loosePrefix) ?? new Set<string>();
1612
+ sections.add(sectionLabel);
1613
+ loosePrefixSections.set(loosePrefix, sections);
1614
+ }
1615
+
1616
+ if (
1617
+ control.type === "color" &&
1618
+ sectionTitle &&
1619
+ /^colors?$/i.test(sectionTitle) &&
1620
+ loosePrefix
1621
+ ) {
1622
+ colorSectionLoosePrefixes.set(loosePrefix, `${sectionLabel} / ${controlId}`);
1623
+ }
1624
+ }
1625
+ }
1626
+
1627
+ for (const { count, label } of sectionTitleCounts.values()) {
1628
+ if (count > 1) {
1629
+ errors.push(
1630
+ `Controls panel repeats the section title "${label}" ${count} times. Section titles must be unique and describe distinct product entities or workflow stages.`,
1631
+ );
1632
+ }
1633
+ }
1634
+
1635
+ const fontPickerControls = visibleControls.filter(
1636
+ (item) => item.control.type === "fontPicker" && item.loosePrefix,
1637
+ );
1638
+
1639
+ for (const item of visibleControls) {
1640
+ if (!item.loosePrefix || item.control.type === "fontPicker") {
1641
+ continue;
1642
+ }
1643
+
1644
+ const ownedTypographyPart =
1645
+ getToolcraftFontPickerOwnedTypographyPart(item.control);
1646
+
1647
+ if (!ownedTypographyPart) {
1648
+ continue;
1649
+ }
1650
+
1651
+ const owningFontPicker = fontPickerControls.find(
1652
+ (fontPicker) => fontPicker.loosePrefix === item.loosePrefix,
1653
+ );
1654
+
1655
+ if (!owningFontPicker) {
1656
+ continue;
1657
+ }
1658
+
1659
+ const label = getControlLabelText(item.control).trim() || item.controlId;
1660
+
1661
+ errors.push(
1662
+ `${item.sectionLabel} / ${item.controlId} splits "${label}" out of the FontPicker-owned typography block for "${item.loosePrefix}". Keep font family, weight, size, case, letter spacing, line height, color, and opacity in the same fontPicker value.`,
1663
+ );
1664
+ }
1665
+
1666
+ for (const [prefix, sections] of strictPrefixSections) {
1667
+ if (sections.size > 1) {
1668
+ errors.push(
1669
+ `Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the spec names a real workflow split.`,
1670
+ );
1671
+ }
1672
+ }
1673
+
1674
+ for (const [prefix, colorControlLabel] of colorSectionLoosePrefixes) {
1675
+ const sections = loosePrefixSections.get(prefix);
1676
+
1677
+ if (sections && sections.size > 1) {
1678
+ errors.push(
1679
+ `${colorControlLabel} is separated from other "${prefix}" controls. A color that configures the same product entity belongs inside that entity section with a concise field label that stays unambiguous in context.`,
1680
+ );
1681
+ }
1682
+ }
1683
+
1684
+ return errors;
1685
+ }
1686
+
1687
+ export function collectToolcraftVisibleControls(
1688
+ schema: ResolvedToolcraftAppSchema = starterSchema,
1689
+ ): ToolcraftVisibleControl[] {
1690
+ return (schema.panels.controls?.sections ?? []).flatMap((section) =>
1691
+ Object.entries(section.controls).map(([controlId, control]) => ({
1692
+ control,
1693
+ controlId,
1694
+ sectionTitle: section.title,
1695
+ })),
1696
+ );
1697
+ }
1698
+
1699
+ export function collectToolcraftKeyframeableControls(
1700
+ schema: ResolvedToolcraftAppSchema = starterSchema,
1701
+ ): ToolcraftVisibleControl[] {
1702
+ return collectToolcraftVisibleControls(schema).filter(
1703
+ ({ control }) => getToolcraftControlKeyframeCapability(control).capable,
1704
+ );
1705
+ }
1706
+
1707
+ export function getToolcraftControlOrder(
1708
+ schema: ResolvedToolcraftAppSchema = starterSchema,
1709
+ ): ToolcraftControlOrderItem[] {
1710
+ return (schema.panels.controls?.sections ?? []).flatMap((section) =>
1711
+ Object.entries(section.controls)
1712
+ .filter(([, control]) => control.type !== "panelActions")
1713
+ .map(([controlId, control]) => {
1714
+ const role = inferToolcraftControlOrderRole(controlId, control);
1715
+
1716
+ return {
1717
+ controlId,
1718
+ rank: controlOrderRoleRanks[role],
1719
+ role,
1720
+ sectionTitle: section.title,
1721
+ target: control.target,
1722
+ type: control.type,
1723
+ };
1724
+ }),
1725
+ );
1726
+ }
1727
+
1728
+ export function getToolcraftControlOrderTargets(
1729
+ schema: ResolvedToolcraftAppSchema = starterSchema,
1730
+ ): string[] {
1731
+ return getToolcraftControlOrder(schema).map((item) => item.target);
1732
+ }
1733
+
1734
+ export function validateToolcraftAcceptanceCoverage(
1735
+ schema: ResolvedToolcraftAppSchema = starterSchema,
1736
+ acceptance: readonly ToolcraftComponentAcceptance[] = starterAcceptance,
1737
+ transferMode: ToolcraftTransferMode = starterTransferMode,
1738
+ ): string[] {
1739
+ const errors: string[] = [];
1740
+ const controls = collectToolcraftVisibleControls(schema);
1741
+ const controlAcceptance = new Map(
1742
+ acceptance
1743
+ .filter((entry) => entry.kind === "control")
1744
+ .map((entry) => [entry.target, entry]),
1745
+ );
1746
+ const timelineMode = schema.panels.timeline?.enabled ? schema.panels.timeline.mode : null;
1747
+ const layersEnabled = Boolean(schema.panels.layers);
1748
+ const controlTargets = new Set(controls.map(({ control }) => control.target));
1749
+ const animationIntent = transferMode.animationIntent;
1750
+ const animationControls = controls.filter(
1751
+ (visibleControl) =>
1752
+ visibleControl.control.type !== "panelActions" &&
1753
+ animationIntentControlPattern.test(getAnimationIntentControlText(visibleControl)),
1754
+ );
1755
+ const commandTargets = new Set([
1756
+ "canvas.center",
1757
+ "canvas.setOffset",
1758
+ "canvas.setSize",
1759
+ "canvas.setViewport",
1760
+ "canvas.zoomIn",
1761
+ "canvas.zoomOut",
1762
+ "controls.setValue",
1763
+ "history.redo",
1764
+ "history.undo",
1765
+ ]);
1766
+
1767
+ errors.push(...getToolcraftControlOrderErrors(schema));
1768
+ errors.push(...getToolcraftControlSectionGroupingErrors(schema));
1769
+
1770
+ for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1771
+ const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
1772
+
1773
+ for (const layoutGroup of section.layoutGroups ?? []) {
1774
+ if (layoutGroup.layout !== "inline") {
1775
+ continue;
1776
+ }
1777
+
1778
+ const rangeSliderIds = layoutGroup.controls.filter(
1779
+ (controlId) => section.controls[controlId]?.type === "rangeSlider",
1780
+ );
1781
+
1782
+ if (rangeSliderIds.length > 0) {
1783
+ errors.push(
1784
+ `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes rangeSlider ${rangeSliderIds.join(", ")}. RangeSlider is a full-width two-thumb control and must not share a row with another slider or range slider.`,
1785
+ );
1786
+ }
1787
+
1788
+ const switchEntries = layoutGroup.controls
1789
+ .map((controlId) => [controlId, section.controls[controlId]] as const)
1790
+ .filter(
1791
+ (entry): entry is readonly [string, ToolcraftControlSchema] =>
1792
+ Boolean(entry[1]) && entry[1].type === "switch",
1793
+ );
1794
+ const booleanEntries = layoutGroup.controls
1795
+ .map((controlId) => [controlId, section.controls[controlId]] as const)
1796
+ .filter(
1797
+ (entry): entry is readonly [string, ToolcraftControlSchema] =>
1798
+ Boolean(entry[1]) && isBooleanControl(entry[1]),
1799
+ );
1800
+ const parameterEntries = layoutGroup.controls
1801
+ .map((controlId) => [controlId, section.controls[controlId]] as const)
1802
+ .filter(
1803
+ (entry): entry is readonly [string, ToolcraftControlSchema] =>
1804
+ Boolean(entry[1]) && !isBooleanControl(entry[1]),
1805
+ );
1806
+
1807
+ if (switchEntries.length > 1) {
1808
+ const unsafeSwitchLabels = switchEntries.filter(
1809
+ ([controlId, control]) => !isInlineSwitchLabelSafe(controlId, control),
1810
+ );
1811
+
1812
+ if (unsafeSwitchLabels.length > 0) {
1813
+ errors.push(
1814
+ `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes switch labels ${unsafeSwitchLabels.map(([controlId, control]) => `${controlId} "${getInlineSwitchLabelText(controlId, control)}"`).join(", ")} that are too long for a two-column toggle row. Switches share a row only when every visible label fits without truncation; shorten labels or stack them.`,
1815
+ );
1816
+ }
1817
+ }
1818
+
1819
+ if (booleanEntries.length === 1 && parameterEntries.length === 1) {
1820
+ const unsafeBooleanLabels = booleanEntries.filter(
1821
+ ([controlId, control]) => !isInlineSwitchLabelSafe(controlId, control),
1822
+ );
1823
+
1824
+ if (unsafeBooleanLabels.length > 0) {
1825
+ errors.push(
1826
+ `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes toggle label ${unsafeBooleanLabels.map(([controlId, control]) => `${controlId} "${getInlineSwitchLabelText(controlId, control)}"`).join(", ")} that is too long for a compact toggle-plus-parameter row. Keep the toggle label short, hide it when the section title supplies the context, or stack the controls.`,
1827
+ );
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ const sectionControls = Object.entries(section.controls).filter(
1833
+ ([, control]) => control.type !== "panelActions",
1834
+ );
1835
+
1836
+ for (let index = 0; index < sectionControls.length - 1; index += 1) {
1837
+ const [firstControlId, firstControl] = sectionControls[index] ?? [];
1838
+ const [secondControlId, secondControl] = sectionControls[index + 1] ?? [];
1839
+
1840
+ if (
1841
+ !firstControlId ||
1842
+ !secondControlId ||
1843
+ !firstControl ||
1844
+ !secondControl ||
1845
+ firstControl.visibleWhen ||
1846
+ secondControl.visibleWhen ||
1847
+ !isBooleanControl(firstControl) ||
1848
+ !isBooleanControl(secondControl) ||
1849
+ !isInlineSwitchLabelSafe(firstControlId, firstControl) ||
1850
+ !isInlineSwitchLabelSafe(secondControlId, secondControl) ||
1851
+ !controlsShareToolcraftTargetEntity(firstControl, secondControl) ||
1852
+ sectionHasInlineLayoutGroupForPair(section, firstControlId, secondControlId)
1853
+ ) {
1854
+ continue;
1855
+ }
1856
+
1857
+ errors.push(
1858
+ `${sectionLabel} has adjacent short toggle controls "${firstControlId}" and "${secondControlId}" for the same product entity "${getToolcraftLooseTargetPrefix(firstControl.target)}". Put them in a two-column inline layoutGroup so compact paired toggles share one row.`,
1859
+ );
1860
+ }
1861
+ }
1862
+
1863
+ if (schemaHasPngExportPanelAction(schema)) {
1864
+ if (!schemaHasOutputBackgroundColorControl(controls)) {
1865
+ errors.push(
1866
+ "Product apps with Export PNG must expose a user-facing background color control such as appearance.background or scene.background. Preview, PNG export, and video export must read that runtime value instead of hardcoding the product background.",
1867
+ );
1868
+ }
1869
+
1870
+ if (!schemaHasOutputBackgroundToggleControl(controls)) {
1871
+ errors.push(
1872
+ "Product apps with Export PNG must expose a user-facing Include background / Transparent background control such as export.includeBackground. PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; video export keeps the background.",
1873
+ );
1874
+ }
1875
+ }
1876
+
1877
+ if (animationControls.length > 0 && !timelineMode && animationIntent?.mode !== "autonomous") {
1878
+ errors.push(
1879
+ [
1880
+ `Animation controls ${animationControls.map(({ control, controlId, sectionTitle }) => `"${sectionTitle ? `${sectionTitle} / ` : ""}${controlId}" (${control.target})`).join(", ")} exist while panels.timeline is omitted.`,
1881
+ 'Use panels.timeline mode "playback" for product animation transport, mode "keyframes" for editable keyframes, or declare starterTransferMode.animationIntent mode "autonomous" with coverage proving there is no user-facing transport.',
1882
+ ].join(" "),
1883
+ );
1884
+ }
1885
+
1886
+ if (animationIntent?.mode === "autonomous") {
1887
+ const declaredAutonomousCoverage = new Set(animationIntent.behaviorCoverage);
1888
+ const missingAutonomousCoverage = requiredAutonomousAnimationCoverage.filter(
1889
+ (coverage) => !declaredAutonomousCoverage.has(coverage),
1890
+ );
1891
+
1892
+ if (timelineMode) {
1893
+ errors.push(
1894
+ `starterTransferMode.animationIntent mode "autonomous" conflicts with panels.timeline mode "${timelineMode}". Use timeline-playback, timeline-keyframes, or remove the timeline.`,
1895
+ );
1896
+ }
1897
+
1898
+ if (!animationIntent.reason.trim()) {
1899
+ errors.push(
1900
+ 'starterTransferMode.animationIntent mode "autonomous" must include a reason explaining why the animation is decorative/self-running and does not need top timeline transport.',
1901
+ );
1902
+ }
1903
+
1904
+ if (missingAutonomousCoverage.length > 0) {
1905
+ errors.push(
1906
+ `starterTransferMode.animationIntent mode "autonomous" must include behaviorCoverage ${missingAutonomousCoverage.map((coverage) => `"${coverage}"`).join(", ")}.`,
1907
+ );
1908
+ }
1909
+ }
1910
+
1911
+ if (animationIntent?.mode === "timeline-playback" && timelineMode !== "playback") {
1912
+ errors.push(
1913
+ 'starterTransferMode.animationIntent mode "timeline-playback" requires panels.timeline mode "playback".',
1914
+ );
1915
+ }
1916
+
1917
+ if (animationIntent?.mode === "timeline-keyframes" && timelineMode !== "keyframes") {
1918
+ errors.push(
1919
+ 'starterTransferMode.animationIntent mode "timeline-keyframes" requires panels.timeline mode "keyframes".',
1920
+ );
1921
+ }
1922
+
1923
+ if (transferMode.mode === "reference-runtime-clone") {
1924
+ const declaredReferenceCoverage = new Set(transferMode.behaviorCoverage);
1925
+ const referenceTimeline = transferMode.referenceTimeline;
1926
+
1927
+ if (!schema.assembly.surfaces.canvas.enabled) {
1928
+ errors.push(
1929
+ "reference-runtime-clone must keep the Toolcraft canvas shell enabled; preserve the reference renderer inside ToolcraftApp canvasContent instead of replacing the app with the original UI.",
1930
+ );
1931
+ }
1932
+
1933
+ if (!transferMode.referenceName.trim()) {
1934
+ errors.push(
1935
+ "reference-runtime-clone transferMode must name the reference app or artifact.",
1936
+ );
1937
+ }
1938
+
1939
+ if (transferMode.sourceOfTruth !== "reference-runtime") {
1940
+ errors.push(
1941
+ 'reference-runtime-clone transferMode must set sourceOfTruth to "reference-runtime".',
1942
+ );
1943
+ }
1944
+
1945
+ for (const coverage of requiredReferenceCloneCoverage) {
1946
+ if (!declaredReferenceCoverage.has(coverage)) {
1947
+ errors.push(
1948
+ `reference-runtime-clone transferMode must include behaviorCoverage "${coverage}".`,
1949
+ );
1950
+ }
1951
+ }
1952
+
1953
+ for (const coverage of declaredReferenceCoverage) {
1954
+ const entry = acceptance.find(
1955
+ (acceptanceEntry) => acceptanceEntry.referenceCoverage === coverage,
1956
+ );
1957
+
1958
+ if (!entry) {
1959
+ errors.push(
1960
+ `reference-runtime-clone behaviorCoverage "${coverage}" is missing an acceptance entry with referenceCoverage "${coverage}".`,
1961
+ );
1962
+ continue;
1963
+ }
1964
+
1965
+ if (!entry.automated || !entry.automatedTestName.trim()) {
1966
+ errors.push(
1967
+ `${entry.id} must have automated coverage proving reference behavior "${coverage}".`,
1968
+ );
1969
+ }
1970
+
1971
+ if (!entry.browser || !entry.browserTestName.trim()) {
1972
+ errors.push(
1973
+ `${entry.id} must have browser coverage proving reference behavior "${coverage}".`,
1974
+ );
1975
+ }
1976
+
1977
+ if (!entry.expectedObservable.trim()) {
1978
+ errors.push(
1979
+ `${entry.id} must describe the observable reference behavior for "${coverage}".`,
1980
+ );
1981
+ }
1982
+ }
1983
+
1984
+ if (!referenceTimeline) {
1985
+ errors.push(
1986
+ 'reference-runtime-clone transferMode must declare referenceTimeline with mode "none", "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline".',
1987
+ );
1988
+ } else {
1989
+ const declaredReferenceTimelineCoverage = new Set(referenceTimeline.behaviorCoverage);
1990
+ const declaredReferenceTransportCoverage = [...declaredReferenceCoverage].filter(
1991
+ (coverage) => referenceTransportCoverage.has(coverage),
1992
+ );
1993
+ const declaredToolcraftTimelineCoverage = [...declaredReferenceTimelineCoverage].filter(
1994
+ (coverage) => toolcraftReferenceTimelineCoverage.has(coverage),
1995
+ );
1996
+
1997
+ if (referenceTimeline.mode === "none" && declaredReferenceTimelineCoverage.size > 0) {
1998
+ errors.push(
1999
+ 'referenceTimeline mode "none" must not declare reference timeline behaviorCoverage.',
2000
+ );
2001
+ }
2002
+
2003
+ if (
2004
+ referenceTimeline.mode === "none" &&
2005
+ declaredReferenceTransportCoverage.length > 0
2006
+ ) {
2007
+ errors.push(
2008
+ `reference-runtime-clone transport behaviorCoverage ${declaredReferenceTransportCoverage.map((coverage) => `"${coverage}"`).join(", ")} requires referenceTimeline mode "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline"; mode "none" is only for references with no user-facing transport behavior.`,
2009
+ );
2010
+ }
2011
+
2012
+ if (
2013
+ (referenceTimeline.mode === "toolcraft-playback" ||
2014
+ referenceTimeline.mode === "toolcraft-keyframes") &&
2015
+ declaredReferenceTimelineCoverage.size === 0
2016
+ ) {
2017
+ errors.push(
2018
+ `referenceTimeline mode "${referenceTimeline.mode}" must list the concrete timeline transport behaviors in behaviorCoverage.`,
2019
+ );
2020
+ }
2021
+
2022
+ if (referenceTimeline.mode === "toolcraft-playback" && timelineMode !== "playback") {
2023
+ errors.push(
2024
+ 'referenceTimeline mode "toolcraft-playback" requires panels.timeline mode "playback".',
2025
+ );
2026
+ }
2027
+
2028
+ if (referenceTimeline.mode === "toolcraft-keyframes" && timelineMode !== "keyframes") {
2029
+ errors.push(
2030
+ 'referenceTimeline mode "toolcraft-keyframes" requires panels.timeline mode "keyframes".',
2031
+ );
2032
+ }
2033
+
2034
+ if (
2035
+ referenceTimeline.mode === "toolcraft-playback" &&
2036
+ declaredReferenceTimelineCoverage.has("keyframes")
2037
+ ) {
2038
+ errors.push(
2039
+ 'referenceTimeline behaviorCoverage "keyframes" requires referenceTimeline mode "toolcraft-keyframes".',
2040
+ );
2041
+ }
2042
+
2043
+ if (
2044
+ referenceTimeline.mode === "toolcraft-keyframes" &&
2045
+ !declaredReferenceTimelineCoverage.has("keyframes")
2046
+ ) {
2047
+ errors.push(
2048
+ 'referenceTimeline mode "toolcraft-keyframes" must include behaviorCoverage "keyframes".',
2049
+ );
2050
+ }
2051
+
2052
+ if (
2053
+ (referenceTimeline.mode === "toolcraft-playback" ||
2054
+ referenceTimeline.mode === "toolcraft-keyframes") &&
2055
+ declaredToolcraftTimelineCoverage.length === 0
2056
+ ) {
2057
+ errors.push(
2058
+ `referenceTimeline mode "${referenceTimeline.mode}" must include at least one Toolcraft timeline behavior such as "playback", "restart", "scrub", "duration", "loop", "time-progress", "export-at-time", or "keyframes".`,
2059
+ );
2060
+ }
2061
+
2062
+ if (
2063
+ referenceTimeline.mode === "custom-reference-timeline" &&
2064
+ declaredReferenceTimelineCoverage.size === 0
2065
+ ) {
2066
+ errors.push(
2067
+ 'referenceTimeline mode "custom-reference-timeline" must list every reference timeline behavior in behaviorCoverage.',
2068
+ );
2069
+ }
2070
+
2071
+ for (const coverage of declaredReferenceTimelineCoverage) {
2072
+ if (
2073
+ customReferenceTimelineCoverage.has(coverage) &&
2074
+ referenceTimeline.mode !== "custom-reference-timeline"
2075
+ ) {
2076
+ errors.push(
2077
+ `referenceTimeline mode "${referenceTimeline.mode}" cannot preserve custom reference timeline behavior "${coverage}". Use mode "custom-reference-timeline" and browser-backed referenceTimelineCoverage instead.`,
2078
+ );
2079
+ }
2080
+
2081
+ const entry = acceptance.find(
2082
+ (acceptanceEntry) => acceptanceEntry.referenceTimelineCoverage === coverage,
2083
+ );
2084
+
2085
+ if (!entry) {
2086
+ errors.push(
2087
+ `referenceTimeline behaviorCoverage "${coverage}" is missing an acceptance entry with referenceTimelineCoverage "${coverage}".`,
2088
+ );
2089
+ continue;
2090
+ }
2091
+
2092
+ if (entry.kind !== "runtime") {
2093
+ errors.push(
2094
+ `${entry.id} must be a runtime acceptance entry proving reference timeline behavior "${coverage}".`,
2095
+ );
2096
+ }
2097
+
2098
+ if (!entry.automated || !entry.automatedTestName.trim()) {
2099
+ errors.push(
2100
+ `${entry.id} must have automated coverage proving reference timeline behavior "${coverage}".`,
2101
+ );
2102
+ }
2103
+
2104
+ if (!entry.browser || !entry.browserTestName.trim()) {
2105
+ errors.push(
2106
+ `${entry.id} must have browser coverage proving reference timeline behavior "${coverage}".`,
2107
+ );
2108
+ }
2109
+
2110
+ if (!entry.expectedObservable.trim()) {
2111
+ errors.push(
2112
+ `${entry.id} must describe the observable reference timeline behavior for "${coverage}".`,
2113
+ );
2114
+ }
2115
+ }
2116
+ }
2117
+ } else {
2118
+ for (const entry of acceptance) {
2119
+ if (entry.referenceCoverage) {
2120
+ errors.push(
2121
+ `${entry.id} declares referenceCoverage "${entry.referenceCoverage}" but transferMode is not "reference-runtime-clone".`,
2122
+ );
2123
+ }
2124
+
2125
+ if (entry.referenceTimelineCoverage) {
2126
+ errors.push(
2127
+ `${entry.id} declares referenceTimelineCoverage "${entry.referenceTimelineCoverage}" but transferMode is not "reference-runtime-clone".`,
2128
+ );
2129
+ }
2130
+ }
2131
+ }
2132
+
2133
+ if (layersEnabled) {
2134
+ for (const coverage of requiredLayerCoverage) {
2135
+ const entry = acceptance.find(
2136
+ (acceptanceEntry) =>
2137
+ acceptanceEntry.kind === "runtime" && acceptanceEntry.layerCoverage === coverage,
2138
+ );
2139
+
2140
+ if (!entry) {
2141
+ errors.push(
2142
+ `panels.layers requires a runtime acceptance entry with layerCoverage "${coverage}" proving layer ${coverage} behavior.`,
2143
+ );
2144
+ continue;
2145
+ }
2146
+
2147
+ if (!entry.automated || !entry.automatedTestName.trim()) {
2148
+ errors.push(`${entry.id} must have automated coverage proving layer ${coverage}.`);
2149
+ }
2150
+
2151
+ if (!entry.browser || !entry.browserTestName.trim()) {
2152
+ errors.push(`${entry.id} must have browser coverage proving layer ${coverage}.`);
2153
+ }
2154
+
2155
+ if (!entry.expectedObservable.trim()) {
2156
+ errors.push(
2157
+ `${entry.id} must describe the observable layer behavior for "${coverage}".`,
2158
+ );
2159
+ }
2160
+ }
2161
+ } else {
2162
+ for (const entry of acceptance) {
2163
+ if (entry.layerCoverage) {
2164
+ errors.push(
2165
+ `${entry.id} declares layerCoverage "${entry.layerCoverage}" but panels.layers is not enabled.`,
2166
+ );
2167
+ }
2168
+ }
2169
+ }
2170
+
2171
+ if (timelineMode) {
2172
+ const playbackEntry = acceptance.find(
2173
+ (entry) => entry.kind === "runtime" && entry.timelineCoverage === "playback",
2174
+ );
2175
+
2176
+ if (!playbackEntry) {
2177
+ errors.push(
2178
+ `panels.timeline mode "${timelineMode}" requires a runtime acceptance entry with timelineCoverage "playback" proving pause, scrub, duration/loop, and rendered-frame behavior.`,
2179
+ );
2180
+ } else if (
2181
+ !hasTimelinePlaybackCoverage(
2182
+ playbackEntry.timelinePlaybackCoverage,
2183
+ requiredTimelinePlaybackCoverage,
2184
+ )
2185
+ ) {
2186
+ errors.push(
2187
+ `${playbackEntry.id} timelineCoverage "playback" must declare timelinePlaybackCoverage for pause-resume, scrub, duration, loop, and rendered-frame. Duration coverage must prove renderer progress maps 0..state.timeline.durationSeconds, not a local fixed animation duration.`,
2188
+ );
2189
+ } else if (hasTimelinePlaybackCoveragePart(playbackEntry.timelinePlaybackCoverage, "duration")) {
2190
+ const durationEvidenceText = [
2191
+ playbackEntry.automatedTestName,
2192
+ playbackEntry.browserTestName,
2193
+ playbackEntry.expectedObservable,
2194
+ playbackEntry.userAction,
2195
+ ].join(" ");
2196
+
2197
+ if (!/\bduration\b/i.test(durationEvidenceText) || !/\b(edit|change|commit|enter|set)\w*\b/i.test(durationEvidenceText)) {
2198
+ errors.push(
2199
+ `${playbackEntry.id} timelinePlaybackCoverage "duration" must describe editing/changing the timeline duration through the UI and proving the renderer follows state.timeline.durationSeconds.`,
2200
+ );
2201
+ }
2202
+ }
2203
+ }
2204
+
2205
+ if (schema.canvas.sizing.mode === "fixed-output") {
2206
+ const fixedCanvasSizingEntry = acceptance.find(
2207
+ (entry) =>
2208
+ entry.kind === "runtime" &&
2209
+ entry.canvasSizingCoverage === "fixed-output-size",
2210
+ );
2211
+
2212
+ if (!fixedCanvasSizingEntry) {
2213
+ errors.push(
2214
+ 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. A user-provided base/default size should normally use "editable-output".',
2215
+ );
2216
+ } else {
2217
+ const evidenceText = getAcceptanceEvidenceText(fixedCanvasSizingEntry);
2218
+
2219
+ if (!/(fixed|locked|non-editable|not user-editable|must not edit|reference-defined|product-defined)/i.test(evidenceText)) {
2220
+ errors.push(
2221
+ `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the product output dimensions are intentionally fixed, not merely initialized from a default size.`,
2222
+ );
2223
+ }
2224
+
2225
+ if (!fixedCanvasSizingEntry.automated || !fixedCanvasSizingEntry.automatedTestName.trim()) {
2226
+ errors.push(
2227
+ `${fixedCanvasSizingEntry.id} must have automated coverage proving fixed output dimensions.`,
2228
+ );
2229
+ }
2230
+
2231
+ if (!fixedCanvasSizingEntry.browser || !fixedCanvasSizingEntry.browserTestName.trim()) {
2232
+ errors.push(
2233
+ `${fixedCanvasSizingEntry.id} must have browser coverage proving fixed output dimensions.`,
2234
+ );
2235
+ }
2236
+ }
2237
+ }
2238
+
2239
+ if (schema.persistence.storage === "localStorage") {
2240
+ const persistenceEntry = acceptance.find(
2241
+ (entry) =>
2242
+ entry.kind === "runtime" &&
2243
+ entry.persistenceCoverage === "reload",
2244
+ );
2245
+
2246
+ if (!persistenceEntry) {
2247
+ errors.push(
2248
+ 'persistence.storage "localStorage" requires a runtime acceptance entry with persistenceCoverage "reload" proving user-edited persisted state restores after a real browser reload. Settings import/export is not a substitute for persistence.',
2249
+ );
2250
+ } else {
2251
+ const evidenceText = getAcceptanceEvidenceText(persistenceEntry);
2252
+
2253
+ if (!persistenceEntry.automated || !persistenceEntry.automatedTestName.trim()) {
2254
+ errors.push(
2255
+ `${persistenceEntry.id} must have automated coverage proving persistence reload behavior.`,
2256
+ );
2257
+ }
2258
+
2259
+ if (!persistenceEntry.browser || !persistenceEntry.browserTestName.trim()) {
2260
+ errors.push(
2261
+ `${persistenceEntry.id} must have browser coverage proving persistence reload behavior.`,
2262
+ );
2263
+ }
2264
+
2265
+ if (!persistenceEntry.expectedObservable.trim()) {
2266
+ errors.push(
2267
+ `${persistenceEntry.id} must describe the persisted state observable after reload.`,
2268
+ );
2269
+ }
2270
+
2271
+ if (!/\b(reload|refresh|reopen|page\.reload)\b/i.test(evidenceText)) {
2272
+ errors.push(
2273
+ `${persistenceEntry.id} persistenceCoverage "reload" must describe changing a user-facing setting, reloading the browser page, and observing the restored value/output.`,
2274
+ );
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ const settingsTransferEligibility = getToolcraftSettingsTransferEligibility({
2280
+ panels: schema.panels,
2281
+ });
2282
+
2283
+ if (settingsTransferEligibility.eligible && !schema.settingsTransfer.enabled) {
2284
+ const settingsTransferOptOutEntry = acceptance.find(
2285
+ (entry) =>
2286
+ entry.kind === "runtime" &&
2287
+ entry.target === "runtime.settingsTransfer" &&
2288
+ entry.settingsTransferCoverage === "opt-out",
2289
+ );
2290
+
2291
+ if (!settingsTransferOptOutEntry) {
2292
+ errors.push(
2293
+ [
2294
+ "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
2295
+ `Eligibility: ${settingsTransferEligibility.controlCount} product controls, ${settingsTransferEligibility.sectionCount} product sections, weighted score ${settingsTransferEligibility.score}, reasons ${settingsTransferEligibility.reasons.join(", ")}.`,
2296
+ 'Use schema settingsTransfer: "auto" or true, or add a runtime acceptance entry with settingsTransferCoverage "opt-out" explaining why the app is ephemeral, temporary, one-off, not portable, or session-only.',
2297
+ ].join(" "),
2298
+ );
2299
+ } else {
2300
+ const evidenceText = getAcceptanceEvidenceText(settingsTransferOptOutEntry);
2301
+
2302
+ if (!settingsTransferOptOutEntry.automated || !settingsTransferOptOutEntry.automatedTestName.trim()) {
2303
+ errors.push(
2304
+ `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have automated coverage proving the app intentionally omits portable settings.`,
2305
+ );
2306
+ }
2307
+
2308
+ if (!settingsTransferOptOutEntry.browser || !settingsTransferOptOutEntry.browserTestName.trim()) {
2309
+ errors.push(
2310
+ `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have browser coverage proving the app intentionally omits portable settings.`,
2311
+ );
2312
+ }
2313
+
2314
+ if (!settingsTransferOptOutReasonPattern.test(evidenceText)) {
2315
+ errors.push(
2316
+ `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must explain why the complex app does not need portable settings using a concrete reason such as ephemeral, temporary, one-off, not portable, or session-only.`,
2317
+ );
2318
+ }
2319
+ }
2320
+ }
2321
+
2322
+ if (timelineMode === "keyframes") {
2323
+ const hasKeyframesCoverage = acceptance.some(
2324
+ (entry) => entry.kind === "runtime" && entry.timelineCoverage === "keyframes",
2325
+ );
2326
+
2327
+ if (!hasKeyframesCoverage) {
2328
+ errors.push(
2329
+ 'panels.timeline mode "keyframes" requires a runtime acceptance entry with timelineCoverage "keyframes" proving expanded rows, diamonds, keyframe mutation, and renderer evaluation.',
2330
+ );
2331
+ }
2332
+ }
2333
+
2334
+ for (const { control, controlId, sectionTitle } of controls) {
2335
+ const label = `${sectionTitle ? `${sectionTitle} / ` : ""}${controlId} (${control.target})`;
2336
+ const entry = controlAcceptance.get(control.target);
2337
+ const keyframeCapability = getToolcraftControlKeyframeCapability(control);
2338
+ const isCustomControl = isCustomToolcraftControl(control);
2339
+ const isSelectedLayerTarget = control.target.startsWith("selectedLayer.");
2340
+ const toggleLabelError = getToggleControlLabelError(control, sectionTitle);
2341
+
2342
+ if (toggleLabelError) {
2343
+ errors.push(`${label} ${toggleLabelError}`);
2344
+ }
2345
+
2346
+ if (
2347
+ control.type === "rangeSlider" &&
2348
+ Array.isArray(control.defaultValue) &&
2349
+ typeof control.defaultValue[0] === "number" &&
2350
+ typeof control.defaultValue[1] === "number" &&
2351
+ control.defaultValue[0] === control.defaultValue[1]
2352
+ ) {
2353
+ errors.push(
2354
+ `${label} rangeSlider defaultValue must start with different lower and upper values so the two-thumb control does not collapse into a single-value slider.`,
2355
+ );
2356
+ }
2357
+
2358
+ if (
2359
+ control.type !== "panelActions" &&
2360
+ timelineTransportControlPattern.test(getTimelineTransportControlText(controlId, control))
2361
+ ) {
2362
+ errors.push(
2363
+ `${label} looks like an app-wide timeline transport control. Play, Pause, Animate, Resume, and Restart animation belong to the top timeline; keep right-panel controls for renderer parameters, generation/apply actions, and output delivery.`,
2364
+ );
2365
+ }
2366
+
2367
+ if (shouldUseSingleCurveVariant(controlId, control)) {
2368
+ errors.push(
2369
+ `${label} is a semantic single curve and must set variant: "single"; RGB/R/G/B curve tabs are reserved for color-correction or channel-specific curves.`,
2370
+ );
2371
+ }
2372
+
2373
+ if (control.keyframeable === true && !keyframeCapability.capable) {
2374
+ errors.push(
2375
+ `${label} sets keyframeable true, but this control type or runtime-owned target cannot create timeline keyframes.`,
2376
+ );
2377
+ }
2378
+
2379
+ if (
2380
+ timelineMode === "keyframes" &&
2381
+ keyframeCapability.capable &&
2382
+ control.keyframeable === false
2383
+ ) {
2384
+ errors.push(
2385
+ `${label} is keyframe-capable by Toolcraft control type; remove keyframeable: false and provide keyframe evaluator coverage instead of hiding the diamond.`,
2386
+ );
2387
+ }
2388
+
2389
+ if (isSelectedLayerTarget && !layersEnabled) {
2390
+ errors.push(
2391
+ `${label} uses reserved selectedLayer.* target without panels.layers enabled. Use an app-specific target for single-layer apps or enable layers with layerCoverage.`,
2392
+ );
2393
+ }
2394
+
2395
+ if (control.visibleWhen) {
2396
+ errors.push(
2397
+ ...getConditionValidationErrors({
2398
+ condition: control.visibleWhen,
2399
+ conditionName: "visibleWhen",
2400
+ controlTargets,
2401
+ label,
2402
+ }),
2403
+ );
2404
+ }
2405
+
2406
+ if (control.disabledWhen) {
2407
+ errors.push(
2408
+ ...getConditionValidationErrors({
2409
+ condition: control.disabledWhen,
2410
+ conditionName: "disabledWhen",
2411
+ controlTargets,
2412
+ label,
2413
+ }),
2414
+ );
2415
+ }
2416
+
2417
+ if (!entry) {
2418
+ errors.push(`${label} is missing an acceptance entry.`);
2419
+ continue;
2420
+ }
2421
+
2422
+ if (!entry.automated) {
2423
+ errors.push(`${label} must have automated acceptance coverage.`);
2424
+ }
2425
+
2426
+ if (!entry.browser) {
2427
+ errors.push(`${label} must have browser acceptance coverage.`);
2428
+ }
2429
+
2430
+ if (entry.browser && !entry.browserTestName.trim()) {
2431
+ errors.push(`${label} must point to a browser test name.`);
2432
+ }
2433
+
2434
+ if (!entry.expectedObservable.trim()) {
2435
+ errors.push(`${label} must describe a product-level observable.`);
2436
+ }
2437
+
2438
+ if (!entry.automatedTestName.trim()) {
2439
+ errors.push(`${label} must point to an automated test name.`);
2440
+ }
2441
+
2442
+ if (entry.componentType !== control.type) {
2443
+ errors.push(
2444
+ `${label} acceptance componentType must be "${control.type}", received "${entry.componentType}".`,
2445
+ );
2446
+ }
2447
+
2448
+ if (
2449
+ isCustomControl &&
2450
+ !hasCustomControlCoverage(
2451
+ entry.customControlCoverage,
2452
+ requiredCustomControlCoverage,
2453
+ )
2454
+ ) {
2455
+ errors.push(
2456
+ `${label} is a custom control and must declare customControlCoverage for: ${requiredCustomControlCoverage.join(", ")}.`,
2457
+ );
2458
+ }
2459
+
2460
+ if (isCustomControl) {
2461
+ errors.push(...getBuiltInFitCheckErrors(label, entry));
2462
+ }
2463
+
2464
+ if (
2465
+ control.disabledWhen &&
2466
+ !/\b(disabled|unavailable|inactive|not editable|not meaningful|no effect|without effect)\b/i.test(
2467
+ getAcceptanceEvidenceText(entry),
2468
+ )
2469
+ ) {
2470
+ errors.push(
2471
+ `${label} uses disabledWhen and acceptance must prove the control becomes disabled/unavailable when ${control.disabledWhen.target} reaches the disabling value.`,
2472
+ );
2473
+ }
2474
+
2475
+ if (
2476
+ control.visibleWhen &&
2477
+ !/\b(visible|shown|show|appears|hidden|hide|hides|not visible|disappear|unavailable)\b/i.test(
2478
+ getAcceptanceEvidenceText(entry),
2479
+ )
2480
+ ) {
2481
+ errors.push(
2482
+ `${label} uses visibleWhen and acceptance must prove the control becomes visible and hidden/unavailable when ${control.visibleWhen.target} reaches the gating values.`,
2483
+ );
2484
+ }
2485
+
2486
+ if (
2487
+ schemaHasPngExportPanelAction(schema) &&
2488
+ isOutputBackgroundToggleControl({ control, controlId, sectionTitle })
2489
+ ) {
2490
+ const evidenceText = getAcceptanceEvidenceText(entry);
2491
+
2492
+ if (
2493
+ !/\b(png|image)\b/i.test(evidenceText) ||
2494
+ !/\b(transparent|transparency|alpha)\b/i.test(evidenceText) ||
2495
+ !/\b(preview|canvas|workspace|backing|video)\b/i.test(evidenceText) ||
2496
+ !/\b(keep|keeps|preserve|preserves|stay|stays|remain|remains|still|not transparent|non-transparent)\b/i.test(
2497
+ evidenceText,
2498
+ )
2499
+ ) {
2500
+ errors.push(
2501
+ `${label} controls PNG background inclusion and acceptance must prove disabling it makes PNG output transparent while live preview, workspace canvas backing, and video output keep the product background.`,
2502
+ );
2503
+ }
2504
+ }
2505
+
2506
+ const requiredControlParts =
2507
+ getRequiredToolcraftControlPartCoverage(control);
2508
+
2509
+ if (!hasControlPartCoverage(entry.controlPartCoverage, requiredControlParts)) {
2510
+ errors.push(
2511
+ `${label} must declare controlPartCoverage for every semantic value part: ${requiredControlParts.join(", ")}.`,
2512
+ );
2513
+ }
2514
+
2515
+ if (timelineMode === "keyframes" && keyframeCapability.capable) {
2516
+ if (entry.timelineCoverage !== "keyframes") {
2517
+ errors.push(
2518
+ `${label} is keyframe-capable by Toolcraft control type and must have acceptance timelineCoverage "keyframes" proving its diamond creates/updates a keyframe row and changes evaluated output.`,
2519
+ );
2520
+ }
2521
+ }
2522
+
2523
+ if (
2524
+ isSelectedLayerTarget &&
2525
+ layersEnabled &&
2526
+ entry.layerCoverage !== "selected-layer-controls"
2527
+ ) {
2528
+ errors.push(
2529
+ `${label} targets selectedLayer.* and must have acceptance layerCoverage "selected-layer-controls" proving the control edits the currently selected layer output.`,
2530
+ );
2531
+ }
2532
+
2533
+ if (isSliderLikeControl(control)) {
2534
+ const expectedMarkerCount = getStepMarkerCount(control);
2535
+
2536
+ if (
2537
+ control.variant === "discrete" &&
2538
+ expectedMarkerCount &&
2539
+ control.markerCount !== expectedMarkerCount
2540
+ ) {
2541
+ errors.push(
2542
+ `${label} discrete slider must render one marker per step; expected markerCount ${expectedMarkerCount}, received ${String(control.markerCount)}.`,
2543
+ );
2544
+ }
2545
+
2546
+ errors.push(
2547
+ ...getSliderVariantClassificationErrors({
2548
+ control,
2549
+ controlId,
2550
+ label,
2551
+ }),
2552
+ );
2553
+ }
2554
+
2555
+ if (control.type === "imagePicker") {
2556
+ const itemValues = getControlOptionValues(control);
2557
+
2558
+ if (!hasCoverageForValues(entry.optionCoverage, itemValues)) {
2559
+ errors.push(
2560
+ `${label} must cover every visible ImagePicker item: ${itemValues.join(", ")}.`,
2561
+ );
2562
+ }
2563
+ }
2564
+
2565
+ if (control.type === "select" || control.type === "segmented") {
2566
+ const optionValues = getControlOptionValues(control);
2567
+
2568
+ if (optionValues.length > 1 && !hasCoverageForValues(entry.optionCoverage, optionValues)) {
2569
+ errors.push(`${label} must cover every visible option: ${optionValues.join(", ")}.`);
2570
+ }
2571
+
2572
+ const segmentedLayoutError = getSegmentedControlLayoutError(control);
2573
+
2574
+ if (segmentedLayoutError) {
2575
+ errors.push(`${label} ${segmentedLayoutError}`);
2576
+ }
2577
+ }
2578
+
2579
+ if (control.type === "panelActions") {
2580
+ const actionValues = control.actions?.map(getActionValue) ?? [];
2581
+ const resetActionValues =
2582
+ control.actions?.filter(isResetPanelAction).map(getActionValue) ?? [];
2583
+
2584
+ if (resetActionValues.length > 0) {
2585
+ errors.push(
2586
+ `${label} must not include Reset footer actions (${resetActionValues.join(", ")}). The controls panel header owns Reset controls; sticky panelActions are only for product delivery actions such as Export, Copy, Generate, Apply, or Download.`,
2587
+ );
2588
+ }
2589
+
2590
+ if (!hasCoverageForValues(entry.actionCoverage, actionValues)) {
2591
+ errors.push(`${label} must cover every footer action: ${actionValues.join(", ")}.`);
2592
+ }
2593
+ }
2594
+ }
2595
+
2596
+ for (const entry of acceptance) {
2597
+ if (entry.kind === "control" && entry.target && !controlTargets.has(entry.target)) {
2598
+ errors.push(`${entry.id} points to missing control target ${entry.target}.`);
2599
+ }
2600
+
2601
+ if (entry.kind !== "canvas-handle") {
2602
+ continue;
2603
+ }
2604
+
2605
+ if (!entry.canvasHandle) {
2606
+ errors.push(`${entry.id} canvas handle is missing canvasHandle metadata.`);
2607
+ continue;
2608
+ }
2609
+
2610
+ if (!entry.canvasHandle.testId.trim()) {
2611
+ errors.push(`${entry.id} canvas handle must provide a stable testId.`);
2612
+ }
2613
+
2614
+ if (!entry.canvasHandle.writesTarget.trim()) {
2615
+ errors.push(`${entry.id} canvas handle must name the runtime target it writes.`);
2616
+ }
2617
+
2618
+ if (
2619
+ entry.canvasHandle.writesTarget &&
2620
+ !controlTargets.has(entry.canvasHandle.writesTarget) &&
2621
+ !commandTargets.has(entry.canvasHandle.writesTarget)
2622
+ ) {
2623
+ errors.push(
2624
+ `${entry.id} canvas handle writesTarget ${entry.canvasHandle.writesTarget} does not match a schema target or supported editor command.`,
2625
+ );
2626
+ }
2627
+
2628
+ if (!entry.canvasHandle.outputObservable.trim()) {
2629
+ errors.push(`${entry.id} canvas handle must describe the product output change.`);
2630
+ }
2631
+
2632
+ if (!entry.canvasHandle.exportCleanTestName.trim()) {
2633
+ errors.push(`${entry.id} canvas handle must point to an export-clean test.`);
2634
+ }
2635
+
2636
+ if (!entry.browser || !entry.browserTestName.trim()) {
2637
+ errors.push(`${entry.id} canvas handle must have browser drag coverage.`);
2638
+ }
2639
+
2640
+ if (!entry.automated || !entry.automatedTestName.trim()) {
2641
+ errors.push(`${entry.id} canvas handle must have automated output coverage.`);
2642
+ }
2643
+ }
2644
+
2645
+ return errors;
2646
+ }