@pixel-point/toolcraft 0.0.8 → 0.0.9

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/generate.mjs +34 -5
  3. package/src/generate.test.mjs +12 -0
  4. package/src/package-json.mjs +15 -0
  5. package/src/package-json.test.mjs +14 -1
  6. package/templates/runtime/contracts/component-contracts.test.ts +104 -14
  7. package/templates/runtime/contracts/component-contracts.ts +54 -22
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  10. package/templates/runtime/react/controls-panel.test.tsx +374 -13
  11. package/templates/runtime/react/controls-panel.tsx +65 -4
  12. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  13. package/templates/runtime/schema/define-toolcraft.ts +25 -1
  14. package/templates/runtime/schema/types.ts +3 -0
  15. package/templates/runtime/testing/performance.test.ts +134 -0
  16. package/templates/runtime/testing/performance.ts +34 -4
  17. package/templates/starter/AGENTS.md +4 -4
  18. package/templates/starter/docs/toolcraft/README.md +1 -1
  19. package/templates/starter/docs/toolcraft/acceptance-testing.md +5 -3
  20. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -5
  21. package/templates/starter/docs/toolcraft/component-rules.md +32 -11
  22. package/templates/starter/docs/toolcraft/performance.md +7 -1
  23. package/templates/starter/docs/toolcraft/schema-reference.md +43 -14
  24. package/templates/starter/gitignore +1 -0
  25. package/templates/starter/package.json +2 -0
  26. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  27. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  28. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  29. package/templates/starter/src/app/starter-acceptance.test.ts +739 -66
  30. package/templates/starter/src/app/starter-acceptance.ts +471 -12
  31. package/templates/ui/components/control-layout/index.tsx +8 -3
  32. package/templates/ui/components/controls/actions/actions-control.tsx +11 -3
  33. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  34. package/templates/ui/components/controls/color/index.ts +4 -1
  35. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  36. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  37. package/templates/ui/components/controls/index.ts +2 -0
  38. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  39. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  40. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  41. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  42. package/templates/ui/components/controls/vector/index.ts +1 -0
  43. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  44. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -147,6 +147,7 @@ export type ToolcraftCustomControlCoverage =
147
147
  const builtInToolcraftControlTypeValues = [
148
148
  "actions",
149
149
  "anchorGrid",
150
+ "aspectRatio",
150
151
  "channelMixer",
151
152
  "checkbox",
152
153
  "code",
@@ -172,6 +173,8 @@ const builtInToolcraftControlTypeValues = [
172
173
  ] as const;
173
174
  const settingsTransferOptOutReasonPattern =
174
175
  /\b(ephemeral|temporary|one-off|not portable|session-only)\b/i;
176
+ const insufficientFixedCanvasSizingReasonPattern =
177
+ /\b(no|without|missing|lacks?|does\s+not\s+(?:have|expose)|did\s+not\s+(?:have|expose)|has\s+no|had\s+no)\b[^.]{0,80}\b(?:size|dimension|canvas|output)\b[^.]{0,80}\b(?:editor|controls?|settings?|picker|input|ui)\b|\breference\s+app\b[^.]{0,120}\b(?:no|without|missing|lacks?|does\s+not\s+(?:have|expose)|did\s+not\s+(?:have|expose)|has\s+no|had\s+no)\b[^.]{0,80}\b(?:size|dimension|canvas|output)\b/i;
175
178
 
176
179
  export type ToolcraftBuiltInControlType =
177
180
  (typeof builtInToolcraftControlTypeValues)[number];
@@ -264,6 +267,15 @@ export type ToolcraftControlOrderItem = {
264
267
  type: string;
265
268
  };
266
269
 
270
+ export type ToolcraftControlSectionInventoryEntry = {
271
+ entity?: string;
272
+ groupingReason: string;
273
+ splitReason?: string;
274
+ targets: readonly string[];
275
+ title: string;
276
+ workflowStage?: string;
277
+ };
278
+
267
279
  export const starterTransferMode: ToolcraftTransferMode = {
268
280
  animationIntent: { mode: "none" },
269
281
  mode: "new-toolcraft-app",
@@ -277,10 +289,16 @@ export const starterProductReadiness: ToolcraftProductReadiness = {
277
289
 
278
290
  export const starterAcceptance: readonly ToolcraftComponentAcceptance[] = [];
279
291
 
292
+ export const starterControlSectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [];
293
+
280
294
  function getActionValue(action: ToolcraftActionSchema | string): string {
281
295
  return typeof action === "string" ? action : action.value;
282
296
  }
283
297
 
298
+ function getActionLabelText(action: ToolcraftActionSchema | string): string {
299
+ return typeof action === "string" ? action : (action.label ?? action.value);
300
+ }
301
+
284
302
  function getActionSearchText(action: ToolcraftActionSchema | string): string {
285
303
  return typeof action === "string" ? action : `${action.label} ${action.value} ${action.command ?? ""}`;
286
304
  }
@@ -873,6 +891,64 @@ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
873
891
  return typeof control.label === "string" && control.label.trim().length > 0;
874
892
  }
875
893
 
894
+ const shortSingleLineTextMaxLength = 80;
895
+ const longTextControlEvidencePattern =
896
+ /\b(multiline|multi-line|multi\s+line|long\s+(?:text|content|prompt|instructions?)|paragraph|body\s+(?:copy|text)|rich\s+text|textarea|instructions?|script|code|json|css|shader|template|markdown|csv|xml|yaml|html|svg|list|lines|dataset|structured|source\s+text)\b/i;
897
+ const shortSingleLineTextIntentPattern =
898
+ /\b(button|label|title|name|caption|headline|cta|badge|token|word|phrase|single-line|one-line|short\s+text)\b/i;
899
+
900
+ function getControlDefaultString(control: ToolcraftControlSchema): string | undefined {
901
+ return typeof control.defaultValue === "string" ? control.defaultValue : undefined;
902
+ }
903
+
904
+ function getTextValueControlIntentText(
905
+ controlId: string,
906
+ control: ToolcraftControlSchema,
907
+ ): string {
908
+ return [
909
+ controlId,
910
+ control.target,
911
+ getControlLabelText(control),
912
+ control.description ?? "",
913
+ ]
914
+ .join(" ")
915
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2");
916
+ }
917
+
918
+ function getCodeTextareaControlError({
919
+ control,
920
+ controlId,
921
+ label,
922
+ }: {
923
+ control: ToolcraftControlSchema;
924
+ controlId: string;
925
+ label: string;
926
+ }): string | undefined {
927
+ if (control.type !== "code") {
928
+ return undefined;
929
+ }
930
+
931
+ const defaultValue = getControlDefaultString(control);
932
+ const intentText = getTextValueControlIntentText(controlId, control);
933
+ const hasLongTextEvidence =
934
+ longTextControlEvidencePattern.test(intentText) ||
935
+ (defaultValue !== undefined &&
936
+ (defaultValue.length > shortSingleLineTextMaxLength ||
937
+ /[\r\n]/.test(defaultValue)));
938
+ const looksLikeShortSingleLineText =
939
+ shortSingleLineTextIntentPattern.test(intentText) ||
940
+ (defaultValue !== undefined &&
941
+ defaultValue.trim().length > 0 &&
942
+ defaultValue.trim().length <= shortSingleLineTextMaxLength &&
943
+ !/[\r\n]/.test(defaultValue));
944
+
945
+ if (!looksLikeShortSingleLineText || hasLongTextEvidence) {
946
+ return undefined;
947
+ }
948
+
949
+ return `${label} uses CodeTextarea for short single-line text. Use type "text" for button labels, canvas labels, titles, captions, names, badges, tokens, and other short one-line content. Reserve type "code" for long, multiline, or structured values, and document that reason in description when the default is short.`;
950
+ }
951
+
876
952
  const singleCurveSemanticPattern =
877
953
  /\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;
878
954
  const rgbCurveSemanticPattern =
@@ -931,6 +1007,33 @@ function getToggleControlLabelError(
931
1007
  return undefined;
932
1008
  }
933
1009
 
1010
+ function getSingleActionsControlLabelError(
1011
+ control: ToolcraftControlSchema,
1012
+ ): string | undefined {
1013
+ if (control.type !== "actions") {
1014
+ return undefined;
1015
+ }
1016
+
1017
+ const actions = getControlActions(control);
1018
+
1019
+ if (actions.length !== 1) {
1020
+ return undefined;
1021
+ }
1022
+
1023
+ const label = getControlLabelText(control).trim();
1024
+ const actionLabel = getActionLabelText(actions[0] as ToolcraftActionSchema | string).trim();
1025
+
1026
+ if (
1027
+ label &&
1028
+ actionLabel &&
1029
+ normalizeToolcraftSemanticText(label) === normalizeToolcraftSemanticText(actionLabel)
1030
+ ) {
1031
+ return `single Actions control label "${label}" duplicates its only button label "${actionLabel}". Keep the button as the command and use a short context label such as "Ink wash", "Palette action", or "Current layer".`;
1032
+ }
1033
+
1034
+ return undefined;
1035
+ }
1036
+
934
1037
  const maxInlineSwitchLabelLength = 16;
935
1038
  const maxInlineSwitchLabelWordCount = 2;
936
1039
 
@@ -1319,7 +1422,7 @@ function isModeSelectorControl(
1319
1422
  return false;
1320
1423
  }
1321
1424
 
1322
- return /mode|type|filter|blend|style|preset|variant/i.test(
1425
+ return /mode|type|filter|style|preset|variant/i.test(
1323
1426
  `${controlId} ${control.target} ${getControlLabelText(control)}`,
1324
1427
  );
1325
1428
  }
@@ -1445,7 +1548,7 @@ const maxHardControlsPerSection = 10;
1445
1548
 
1446
1549
  const controlSemanticClusterPatterns: ReadonlyArray<readonly [string, RegExp]> = [
1447
1550
  ["input", /\b(upload|source|prompt|content|text|phrase|copy|message|file|media|image)\b/i],
1448
- ["mode", /\b(mode|type|preset|style|variant|blend|filter|layout|format|quality)\b/i],
1551
+ ["mode", /\b(mode|type|preset|style|variant|filter|layout|format|quality)\b/i],
1449
1552
  ["motion", /\b(animation|speed|velocity|accel|acceleration|correlation|duration|timing|loop|phase|fps|rate)\b/i],
1450
1553
  ["geometry", /\b(width|height|size|scale|position|offset|anchor|origin|target|radius|distance|spread|bend|curve|curves|path|shape|grid|gap)\b/i],
1451
1554
  ["density", /\b(fill|density|amount|count|ratio|word|words|letter|letters|particle|particles|layer|layers|island|islands)\b/i],
@@ -1683,6 +1786,73 @@ function getToolcraftControlSemanticCluster(
1683
1786
  return inferToolcraftControlOrderRole(controlId, control);
1684
1787
  }
1685
1788
 
1789
+ function normalizeToolcraftConditionValue(value: unknown): string | null {
1790
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1791
+ const normalized = normalizeToolcraftSemanticText(String(value));
1792
+ return normalized || null;
1793
+ }
1794
+
1795
+ return null;
1796
+ }
1797
+
1798
+ function getToolcraftConditionBranchTexts(
1799
+ condition: ToolcraftControlConditionSchema,
1800
+ gateControl: ToolcraftControlSchema,
1801
+ ): string[] {
1802
+ const rawValues =
1803
+ "equals" in condition
1804
+ ? [condition.equals]
1805
+ : Array.isArray(condition.oneOf)
1806
+ ? [...condition.oneOf]
1807
+ : [];
1808
+ const normalizedValues = rawValues
1809
+ .map(normalizeToolcraftConditionValue)
1810
+ .filter((value): value is string => Boolean(value));
1811
+ const optionLabels = (gateControl.options ?? [])
1812
+ .filter((option) =>
1813
+ normalizedValues.includes(normalizeToolcraftSemanticText(option.value)),
1814
+ )
1815
+ .map((option) => normalizeToolcraftSemanticText(option.label))
1816
+ .filter(Boolean);
1817
+
1818
+ return [...new Set([...normalizedValues, ...optionLabels])];
1819
+ }
1820
+
1821
+ function sectionTitleLooksLikeDependencyBranch({
1822
+ condition,
1823
+ gateControl,
1824
+ sectionTitle,
1825
+ }: {
1826
+ condition: ToolcraftControlConditionSchema;
1827
+ gateControl: ToolcraftControlSchema;
1828
+ sectionTitle: string | undefined;
1829
+ }): boolean {
1830
+ const sectionText = normalizeToolcraftSemanticText(sectionTitle);
1831
+
1832
+ if (!sectionText) {
1833
+ return false;
1834
+ }
1835
+
1836
+ return getToolcraftConditionBranchTexts(condition, gateControl).some(
1837
+ (branchText) =>
1838
+ branchText.length > 2 &&
1839
+ (sectionText === branchText ||
1840
+ sectionText.includes(branchText) ||
1841
+ branchText.includes(sectionText)),
1842
+ );
1843
+ }
1844
+
1845
+ function isToolcraftProductSectionControl(control: ToolcraftControlSchema): boolean {
1846
+ return (
1847
+ control.type !== "panelActions" &&
1848
+ control.type !== "settingsTransfer" &&
1849
+ control.target !== "canvas.aspectRatio" &&
1850
+ control.target !== "canvas.renderScale" &&
1851
+ control.target !== "canvas.size.width" &&
1852
+ control.target !== "canvas.size.height"
1853
+ );
1854
+ }
1855
+
1686
1856
  function getToolcraftGenericControlLabelError({
1687
1857
  control,
1688
1858
  controlId,
@@ -1887,8 +2057,187 @@ function getToolcraftColorBankLabelErrors({
1887
2057
  return errors;
1888
2058
  }
1889
2059
 
2060
+ function getToolcraftSectionInventoryByTitle(
2061
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
2062
+ ): Map<string, ToolcraftControlSectionInventoryEntry> {
2063
+ return new Map(
2064
+ sectionInventory.map((entry) => [entry.title.trim(), entry]),
2065
+ );
2066
+ }
2067
+
2068
+ function hasToolcraftInventorySplitEvidence({
2069
+ sectionInventoryByTitle,
2070
+ sections,
2071
+ }: {
2072
+ sectionInventoryByTitle: ReadonlyMap<string, ToolcraftControlSectionInventoryEntry>;
2073
+ sections: ReadonlySet<string>;
2074
+ }): boolean {
2075
+ const entries = [...sections].map((sectionLabel) =>
2076
+ sectionInventoryByTitle.get(sectionLabel),
2077
+ );
2078
+
2079
+ return (
2080
+ entries.length === sections.size &&
2081
+ entries.every(
2082
+ (entry) =>
2083
+ entry &&
2084
+ (entry.workflowStage?.trim().length ?? 0) > 0 &&
2085
+ (entry.splitReason?.trim().length ?? 0) >= 12,
2086
+ )
2087
+ );
2088
+ }
2089
+
2090
+ function getToolcraftControlSectionInventoryErrors(
2091
+ schema: ResolvedToolcraftAppSchema,
2092
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
2093
+ ): string[] {
2094
+ const errors: string[] = [];
2095
+
2096
+ if (sectionInventory.length === 0) {
2097
+ return errors;
2098
+ }
2099
+
2100
+ const schemaSections = (schema.panels.controls?.sections ?? []).flatMap(
2101
+ (section, sectionIndex) => {
2102
+ const targets = Object.values(section.controls)
2103
+ .filter(isToolcraftProductSectionControl)
2104
+ .map((control) => control.target);
2105
+
2106
+ if (targets.length === 0) {
2107
+ return [];
2108
+ }
2109
+
2110
+ return [
2111
+ {
2112
+ label: getToolcraftSectionLabel(section.title, sectionIndex),
2113
+ targets,
2114
+ },
2115
+ ];
2116
+ },
2117
+ );
2118
+ const schemaSectionTitles = new Set(schemaSections.map((section) => section.label));
2119
+ const schemaTargetToSection = new Map<string, string>();
2120
+
2121
+ for (const section of schemaSections) {
2122
+ for (const target of section.targets) {
2123
+ schemaTargetToSection.set(target, section.label);
2124
+ }
2125
+ }
2126
+
2127
+ const inventoryTitleCounts = new Map<string, number>();
2128
+ const inventoryTargetToSection = new Map<string, string>();
2129
+
2130
+ for (const entry of sectionInventory) {
2131
+ const title = entry.title.trim();
2132
+ const entity = entry.entity?.trim() ?? "";
2133
+ const workflowStage = entry.workflowStage?.trim() ?? "";
2134
+ const groupingReason = entry.groupingReason.trim();
2135
+ const splitReason = entry.splitReason?.trim() ?? "";
2136
+
2137
+ if (!title) {
2138
+ errors.push(
2139
+ "Control Section Inventory contains an entry without a section title.",
2140
+ );
2141
+ continue;
2142
+ }
2143
+
2144
+ inventoryTitleCounts.set(title, (inventoryTitleCounts.get(title) ?? 0) + 1);
2145
+
2146
+ if (!schemaSectionTitles.has(title)) {
2147
+ errors.push(
2148
+ `Control Section Inventory references "${title}", but no rendered product controls section uses that title.`,
2149
+ );
2150
+ }
2151
+
2152
+ if (!entity && !workflowStage) {
2153
+ errors.push(
2154
+ `Control Section Inventory entry "${title}" must declare entity or workflowStage so grouping is based on product meaning, not UI layout.`,
2155
+ );
2156
+ }
2157
+
2158
+ if (groupingReason.length < 12) {
2159
+ errors.push(
2160
+ `Control Section Inventory entry "${title}" must include a concrete groupingReason explaining why these controls belong together.`,
2161
+ );
2162
+ }
2163
+
2164
+ if (splitReason && splitReason.length < 12) {
2165
+ errors.push(
2166
+ `Control Section Inventory entry "${title}" splitReason is too vague. Explain the product workflow split or omit splitReason.`,
2167
+ );
2168
+ }
2169
+
2170
+ if (entry.targets.length === 0) {
2171
+ errors.push(
2172
+ `Control Section Inventory entry "${title}" must list the product control targets rendered in that section.`,
2173
+ );
2174
+ }
2175
+
2176
+ for (const target of entry.targets) {
2177
+ const schemaSection = schemaTargetToSection.get(target);
2178
+
2179
+ if (!schemaSection) {
2180
+ errors.push(
2181
+ `Control Section Inventory entry "${title}" lists target "${target}", but that target is not rendered by any product controls section.`,
2182
+ );
2183
+ continue;
2184
+ }
2185
+
2186
+ if (schemaSection !== title) {
2187
+ errors.push(
2188
+ `Control Section Inventory entry "${title}" lists target "${target}", but the schema renders it in "${schemaSection}". Update the schema grouping or the inventory.`,
2189
+ );
2190
+ }
2191
+
2192
+ const existingSection = inventoryTargetToSection.get(target);
2193
+
2194
+ if (existingSection && existingSection !== title) {
2195
+ errors.push(
2196
+ `Control Section Inventory lists target "${target}" in both "${existingSection}" and "${title}". Each product control target belongs to exactly one section inventory entry.`,
2197
+ );
2198
+ }
2199
+
2200
+ inventoryTargetToSection.set(target, title);
2201
+ }
2202
+ }
2203
+
2204
+ for (const [title, count] of inventoryTitleCounts) {
2205
+ if (count > 1) {
2206
+ errors.push(
2207
+ `Control Section Inventory repeats section "${title}" ${count} times. Section inventory entries must be unique.`,
2208
+ );
2209
+ }
2210
+ }
2211
+
2212
+ for (const section of schemaSections) {
2213
+ const entry = sectionInventory.find(
2214
+ (inventoryEntry) => inventoryEntry.title.trim() === section.label,
2215
+ );
2216
+
2217
+ if (!entry) {
2218
+ errors.push(
2219
+ `Control Section Inventory is missing product section "${section.label}". Add its entity/workflow stage, targets, and groupingReason.`,
2220
+ );
2221
+ continue;
2222
+ }
2223
+
2224
+ const inventoryTargets = new Set(entry.targets);
2225
+
2226
+ for (const target of section.targets) {
2227
+ if (!inventoryTargets.has(target)) {
2228
+ errors.push(
2229
+ `Control Section Inventory entry "${section.label}" is missing rendered target "${target}". The inventory must cover every product control in the section.`,
2230
+ );
2231
+ }
2232
+ }
2233
+ }
2234
+
2235
+ return errors;
2236
+ }
2237
+
1890
2238
  function getToolcraftControlSectionGroupingErrors(
1891
2239
  schema: ResolvedToolcraftAppSchema,
2240
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [],
1892
2241
  ): string[] {
1893
2242
  const errors: string[] = [];
1894
2243
  const visibleControls: Array<{
@@ -1896,11 +2245,23 @@ function getToolcraftControlSectionGroupingErrors(
1896
2245
  controlId: string;
1897
2246
  loosePrefix: string | null;
1898
2247
  sectionLabel: string;
2248
+ sectionTitle: string | undefined;
1899
2249
  }> = [];
1900
2250
  const strictPrefixSections = new Map<string, Set<string>>();
1901
2251
  const loosePrefixSections = new Map<string, Set<string>>();
1902
2252
  const colorSectionLoosePrefixes = new Map<string, string>();
2253
+ const sectionInventoryByTitle = getToolcraftSectionInventoryByTitle(sectionInventory);
1903
2254
  const sectionTitleCounts = new Map<string, { count: number; label: string }>();
2255
+ const controlsByTarget = new Map<
2256
+ string,
2257
+ {
2258
+ control: ToolcraftControlSchema;
2259
+ controlId: string;
2260
+ loosePrefix: string | null;
2261
+ sectionLabel: string;
2262
+ sectionTitle: string | undefined;
2263
+ }
2264
+ >();
1904
2265
 
1905
2266
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1906
2267
  const sectionTitle = section.title?.trim();
@@ -1945,11 +2306,8 @@ function getToolcraftControlSectionGroupingErrors(
1945
2306
  .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
1946
2307
  .filter((prefix): prefix is string => Boolean(prefix)),
1947
2308
  );
1948
- const productControls = controls.filter(
1949
- ([, control]) =>
1950
- control.type !== "settingsTransfer" &&
1951
- control.target !== "canvas.size.width" &&
1952
- control.target !== "canvas.size.height",
2309
+ const productControls = controls.filter(([, control]) =>
2310
+ isToolcraftProductSectionControl(control),
1953
2311
  );
1954
2312
  const semanticClusters = new Set(
1955
2313
  productControls.map(([controlId, control]) =>
@@ -2015,6 +2373,14 @@ function getToolcraftControlSectionGroupingErrors(
2015
2373
  controlId,
2016
2374
  loosePrefix,
2017
2375
  sectionLabel,
2376
+ sectionTitle,
2377
+ });
2378
+ controlsByTarget.set(control.target, {
2379
+ control,
2380
+ controlId,
2381
+ loosePrefix,
2382
+ sectionLabel,
2383
+ sectionTitle,
2018
2384
  });
2019
2385
 
2020
2386
  if (strictPrefix) {
@@ -2048,6 +2414,42 @@ function getToolcraftControlSectionGroupingErrors(
2048
2414
  }
2049
2415
  }
2050
2416
 
2417
+ for (const item of visibleControls) {
2418
+ for (const [conditionName, condition] of [
2419
+ ["visibleWhen", item.control.visibleWhen],
2420
+ ["disabledWhen", item.control.disabledWhen],
2421
+ ] as const) {
2422
+ if (!condition || !item.loosePrefix) {
2423
+ continue;
2424
+ }
2425
+
2426
+ const gateControl = controlsByTarget.get(condition.target);
2427
+
2428
+ if (!gateControl || gateControl.sectionLabel === item.sectionLabel) {
2429
+ continue;
2430
+ }
2431
+
2432
+ const sharesTargetEntity =
2433
+ gateControl.loosePrefix !== null &&
2434
+ gateControl.loosePrefix === item.loosePrefix;
2435
+ const looksLikeBranchSection =
2436
+ conditionName === "visibleWhen" &&
2437
+ sectionTitleLooksLikeDependencyBranch({
2438
+ condition,
2439
+ gateControl: gateControl.control,
2440
+ sectionTitle: item.sectionTitle,
2441
+ });
2442
+
2443
+ if (!sharesTargetEntity && !looksLikeBranchSection) {
2444
+ continue;
2445
+ }
2446
+
2447
+ errors.push(
2448
+ `${item.sectionLabel} / ${item.controlId} is gated by ${conditionName} target "${condition.target}" in ${gateControl.sectionLabel}, but it belongs to the same dependency group. Keep selectors and their dependent controls in one semantic section when they describe one product entity or branch; use visibleWhen/disabledWhen inside that section instead of splitting branch controls into their own section.`,
2449
+ );
2450
+ }
2451
+ }
2452
+
2051
2453
  const fontPickerControls = visibleControls.filter(
2052
2454
  (item) => item.control.type === "fontPicker" && item.loosePrefix,
2053
2455
  );
@@ -2080,9 +2482,12 @@ function getToolcraftControlSectionGroupingErrors(
2080
2482
  }
2081
2483
 
2082
2484
  for (const [prefix, sections] of strictPrefixSections) {
2083
- if (sections.size > 1) {
2485
+ if (
2486
+ sections.size > 1 &&
2487
+ !hasToolcraftInventorySplitEvidence({ sectionInventoryByTitle, sections })
2488
+ ) {
2084
2489
  errors.push(
2085
- `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.`,
2490
+ `Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the Control Section Inventory declares workflowStage and splitReason for every split section.`,
2086
2491
  );
2087
2492
  }
2088
2493
  }
@@ -2151,6 +2556,7 @@ export function validateToolcraftAcceptanceCoverage(
2151
2556
  schema: ResolvedToolcraftAppSchema = starterSchema,
2152
2557
  acceptance: readonly ToolcraftComponentAcceptance[] = starterAcceptance,
2153
2558
  transferMode: ToolcraftTransferMode = starterTransferMode,
2559
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = starterControlSectionInventory,
2154
2560
  ): string[] {
2155
2561
  const errors: string[] = [];
2156
2562
  const controls = collectToolcraftVisibleControls(schema);
@@ -2181,7 +2587,8 @@ export function validateToolcraftAcceptanceCoverage(
2181
2587
  ]);
2182
2588
 
2183
2589
  errors.push(...getToolcraftControlOrderErrors(schema));
2184
- errors.push(...getToolcraftControlSectionGroupingErrors(schema));
2590
+ errors.push(...getToolcraftControlSectionGroupingErrors(schema, sectionInventory));
2591
+ errors.push(...getToolcraftControlSectionInventoryErrors(schema, sectionInventory));
2185
2592
 
2186
2593
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
2187
2594
  const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
@@ -2201,6 +2608,16 @@ export function validateToolcraftAcceptanceCoverage(
2201
2608
  );
2202
2609
  }
2203
2610
 
2611
+ const segmentedIds = layoutGroup.controls.filter(
2612
+ (controlId) => section.controls[controlId]?.type === "segmented",
2613
+ );
2614
+
2615
+ if (segmentedIds.length > 0) {
2616
+ errors.push(
2617
+ `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes segmented control ${segmentedIds.join(", ")}. Segmented is full-width and must not share a two-column or half-width row; use Select when a finite choice must fit beside another control.`,
2618
+ );
2619
+ }
2620
+
2204
2621
  const switchEntries = layoutGroup.controls
2205
2622
  .map((controlId) => [controlId, section.controls[controlId]] as const)
2206
2623
  .filter(
@@ -2242,6 +2659,16 @@ export function validateToolcraftAcceptanceCoverage(
2242
2659
  `${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, such as "Include" inside Background, or stack the controls.`,
2243
2660
  );
2244
2661
  }
2662
+
2663
+ const visibleParameterLabels = parameterEntries.filter(
2664
+ ([, control]) => control.label !== false,
2665
+ );
2666
+
2667
+ if (visibleParameterLabels.length > 0) {
2668
+ errors.push(
2669
+ `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" pairs a toggle with parameter labels ${visibleParameterLabels.map(([controlId, control]) => `${controlId} "${getControlLabelText(control)}"`).join(", ")}. In toggle-plus-parameter rows, the non-toggle parameter must use label false; if that label is needed, stack the controls instead.`,
2670
+ );
2671
+ }
2245
2672
  }
2246
2673
  }
2247
2674
 
@@ -2803,6 +3230,12 @@ export function validateToolcraftAcceptanceCoverage(
2803
3230
  }
2804
3231
 
2805
3232
  if (schema.canvas.sizing.mode === "fixed-output") {
3233
+ if (schemaHasPngExportPanelAction(schema) || schemaHasVideoExportPanelAction(schema)) {
3234
+ errors.push(
3235
+ 'Product/output apps with export actions must use canvas.sizing mode "editable-output" so Aspect ratio, Canvas width, and Canvas height are always available. Put reference, fixed-format, or user-requested dimensions in canvas.size as the initial value instead of hiding size controls with "fixed-output".',
3236
+ );
3237
+ }
3238
+
2806
3239
  const fixedCanvasSizingEntry = acceptance.find(
2807
3240
  (entry) =>
2808
3241
  entry.kind === "runtime" &&
@@ -2811,14 +3244,20 @@ export function validateToolcraftAcceptanceCoverage(
2811
3244
 
2812
3245
  if (!fixedCanvasSizingEntry) {
2813
3246
  errors.push(
2814
- '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".',
3247
+ 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. Product/output apps must use "editable-output"; user-provided, reference, fixed-format, or base/default sizes belong in canvas.size as editable initial values.',
2815
3248
  );
2816
3249
  } else {
2817
3250
  const evidenceText = getAcceptanceEvidenceText(fixedCanvasSizingEntry);
2818
3251
 
2819
3252
  if (!/(fixed|locked|non-editable|not user-editable|must not edit|reference-defined|product-defined)/i.test(evidenceText)) {
2820
3253
  errors.push(
2821
- `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the product output dimensions are intentionally fixed, not merely initialized from a default size.`,
3254
+ `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the output dimensions are intentionally fixed for a non-product/internal fixture, not merely initialized from a default size.`,
3255
+ );
3256
+ }
3257
+
3258
+ if (insufficientFixedCanvasSizingReasonPattern.test(evidenceText)) {
3259
+ errors.push(
3260
+ `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" cannot be justified by the reference or previous app lacking a size editor. Exportable product apps must use editable-output; put reference, product-defined, or fixed-format dimensions in canvas.size as initial values instead of hiding size controls.`,
2822
3261
  );
2823
3262
  }
2824
3263
 
@@ -2938,11 +3377,25 @@ export function validateToolcraftAcceptanceCoverage(
2938
3377
  const isCustomControl = isCustomToolcraftControl(control);
2939
3378
  const isSelectedLayerTarget = control.target.startsWith("selectedLayer.");
2940
3379
  const toggleLabelError = getToggleControlLabelError(control, sectionTitle);
3380
+ const singleActionsLabelError = getSingleActionsControlLabelError(control);
3381
+ const codeTextareaError = getCodeTextareaControlError({
3382
+ control,
3383
+ controlId,
3384
+ label,
3385
+ });
2941
3386
 
2942
3387
  if (toggleLabelError) {
2943
3388
  errors.push(`${label} ${toggleLabelError}`);
2944
3389
  }
2945
3390
 
3391
+ if (singleActionsLabelError) {
3392
+ errors.push(`${label} ${singleActionsLabelError}`);
3393
+ }
3394
+
3395
+ if (codeTextareaError) {
3396
+ errors.push(codeTextareaError);
3397
+ }
3398
+
2946
3399
  if (
2947
3400
  control.type === "rangeSlider" &&
2948
3401
  Array.isArray(control.defaultValue) &&
@@ -3143,6 +3596,12 @@ export function validateToolcraftAcceptanceCoverage(
3143
3596
  if (isSliderLikeControl(control)) {
3144
3597
  const expectedMarkerCount = getStepMarkerCount(control);
3145
3598
 
3599
+ if (control.unit === "x") {
3600
+ errors.push(
3601
+ `${label} uses unit "x", but Toolcraft slider values do not use x suffixes. Omit unit for scale, multiplier, intensity, opacity, strength, depth, and shader amount values unless a real measurement unit applies.`,
3602
+ );
3603
+ }
3604
+
3146
3605
  if (
3147
3606
  control.variant === "discrete" &&
3148
3607
  expectedMarkerCount &&