@pixel-point/toolcraft 0.0.7 → 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 (62) 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 +185 -23
  7. package/templates/runtime/contracts/component-contracts.ts +91 -36
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +4 -4
  10. package/templates/runtime/export/export.test.ts +31 -0
  11. package/templates/runtime/export/export.ts +41 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  13. package/templates/runtime/react/canvas-shell.tsx +178 -23
  14. package/templates/runtime/react/control-conditions.ts +166 -0
  15. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  16. package/templates/runtime/react/controls-panel.test.tsx +774 -17
  17. package/templates/runtime/react/controls-panel.tsx +155 -8
  18. package/templates/runtime/react/media-file.ts +19 -0
  19. package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
  20. package/templates/runtime/schema/define-toolcraft.ts +29 -3
  21. package/templates/runtime/schema/types.ts +7 -0
  22. package/templates/runtime/state/reducer.test.ts +304 -0
  23. package/templates/runtime/state/reducer.ts +148 -9
  24. package/templates/runtime/state/types.ts +10 -2
  25. package/templates/runtime/testing/performance.test.ts +1424 -56
  26. package/templates/runtime/testing/performance.ts +710 -43
  27. package/templates/starter/AGENTS.md +10 -9
  28. package/templates/starter/docs/toolcraft/README.md +1 -1
  29. package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
  30. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
  31. package/templates/starter/docs/toolcraft/component-rules.md +46 -16
  32. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  33. package/templates/starter/docs/toolcraft/performance.md +54 -6
  34. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  35. package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
  36. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  37. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  38. package/templates/starter/e2e/performance-helpers.ts +197 -0
  39. package/templates/starter/gitignore +1 -0
  40. package/templates/starter/package.json +5 -0
  41. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  42. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  43. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  44. package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
  45. package/templates/starter/src/app/starter-acceptance.ts +740 -24
  46. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  47. package/templates/ui/components/control-layout/index.tsx +8 -3
  48. package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
  49. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  50. package/templates/ui/components/controls/color/index.ts +4 -1
  51. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  52. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  53. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  54. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  55. package/templates/ui/components/controls/index.ts +3 -0
  56. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  57. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  58. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  59. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  60. package/templates/ui/components/controls/vector/index.ts +1 -0
  61. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  62. package/templates/ui/components/panel/panel-section.tsx +82 -6
@@ -147,9 +147,11 @@ export type ToolcraftCustomControlCoverage =
147
147
  const builtInToolcraftControlTypeValues = [
148
148
  "actions",
149
149
  "anchorGrid",
150
+ "aspectRatio",
150
151
  "channelMixer",
151
152
  "checkbox",
152
153
  "code",
154
+ "collectionActions",
153
155
  "color",
154
156
  "colorOpacity",
155
157
  "curves",
@@ -171,6 +173,8 @@ const builtInToolcraftControlTypeValues = [
171
173
  ] as const;
172
174
  const settingsTransferOptOutReasonPattern =
173
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;
174
178
 
175
179
  export type ToolcraftBuiltInControlType =
176
180
  (typeof builtInToolcraftControlTypeValues)[number];
@@ -263,6 +267,15 @@ export type ToolcraftControlOrderItem = {
263
267
  type: string;
264
268
  };
265
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
+
266
279
  export const starterTransferMode: ToolcraftTransferMode = {
267
280
  animationIntent: { mode: "none" },
268
281
  mode: "new-toolcraft-app",
@@ -276,10 +289,16 @@ export const starterProductReadiness: ToolcraftProductReadiness = {
276
289
 
277
290
  export const starterAcceptance: readonly ToolcraftComponentAcceptance[] = [];
278
291
 
292
+ export const starterControlSectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [];
293
+
279
294
  function getActionValue(action: ToolcraftActionSchema | string): string {
280
295
  return typeof action === "string" ? action : action.value;
281
296
  }
282
297
 
298
+ function getActionLabelText(action: ToolcraftActionSchema | string): string {
299
+ return typeof action === "string" ? action : (action.label ?? action.value);
300
+ }
301
+
283
302
  function getActionSearchText(action: ToolcraftActionSchema | string): string {
284
303
  return typeof action === "string" ? action : `${action.label} ${action.value} ${action.command ?? ""}`;
285
304
  }
@@ -385,6 +404,34 @@ function getAcceptanceEvidenceText(entry: ToolcraftComponentAcceptance): string
385
404
  ].join(" ");
386
405
  }
387
406
 
407
+ function getFileDropLifecycleCoverageErrors(
408
+ label: string,
409
+ entry: ToolcraftComponentAcceptance,
410
+ ): string[] {
411
+ const errors: string[] = [];
412
+ const evidenceText = getAcceptanceEvidenceText(entry);
413
+
414
+ if (entry.evidence !== "media-lifecycle") {
415
+ errors.push(
416
+ `${label} fileDrop acceptance evidence must be "media-lifecycle" so upload, clear, and reset behavior cannot be replaced by generic product-output coverage.`,
417
+ );
418
+ }
419
+
420
+ if (
421
+ !/\b(upload|import|drop|drag|browse|choose|select file|source image)\b/i.test(
422
+ evidenceText,
423
+ ) ||
424
+ !/\b(clear|remove|delete|trash)\b/i.test(evidenceText) ||
425
+ !/\b(reset|reset controls|section reset|global reset)\b/i.test(evidenceText)
426
+ ) {
427
+ errors.push(
428
+ `${label} fileDrop acceptance must prove upload/import, clear/remove, and section or global reset remove source media.`,
429
+ );
430
+ }
431
+
432
+ return errors;
433
+ }
434
+
388
435
  const conditionOperatorLabels = [
389
436
  "equals",
390
437
  "notEquals",
@@ -520,9 +567,109 @@ function isCustomToolcraftControl(control: ToolcraftControlSchema): boolean {
520
567
  return !builtInToolcraftControlTypes.has(control.type);
521
568
  }
522
569
 
570
+ const collectionEntityCustomControlRe =
571
+ /\b(collection|repeatable|list|lists|item|items|entry|entries|row|rows|asset|assets|object|objects|color|colors|swatch|swatches|glyph|glyphs|symbol|symbols|point|points|stop|stops|variant|variants|rule|rules|mask|masks|shape|shapes|layer|layers|media|image|images|file|files)\b/i;
572
+ const collectionOperationCustomControlRe =
573
+ /\b(add|adding|delete|deleting|remove|removing|reorder|reordering|order|ordering|sort|sorting|select|selecting|selected|selection|duplicate|duplicating|upload|import|clear|clearing)\b/i;
574
+
575
+ const actionLikeCustomControlRe =
576
+ /\b(add|adding|delete|deleting|remove|removing|duplicate|duplicating|sort|sorting|normalize|normalizing|clear|clearing|reset|shuffle|randomize|randomizing)\b/i;
577
+
578
+ const chromeOnlyCustomControlReasonRe =
579
+ /\b(icon|icons|visual|style|styling|layout|spacing|chrome|button|buttons|compact|custom look|custom ui)\b/i;
580
+ const productInteractionCustomControlReasonRe =
581
+ /\b(runtime|state|canvas|output|export|upload|import|preview|reorder|ordering|sort|drag|resize|handle|threshold|density|mapping|geometry|nested|multi|multiple|per-item|metadata|hit target|validation|selection)\b/i;
582
+ const collectionValueKeyRe =
583
+ /^(items?|entries|rows|assets|objects|colors?|glyphs?|symbols?|points?|stops?|variants?|rules?|masks?|shapes?|layers?|media|images?|files?)$/i;
584
+
585
+ function isRecord(value: unknown): value is Record<string, unknown> {
586
+ return typeof value === "object" && value !== null && !Array.isArray(value);
587
+ }
588
+
589
+ function stringifyUnknownForFitCheck(value: unknown): string {
590
+ if (value == null) {
591
+ return "";
592
+ }
593
+
594
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
595
+ return String(value);
596
+ }
597
+
598
+ if (Array.isArray(value)) {
599
+ return value.map(stringifyUnknownForFitCheck).join(" ");
600
+ }
601
+
602
+ if (isRecord(value)) {
603
+ return Object.entries(value)
604
+ .flatMap(([key, entryValue]) => [key, stringifyUnknownForFitCheck(entryValue)])
605
+ .join(" ");
606
+ }
607
+
608
+ return "";
609
+ }
610
+
611
+ function arrayLooksLikeCollectionValue(value: readonly unknown[]): boolean {
612
+ return (
613
+ value.length === 0 ||
614
+ value.some((item) => isRecord(item) || Array.isArray(item) || typeof item === "string")
615
+ );
616
+ }
617
+
618
+ function hasCollectionValueShape(value: unknown): boolean {
619
+ if (!isRecord(value)) {
620
+ return Array.isArray(value) && arrayLooksLikeCollectionValue(value);
621
+ }
622
+
623
+ return Object.entries(value).some(([key, entryValue]) => {
624
+ if (Array.isArray(entryValue)) {
625
+ return collectionValueKeyRe.test(key) || arrayLooksLikeCollectionValue(entryValue);
626
+ }
627
+
628
+ return collectionValueKeyRe.test(key) && isRecord(entryValue);
629
+ });
630
+ }
631
+
632
+ function getCustomFitCheckSearchText(
633
+ entry: ToolcraftComponentAcceptance,
634
+ control?: ToolcraftControlSchema,
635
+ ): string {
636
+ return [
637
+ control?.type,
638
+ control?.target,
639
+ typeof control?.label === "string" ? control.label : undefined,
640
+ stringifyUnknownForFitCheck(control?.defaultValue),
641
+ entry.id,
642
+ entry.target,
643
+ entry.componentType,
644
+ entry.expectedObservable,
645
+ entry.userAction,
646
+ entry.builtInFitCheck?.whyInsufficient,
647
+ entry.builtInFitCheck?.productObservable,
648
+ ]
649
+ .filter(Boolean)
650
+ .join(" ");
651
+ }
652
+
653
+ function isCollectionLikeCustomControl(
654
+ entry: ToolcraftComponentAcceptance,
655
+ control: ToolcraftControlSchema,
656
+ ): boolean {
657
+ if (hasCollectionValueShape(control.defaultValue)) {
658
+ return true;
659
+ }
660
+
661
+ const searchText = getCustomFitCheckSearchText(entry, control);
662
+
663
+ return (
664
+ collectionEntityCustomControlRe.test(searchText) &&
665
+ collectionOperationCustomControlRe.test(searchText)
666
+ );
667
+ }
668
+
523
669
  function getBuiltInFitCheckErrors(
524
670
  label: string,
525
671
  entry: ToolcraftComponentAcceptance,
672
+ control: ToolcraftControlSchema,
526
673
  ): string[] {
527
674
  const fitCheck = entry.builtInFitCheck;
528
675
 
@@ -574,6 +721,32 @@ function getBuiltInFitCheckErrors(
574
721
  );
575
722
  }
576
723
 
724
+ const searchText = getCustomFitCheckSearchText(entry, control);
725
+
726
+ if (
727
+ isCollectionLikeCustomControl(entry, control) &&
728
+ !checkedBuiltIns.includes("collectionActions")
729
+ ) {
730
+ errors.push(
731
+ `${label} builtInFitCheck.checkedBuiltIns must include collectionActions when the custom control owns a growable, removable, selectable, or reorderable runtime item set.`,
732
+ );
733
+ }
734
+
735
+ if (actionLikeCustomControlRe.test(searchText) && !checkedBuiltIns.includes("actions")) {
736
+ errors.push(
737
+ `${label} builtInFitCheck.checkedBuiltIns must include actions when the custom control exposes local command buttons such as add, remove, delete, duplicate, sort, normalize, or clear.`,
738
+ );
739
+ }
740
+
741
+ if (
742
+ chromeOnlyCustomControlReasonRe.test(fitCheck.whyInsufficient) &&
743
+ !productInteractionCustomControlReasonRe.test(fitCheck.whyInsufficient)
744
+ ) {
745
+ errors.push(
746
+ `${label} builtInFitCheck.whyInsufficient cannot justify a custom control only with icons, layout, styling, or custom buttons; name the product interaction or value model that built-ins cannot express.`,
747
+ );
748
+ }
749
+
577
750
  return errors;
578
751
  }
579
752
 
@@ -714,6 +887,68 @@ function getControlLabelText(control: ToolcraftControlSchema): string {
714
887
  return typeof control.label === "string" ? control.label : "";
715
888
  }
716
889
 
890
+ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
891
+ return typeof control.label === "string" && control.label.trim().length > 0;
892
+ }
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
+
717
952
  const singleCurveSemanticPattern =
718
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;
719
954
  const rgbCurveSemanticPattern =
@@ -772,6 +1007,33 @@ function getToggleControlLabelError(
772
1007
  return undefined;
773
1008
  }
774
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
+
775
1037
  const maxInlineSwitchLabelLength = 16;
776
1038
  const maxInlineSwitchLabelWordCount = 2;
777
1039
 
@@ -1160,7 +1422,7 @@ function isModeSelectorControl(
1160
1422
  return false;
1161
1423
  }
1162
1424
 
1163
- return /mode|type|filter|blend|style|preset|variant/i.test(
1425
+ return /mode|type|filter|style|preset|variant/i.test(
1164
1426
  `${controlId} ${control.target} ${getControlLabelText(control)}`,
1165
1427
  );
1166
1428
  }
@@ -1286,7 +1548,7 @@ const maxHardControlsPerSection = 10;
1286
1548
 
1287
1549
  const controlSemanticClusterPatterns: ReadonlyArray<readonly [string, RegExp]> = [
1288
1550
  ["input", /\b(upload|source|prompt|content|text|phrase|copy|message|file|media|image)\b/i],
1289
- ["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],
1290
1552
  ["motion", /\b(animation|speed|velocity|accel|acceleration|correlation|duration|timing|loop|phase|fps|rate)\b/i],
1291
1553
  ["geometry", /\b(width|height|size|scale|position|offset|anchor|origin|target|radius|distance|spread|bend|curve|curves|path|shape|grid|gap)\b/i],
1292
1554
  ["density", /\b(fill|density|amount|count|ratio|word|words|letter|letters|particle|particles|layer|layers|island|islands)\b/i],
@@ -1524,6 +1786,73 @@ function getToolcraftControlSemanticCluster(
1524
1786
  return inferToolcraftControlOrderRole(controlId, control);
1525
1787
  }
1526
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
+
1527
1856
  function getToolcraftGenericControlLabelError({
1528
1857
  control,
1529
1858
  controlId,
@@ -1603,13 +1932,23 @@ function getToolcraftControlDescriptionError({
1603
1932
  }
1604
1933
 
1605
1934
  function isToolcraftColorSectionTitle(sectionTitle: string | undefined): boolean {
1606
- return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1935
+ return /\b(colou?rs?|palette|palettes|shades?|accents?)\b/i.test(
1936
+ sectionTitle ?? "",
1937
+ );
1607
1938
  }
1608
1939
 
1609
1940
  function isToolcraftSequentialColorLabel(label: string): boolean {
1610
1941
  return /^colou?r\s+\d+$/i.test(label.trim());
1611
1942
  }
1612
1943
 
1944
+ function isToolcraftPaletteVariationTarget(target: string): boolean {
1945
+ return (
1946
+ /(?:^|\.)(?:palette|palettes|colou?rs?|shades?|accents?)\b/i.test(
1947
+ target,
1948
+ ) || /(?:^|\.)(?:accent|shade|colou?r)\d+\b/i.test(target)
1949
+ );
1950
+ }
1951
+
1613
1952
  function isToolcraftSimplePaletteDistributionLabel(label: string): boolean {
1614
1953
  return /^(spread|mix|distribution)$/i.test(label.trim());
1615
1954
  }
@@ -1648,8 +1987,257 @@ function isToolcraftObviousColorSectionControlDescription({
1648
1987
  );
1649
1988
  }
1650
1989
 
1990
+ function getToolcraftColorBankLabelErrors({
1991
+ controls,
1992
+ sectionLabel,
1993
+ sectionTitle,
1994
+ }: {
1995
+ controls: readonly [string, ToolcraftControlSchema][];
1996
+ sectionLabel: string;
1997
+ sectionTitle: string | undefined;
1998
+ }): string[] {
1999
+ const colorControls = controls.filter(([, control]) => {
2000
+ if (control.type !== "color" && control.type !== "colorOpacity") {
2001
+ return false;
2002
+ }
2003
+
2004
+ return true;
2005
+ });
2006
+
2007
+ if (colorControls.length < 2) {
2008
+ return [];
2009
+ }
2010
+
2011
+ const loosePrefixes = new Set(
2012
+ colorControls
2013
+ .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
2014
+ .filter((prefix): prefix is string => Boolean(prefix)),
2015
+ );
2016
+
2017
+ if (loosePrefixes.size !== 1) {
2018
+ return [];
2019
+ }
2020
+
2021
+ const sequentialColorControls = colorControls.filter(([, control]) =>
2022
+ isToolcraftSequentialColorLabel(getControlLabelText(control)),
2023
+ );
2024
+ const isPaletteVariationBank =
2025
+ colorControls.every(([, control]) =>
2026
+ isToolcraftPaletteVariationTarget(control.target),
2027
+ ) ||
2028
+ (isToolcraftColorSectionTitle(sectionTitle) &&
2029
+ sequentialColorControls.length > 0);
2030
+
2031
+ if (!isPaletteVariationBank) {
2032
+ return [];
2033
+ }
2034
+
2035
+ const visibleColorControls = colorControls.filter(([, control]) =>
2036
+ hasVisibleControlLabel(control),
2037
+ );
2038
+ const errors: string[] = [];
2039
+
2040
+ if (
2041
+ visibleColorControls.length > 0 &&
2042
+ visibleColorControls.length < colorControls.length
2043
+ ) {
2044
+ errors.push(
2045
+ `${sectionLabel} mixes labeled and unlabeled color items in one palette variation group. Decide label visibility for the whole group: omit all per-item labels when colors only add variety, or label every item only when each color has a distinct user-facing role.`,
2046
+ );
2047
+ }
2048
+
2049
+ for (const [controlId, control] of sequentialColorControls) {
2050
+ const label = getControlLabelText(control).trim();
2051
+
2052
+ errors.push(
2053
+ `${sectionLabel} / ${controlId} uses visible label "${label}" for a palette variation color. When colors only add variety to one shared palette, set label: false or use collectionActions with unlabeled items. Keep visible labels only when each color edits a distinct user-facing entity such as Fill, Stroke, Background, Connector, or Object color.`,
2054
+ );
2055
+ }
2056
+
2057
+ return errors;
2058
+ }
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
+
1651
2238
  function getToolcraftControlSectionGroupingErrors(
1652
2239
  schema: ResolvedToolcraftAppSchema,
2240
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [],
1653
2241
  ): string[] {
1654
2242
  const errors: string[] = [];
1655
2243
  const visibleControls: Array<{
@@ -1657,11 +2245,23 @@ function getToolcraftControlSectionGroupingErrors(
1657
2245
  controlId: string;
1658
2246
  loosePrefix: string | null;
1659
2247
  sectionLabel: string;
2248
+ sectionTitle: string | undefined;
1660
2249
  }> = [];
1661
2250
  const strictPrefixSections = new Map<string, Set<string>>();
1662
2251
  const loosePrefixSections = new Map<string, Set<string>>();
1663
2252
  const colorSectionLoosePrefixes = new Map<string, string>();
2253
+ const sectionInventoryByTitle = getToolcraftSectionInventoryByTitle(sectionInventory);
1664
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
+ >();
1665
2265
 
1666
2266
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1667
2267
  const sectionTitle = section.title?.trim();
@@ -1706,11 +2306,8 @@ function getToolcraftControlSectionGroupingErrors(
1706
2306
  .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
1707
2307
  .filter((prefix): prefix is string => Boolean(prefix)),
1708
2308
  );
1709
- const productControls = controls.filter(
1710
- ([, control]) =>
1711
- control.type !== "settingsTransfer" &&
1712
- control.target !== "canvas.size.width" &&
1713
- control.target !== "canvas.size.height",
2309
+ const productControls = controls.filter(([, control]) =>
2310
+ isToolcraftProductSectionControl(control),
1714
2311
  );
1715
2312
  const semanticClusters = new Set(
1716
2313
  productControls.map(([controlId, control]) =>
@@ -1737,6 +2334,14 @@ function getToolcraftControlSectionGroupingErrors(
1737
2334
  );
1738
2335
  }
1739
2336
 
2337
+ errors.push(
2338
+ ...getToolcraftColorBankLabelErrors({
2339
+ controls,
2340
+ sectionLabel,
2341
+ sectionTitle,
2342
+ }),
2343
+ );
2344
+
1740
2345
  for (const [controlId, control] of controls) {
1741
2346
  const strictPrefix = getToolcraftStrictTargetPrefix(control.target);
1742
2347
  const loosePrefix = getToolcraftLooseTargetPrefix(control.target);
@@ -1768,6 +2373,14 @@ function getToolcraftControlSectionGroupingErrors(
1768
2373
  controlId,
1769
2374
  loosePrefix,
1770
2375
  sectionLabel,
2376
+ sectionTitle,
2377
+ });
2378
+ controlsByTarget.set(control.target, {
2379
+ control,
2380
+ controlId,
2381
+ loosePrefix,
2382
+ sectionLabel,
2383
+ sectionTitle,
1771
2384
  });
1772
2385
 
1773
2386
  if (strictPrefix) {
@@ -1801,6 +2414,42 @@ function getToolcraftControlSectionGroupingErrors(
1801
2414
  }
1802
2415
  }
1803
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
+
1804
2453
  const fontPickerControls = visibleControls.filter(
1805
2454
  (item) => item.control.type === "fontPicker" && item.loosePrefix,
1806
2455
  );
@@ -1833,9 +2482,12 @@ function getToolcraftControlSectionGroupingErrors(
1833
2482
  }
1834
2483
 
1835
2484
  for (const [prefix, sections] of strictPrefixSections) {
1836
- if (sections.size > 1) {
2485
+ if (
2486
+ sections.size > 1 &&
2487
+ !hasToolcraftInventorySplitEvidence({ sectionInventoryByTitle, sections })
2488
+ ) {
1837
2489
  errors.push(
1838
- `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.`,
1839
2491
  );
1840
2492
  }
1841
2493
  }
@@ -1904,6 +2556,7 @@ export function validateToolcraftAcceptanceCoverage(
1904
2556
  schema: ResolvedToolcraftAppSchema = starterSchema,
1905
2557
  acceptance: readonly ToolcraftComponentAcceptance[] = starterAcceptance,
1906
2558
  transferMode: ToolcraftTransferMode = starterTransferMode,
2559
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = starterControlSectionInventory,
1907
2560
  ): string[] {
1908
2561
  const errors: string[] = [];
1909
2562
  const controls = collectToolcraftVisibleControls(schema);
@@ -1934,7 +2587,8 @@ export function validateToolcraftAcceptanceCoverage(
1934
2587
  ]);
1935
2588
 
1936
2589
  errors.push(...getToolcraftControlOrderErrors(schema));
1937
- errors.push(...getToolcraftControlSectionGroupingErrors(schema));
2590
+ errors.push(...getToolcraftControlSectionGroupingErrors(schema, sectionInventory));
2591
+ errors.push(...getToolcraftControlSectionInventoryErrors(schema, sectionInventory));
1938
2592
 
1939
2593
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1940
2594
  const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
@@ -1954,6 +2608,16 @@ export function validateToolcraftAcceptanceCoverage(
1954
2608
  );
1955
2609
  }
1956
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
+
1957
2621
  const switchEntries = layoutGroup.controls
1958
2622
  .map((controlId) => [controlId, section.controls[controlId]] as const)
1959
2623
  .filter(
@@ -1995,6 +2659,16 @@ export function validateToolcraftAcceptanceCoverage(
1995
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.`,
1996
2660
  );
1997
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
+ }
1998
2672
  }
1999
2673
  }
2000
2674
 
@@ -2121,7 +2795,7 @@ export function validateToolcraftAcceptanceCoverage(
2121
2795
 
2122
2796
  if (!schemaHasOutputBackgroundToggleControl(controls)) {
2123
2797
  errors.push(
2124
- 'Product apps with Export PNG must expose export.includeBackground inside the required "Background" section as a Switch labeled "Include". PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; video export keeps the background.',
2798
+ 'Product apps with Export PNG must expose export.includeBackground inside the required "Background" section as a Switch labeled "Include". PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; live preview must use shouldIncludeToolcraftPreviewBackground(state); video export keeps the background.',
2125
2799
  );
2126
2800
  }
2127
2801
 
@@ -2556,6 +3230,12 @@ export function validateToolcraftAcceptanceCoverage(
2556
3230
  }
2557
3231
 
2558
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
+
2559
3239
  const fixedCanvasSizingEntry = acceptance.find(
2560
3240
  (entry) =>
2561
3241
  entry.kind === "runtime" &&
@@ -2564,14 +3244,20 @@ export function validateToolcraftAcceptanceCoverage(
2564
3244
 
2565
3245
  if (!fixedCanvasSizingEntry) {
2566
3246
  errors.push(
2567
- '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.',
2568
3248
  );
2569
3249
  } else {
2570
3250
  const evidenceText = getAcceptanceEvidenceText(fixedCanvasSizingEntry);
2571
3251
 
2572
3252
  if (!/(fixed|locked|non-editable|not user-editable|must not edit|reference-defined|product-defined)/i.test(evidenceText)) {
2573
3253
  errors.push(
2574
- `${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.`,
2575
3261
  );
2576
3262
  }
2577
3263
 
@@ -2691,11 +3377,25 @@ export function validateToolcraftAcceptanceCoverage(
2691
3377
  const isCustomControl = isCustomToolcraftControl(control);
2692
3378
  const isSelectedLayerTarget = control.target.startsWith("selectedLayer.");
2693
3379
  const toggleLabelError = getToggleControlLabelError(control, sectionTitle);
3380
+ const singleActionsLabelError = getSingleActionsControlLabelError(control);
3381
+ const codeTextareaError = getCodeTextareaControlError({
3382
+ control,
3383
+ controlId,
3384
+ label,
3385
+ });
2694
3386
 
2695
3387
  if (toggleLabelError) {
2696
3388
  errors.push(`${label} ${toggleLabelError}`);
2697
3389
  }
2698
3390
 
3391
+ if (singleActionsLabelError) {
3392
+ errors.push(`${label} ${singleActionsLabelError}`);
3393
+ }
3394
+
3395
+ if (codeTextareaError) {
3396
+ errors.push(codeTextareaError);
3397
+ }
3398
+
2699
3399
  if (
2700
3400
  control.type === "rangeSlider" &&
2701
3401
  Array.isArray(control.defaultValue) &&
@@ -2798,6 +3498,10 @@ export function validateToolcraftAcceptanceCoverage(
2798
3498
  );
2799
3499
  }
2800
3500
 
3501
+ if (control.type === "fileDrop") {
3502
+ errors.push(...getFileDropLifecycleCoverageErrors(label, entry));
3503
+ }
3504
+
2801
3505
  if (
2802
3506
  isCustomControl &&
2803
3507
  !hasCustomControlCoverage(
@@ -2811,7 +3515,7 @@ export function validateToolcraftAcceptanceCoverage(
2811
3515
  }
2812
3516
 
2813
3517
  if (isCustomControl) {
2814
- errors.push(...getBuiltInFitCheckErrors(label, entry));
3518
+ errors.push(...getBuiltInFitCheckErrors(label, entry, control));
2815
3519
  }
2816
3520
 
2817
3521
  if (
@@ -2841,17 +3545,23 @@ export function validateToolcraftAcceptanceCoverage(
2841
3545
  isOutputBackgroundToggleControl({ control, controlId, sectionTitle })
2842
3546
  ) {
2843
3547
  const evidenceText = getAcceptanceEvidenceText(entry);
2844
-
2845
- if (
2846
- !/\b(png|image)\b/i.test(evidenceText) ||
2847
- !/\b(transparent|transparency|alpha)\b/i.test(evidenceText) ||
2848
- !/\b(preview|canvas|workspace|backing|video)\b/i.test(evidenceText) ||
2849
- !/\b(keep|keeps|preserve|preserves|stay|stays|remain|remains|still|not transparent|non-transparent)\b/i.test(
3548
+ const provesPngTransparency =
3549
+ /\b(png|image)\b/i.test(evidenceText) &&
3550
+ /\b(transparent|transparency|alpha)\b/i.test(evidenceText);
3551
+ const provesPreviewTransparency =
3552
+ /\b(preview|canvas)\b/i.test(evidenceText) &&
3553
+ /\b(transparent|transparency|alpha|hide|hides|hidden|without background|no background|background off)\b/i.test(
2850
3554
  evidenceText,
2851
- )
2852
- ) {
3555
+ );
3556
+ const provesVideoBackground =
3557
+ /\bvideo\b/i.test(evidenceText) &&
3558
+ /\b(keep|keeps|preserve|preserves|stay|stays|remain|remains|still|background)\b/i.test(
3559
+ evidenceText,
3560
+ );
3561
+
3562
+ if (!provesPngTransparency || !provesPreviewTransparency || !provesVideoBackground) {
2853
3563
  errors.push(
2854
- `${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.`,
3564
+ `${label} controls background inclusion and acceptance must prove disabling it makes PNG output transparent, hides the live preview product background, and keeps video output with the product background.`,
2855
3565
  );
2856
3566
  }
2857
3567
  }
@@ -2886,6 +3596,12 @@ export function validateToolcraftAcceptanceCoverage(
2886
3596
  if (isSliderLikeControl(control)) {
2887
3597
  const expectedMarkerCount = getStepMarkerCount(control);
2888
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
+
2889
3605
  if (
2890
3606
  control.variant === "discrete" &&
2891
3607
  expectedMarkerCount &&