@pixel-point/toolcraft 0.0.9 → 0.0.12

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 (77) hide show
  1. package/README.md +42 -9
  2. package/package.json +2 -1
  3. package/src/cli.mjs +4 -2
  4. package/src/cli.test.mjs +5 -2
  5. package/src/generate.mjs +14 -6
  6. package/src/generate.test.mjs +33 -2
  7. package/templates/runtime/contracts/component-contracts.test.ts +175 -36
  8. package/templates/runtime/contracts/component-contracts.ts +90 -45
  9. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +478 -27
  17. package/templates/runtime/react/controls-panel.tsx +71 -26
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  27. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +41 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +657 -2
  41. package/templates/runtime/testing/performance.ts +789 -49
  42. package/templates/starter/AGENTS.md +22 -16
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +58 -37
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +30 -10
  50. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +57 -4
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/index.html +1 -0
  56. package/templates/starter/package.json +3 -2
  57. package/templates/starter/playwright.config.ts +1 -1
  58. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  59. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  60. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  61. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  62. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  63. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  64. package/templates/starter/src/app/starter-performance.test.ts +130 -8
  65. package/templates/starter/src/app/starter-performance.ts +5 -0
  66. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  67. package/templates/starter/src/app/starter-schema.ts +6 -2
  68. package/templates/starter/vite.config.ts +58 -2
  69. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  70. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  71. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  72. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  73. package/templates/ui/components/controls/index.ts +2 -0
  74. package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
  75. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  76. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  77. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -1,7 +1,4 @@
1
- import {
2
- getToolcraftControlKeyframeCapability,
3
- getToolcraftSettingsTransferEligibility,
4
- } from "@repo/toolcraft-runtime";
1
+ import { getToolcraftControlKeyframeCapability } from "@repo/toolcraft-runtime";
5
2
  import type {
6
3
  ToolcraftActionSchema,
7
4
  ToolcraftControlConditionSchema,
@@ -58,7 +55,7 @@ export type ToolcraftTimelinePlaybackCoverage =
58
55
  | "rendered-frame"
59
56
  | "scrub";
60
57
 
61
- export type ToolcraftCanvasSizingCoverage = "fixed-output-size";
58
+ export type ToolcraftCanvasSizingCoverage = "fixed-output-size" | "intrinsic-media-size";
62
59
 
63
60
  export type ToolcraftPersistenceCoverage = "reload";
64
61
 
@@ -72,6 +69,17 @@ export type ToolcraftAutonomousAnimationCoverage =
72
69
  | "no-scrub"
73
70
  | "no-user-facing-transport";
74
71
 
72
+ export type ToolcraftTimelineLoopDurationSource =
73
+ | "product-derived"
74
+ | "reference"
75
+ | "user-request";
76
+
77
+ export type ToolcraftTimelineLoopDurationIntent = {
78
+ evidence: string;
79
+ seconds: number;
80
+ source: ToolcraftTimelineLoopDurationSource;
81
+ };
82
+
75
83
  export type ToolcraftAnimationIntent =
76
84
  | {
77
85
  mode: "none";
@@ -82,9 +90,11 @@ export type ToolcraftAnimationIntent =
82
90
  reason: string;
83
91
  }
84
92
  | {
93
+ loopDuration: ToolcraftTimelineLoopDurationIntent;
85
94
  mode: "timeline-keyframes";
86
95
  }
87
96
  | {
97
+ loopDuration: ToolcraftTimelineLoopDurationIntent;
88
98
  mode: "timeline-playback";
89
99
  };
90
100
 
@@ -96,9 +106,71 @@ export type ToolcraftReferenceTimelineMode =
96
106
 
97
107
  export type ToolcraftReferenceTimelineContract = {
98
108
  behaviorCoverage: readonly ToolcraftReferenceTimelineCoverage[];
109
+ loopDuration?: ToolcraftTimelineLoopDurationIntent;
99
110
  mode: ToolcraftReferenceTimelineMode;
100
111
  };
101
112
 
113
+ export type ToolcraftReferenceStudyStatus =
114
+ | "ran-original"
115
+ | "restored-local"
116
+ | "source-inspection-only";
117
+
118
+ export type ToolcraftReferenceStudyEvidence = {
119
+ behaviorEvidence: string;
120
+ referenceLocation: string;
121
+ reproductionSteps: string;
122
+ sourceEvidence: string;
123
+ sourceOnlyReason?: string;
124
+ status: ToolcraftReferenceStudyStatus;
125
+ };
126
+
127
+ export type ToolcraftVideoReferenceStoryboardFrame = {
128
+ behaviorObservation: string;
129
+ frameId: string;
130
+ frameSource: string;
131
+ timeSeconds: number;
132
+ visualObservation: string;
133
+ };
134
+
135
+ export type ToolcraftVideoReferenceTransition = {
136
+ behaviorDelta: string;
137
+ fromFrameId: string;
138
+ id: string;
139
+ toFrameId: string;
140
+ };
141
+
142
+ export type ToolcraftVideoReferenceAcceptanceMapping = {
143
+ acceptanceId: string;
144
+ behavior: string;
145
+ frameIds: readonly string[];
146
+ };
147
+
148
+ export type ToolcraftVideoReferenceStudyEvidence = {
149
+ acceptanceMapping: readonly ToolcraftVideoReferenceAcceptanceMapping[];
150
+ behaviorDecomposition: string;
151
+ extractionEvidence: string;
152
+ referenceLocation: string;
153
+ storyboard: readonly ToolcraftVideoReferenceStoryboardFrame[];
154
+ transitionAnalysis: readonly ToolcraftVideoReferenceTransition[];
155
+ };
156
+
157
+ export type ToolcraftReferenceFeatureStatus =
158
+ | "intentionally-changed"
159
+ | "ported"
160
+ | "toolcraft-native";
161
+
162
+ export type ToolcraftReferenceFeatureInventoryItem = {
163
+ acceptanceId: string;
164
+ behaviorEvidence: string;
165
+ featureName: string;
166
+ id: string;
167
+ referenceBehavior: string;
168
+ sourceEvidence: string;
169
+ status: ToolcraftReferenceFeatureStatus;
170
+ toolcraftMapping: string;
171
+ userApprovedChangeReason?: string;
172
+ };
173
+
102
174
  export type ToolcraftLayerCoverage =
103
175
  | "grouping"
104
176
  | "media-lifecycle"
@@ -171,8 +243,6 @@ const builtInToolcraftControlTypeValues = [
171
243
  "text",
172
244
  "vector",
173
245
  ] as const;
174
- const settingsTransferOptOutReasonPattern =
175
- /\b(ephemeral|temporary|one-off|not portable|session-only)\b/i;
176
246
  const insufficientFixedCanvasSizingReasonPattern =
177
247
  /\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;
178
248
 
@@ -190,14 +260,18 @@ export type ToolcraftTransferMode =
190
260
  | {
191
261
  animationIntent?: ToolcraftAnimationIntent;
192
262
  mode: "new-toolcraft-app";
263
+ videoReferenceStudy?: ToolcraftVideoReferenceStudyEvidence;
193
264
  }
194
265
  | {
195
266
  animationIntent?: ToolcraftAnimationIntent;
196
267
  behaviorCoverage: readonly ToolcraftReferenceCoverage[];
197
268
  mode: "reference-runtime-clone";
269
+ referenceFeatureInventory?: readonly ToolcraftReferenceFeatureInventoryItem[];
198
270
  referenceName: string;
271
+ referenceStudy?: ToolcraftReferenceStudyEvidence;
199
272
  referenceTimeline: ToolcraftReferenceTimelineContract;
200
273
  sourceOfTruth: "reference-runtime";
274
+ videoReferenceStudy?: ToolcraftVideoReferenceStudyEvidence;
201
275
  };
202
276
 
203
277
  export type ToolcraftProductReadiness =
@@ -307,6 +381,19 @@ function isCanvasSizeTarget(target: string): boolean {
307
381
  return target === "canvas.size.width" || target === "canvas.size.height";
308
382
  }
309
383
 
384
+ const runtimeSetupControlTargets = new Set([
385
+ "runtime.settingsTransfer",
386
+ "canvas.aspectRatio",
387
+ "canvas.renderScale",
388
+ "canvas.size.width",
389
+ "canvas.size.height",
390
+ "panels.timeline.extended",
391
+ ]);
392
+
393
+ function isRuntimeSetupControlTarget(target: string): boolean {
394
+ return runtimeSetupControlTargets.has(target);
395
+ }
396
+
310
397
  function isResetPanelAction(action: ToolcraftActionSchema | string): boolean {
311
398
  return /\breset\b/i.test(getActionSearchText(action));
312
399
  }
@@ -404,9 +491,70 @@ function getAcceptanceEvidenceText(entry: ToolcraftComponentAcceptance): string
404
491
  ].join(" ");
405
492
  }
406
493
 
494
+ function getTimelineLoopDurationIntent(
495
+ animationIntent: ToolcraftAnimationIntent | undefined,
496
+ ): ToolcraftTimelineLoopDurationIntent | undefined {
497
+ if (
498
+ animationIntent?.mode !== "timeline-playback" &&
499
+ animationIntent?.mode !== "timeline-keyframes"
500
+ ) {
501
+ return undefined;
502
+ }
503
+
504
+ return animationIntent.loopDuration;
505
+ }
506
+
507
+ function isValidTimelineLoopDurationSource(source: string): boolean {
508
+ return source === "reference" || source === "user-request" || source === "product-derived";
509
+ }
510
+
511
+ const runtimeDefaultLoopDurationEvidencePattern =
512
+ /\b(?:runtime|template|generic|fallback|default)\b.{0,40}\b(?:8\s*s|8\s*sec(?:ond)?s?|eight\s*sec(?:ond)?s?)\b|\b(?:8\s*s|8\s*sec(?:ond)?s?|eight\s*sec(?:ond)?s?)\b.{0,40}\b(?:runtime|template|generic|fallback|default)\b/i;
513
+
514
+ function hasSeamlessForwardLoopEvidence(text: string): boolean {
515
+ const hasDurationChange =
516
+ /\b(duration|range|state\.timeline\.durationSeconds|timeline)\b/i.test(text) &&
517
+ /\b(edit|change|changed|changing|commit|set|after)\w*\b/i.test(text);
518
+ const hasFirstLastFrameEvidence =
519
+ /\b(first[-\s/]*(?:and\s+)?last|last[-\s/]*(?:and\s+)?first|wrapped\s+first\s+frame)\b/i.test(
520
+ text,
521
+ ) ||
522
+ (/\bfirst\s+frame\b/i.test(text) && /\blast\s+frame\b/i.test(text)) ||
523
+ (/\bstart\s+frame\b/i.test(text) && /\bend\s+frame\b/i.test(text));
524
+ const hasNoVisibleJumpEvidence =
525
+ /\b(no|not|without|avoid(?:s|ing)?|absent|rejects?|disallow(?:s|ed)?|forbid(?:s|den)?)\b.{0,40}\bvisible\s+jump\b/i.test(
526
+ text,
527
+ );
528
+ const hasSeamEvidence =
529
+ hasFirstLastFrameEvidence &&
530
+ (/\b(stitch(?:es|ed|ing)?|seam(?:less)?)\b/i.test(text) || hasNoVisibleJumpEvidence);
531
+ const hasForwardDirectionEvidence =
532
+ /\b(forward[-\s]*only|one\s+direction|same\s+direction|advances?\s+in\s+one\s+direction|motion\s+advances?\s+forward|direction\s+does\s+not\s+reverse)\b/i.test(text);
533
+ const hasNoMirrorEvidence =
534
+ /\b(no|not|without|avoid(?:s|ing)?|absent|rejects?|disallow(?:s|ed)?|forbid(?:s|den)?)\b.{0,40}\bmirror(?:ed|ing)?\b/i.test(text);
535
+ const hasNoYoyoEvidence =
536
+ /\b(no|not|without|avoid(?:s|ing)?|absent|rejects?|disallow(?:s|ed)?|forbid(?:s|den)?)\b.{0,40}\byo-?yo\b/i.test(text);
537
+ const hasNoPingPongEvidence =
538
+ /\b(no|not|without|avoid(?:s|ing)?|absent|rejects?|disallow(?:s|ed)?|forbid(?:s|den)?)\b.{0,40}\bping[-\s]*pong\b/i.test(text);
539
+ const hasNoReverseEvidence =
540
+ /\b(no|not|without|avoid(?:s|ing)?|absent|rejects?|disallow(?:s|ed)?|forbid(?:s|den)?)\b.{0,40}\breverse\b/i.test(text);
541
+
542
+ return (
543
+ hasDurationChange &&
544
+ hasSeamEvidence &&
545
+ hasForwardDirectionEvidence &&
546
+ hasNoMirrorEvidence &&
547
+ hasNoYoyoEvidence &&
548
+ hasNoPingPongEvidence &&
549
+ hasNoReverseEvidence
550
+ );
551
+ }
552
+
407
553
  function getFileDropLifecycleCoverageErrors(
408
554
  label: string,
555
+ control: ToolcraftControlSchema,
409
556
  entry: ToolcraftComponentAcceptance,
557
+ hasDefaultMediaAssets: boolean,
410
558
  ): string[] {
411
559
  const errors: string[] = [];
412
560
  const evidenceText = getAcceptanceEvidenceText(entry);
@@ -425,10 +573,59 @@ function getFileDropLifecycleCoverageErrors(
425
573
  !/\b(reset|reset controls|section reset|global reset)\b/i.test(evidenceText)
426
574
  ) {
427
575
  errors.push(
428
- `${label} fileDrop acceptance must prove upload/import, clear/remove, and section or global reset remove source media.`,
576
+ `${label} fileDrop acceptance must prove upload/import, clear/remove, and section or global reset restore default source media or remove uploaded source media when no default exists.`,
429
577
  );
430
578
  }
431
579
 
580
+ if (hasDefaultMediaAssets) {
581
+ const provesDefaultRemoval =
582
+ /\b(default|predefined|preset|attached)\b.{0,60}\b(clear|remove|delete|trash)\b/i.test(
583
+ evidenceText,
584
+ ) ||
585
+ /\b(clear|remove|delete|trash)\b.{0,60}\b(default|predefined|preset|attached)\b/i.test(
586
+ evidenceText,
587
+ );
588
+ const provesDefaultReset =
589
+ /\b(reset|section reset|global reset)\b.{0,80}\b(restore|restores|return|returns|recreate|recreates)\b.{0,80}\b(default|predefined|preset|attached)\b/i.test(
590
+ evidenceText,
591
+ );
592
+
593
+ if (!provesDefaultRemoval || !provesDefaultReset) {
594
+ errors.push(
595
+ `${label} fileDrop acceptance must prove predefined media.defaultAssets render as attached files, can be removed to an empty source/canvas state, and are restored by section or global Reset.`,
596
+ );
597
+ }
598
+ }
599
+
600
+ if (control.assetKind !== "file") {
601
+ const provesRotate = /\b(rotate|rotation|90°|90\s*degrees?)\b/i.test(evidenceText);
602
+ const provesFlip = /\b(flip|flipped)\b/i.test(evidenceText);
603
+ const provesTransformConsumption =
604
+ /\b(mediaAssets\[\]\.transform|runtime media transform|transform metadata|preview|renderer|rendered output|export)\b/i.test(
605
+ evidenceText,
606
+ );
607
+
608
+ if (!provesRotate || !provesFlip || !provesTransformConsumption) {
609
+ errors.push(
610
+ `${label} image fileDrop acceptance must prove rotate and flip actions update runtime media transform metadata and that preview, renderer, or export consumes the transform.`,
611
+ );
612
+ }
613
+ }
614
+
615
+ if (control.multiple === true) {
616
+ const provesReorder = /\b(reorder|sort|sorting|drag|order)\b/i.test(evidenceText);
617
+ const provesOrderConsumption =
618
+ /\b(runtime media order|mediaAssets order|ordered media|preview|renderer|rendered output|export)\b/i.test(
619
+ evidenceText,
620
+ );
621
+
622
+ if (!provesReorder || !provesOrderConsumption) {
623
+ errors.push(
624
+ `${label} multiple fileDrop acceptance must prove thumbnail/file reorder updates runtime media order and that preview, renderer, or export consumes that order.`,
625
+ );
626
+ }
627
+ }
628
+
432
629
  return errors;
433
630
  }
434
631
 
@@ -1384,6 +1581,27 @@ const customReferenceTimelineCoverage = new Set<ToolcraftReferenceTimelineCovera
1384
1581
  "trim-range",
1385
1582
  ]);
1386
1583
 
1584
+ const referenceFeatureStatusValues = new Set<ToolcraftReferenceFeatureStatus>([
1585
+ "intentionally-changed",
1586
+ "ported",
1587
+ "toolcraft-native",
1588
+ ]);
1589
+
1590
+ const referenceStudyStatusValues = new Set<ToolcraftReferenceStudyStatus>([
1591
+ "ran-original",
1592
+ "restored-local",
1593
+ "source-inspection-only",
1594
+ ]);
1595
+
1596
+ const explicitReferenceChangeReasonPattern =
1597
+ /\b(user|requested|explicit|approved|redesign|change request)\b/i;
1598
+
1599
+ const referenceStudySourceOnlyReasonPattern =
1600
+ /\b(cannot|can't|unable|unavailable|missing|broken|fails?|blocked|no\s+(?:server|deps?|dependency|access)|not\s+(?:runnable|available))\b/i;
1601
+
1602
+ const videoReferenceEvidencePattern =
1603
+ /\b(?:reference\s+(?:video|gif)|screen\s*recording|contact[-\s]*sheet|storyboard|frame[-\s]*by[-\s]*frame|extracted[-\s]*frames?|ffprobe)\b|(?:^|[\\/"'\s])[^\\/"'\s]+\.(?:mp4|mov|webm|gif)\b/i;
1604
+
1387
1605
  const timelineTransportControlPattern =
1388
1606
  /\b(play|pause|paused|resume|animate|restart)\b/i;
1389
1607
 
@@ -1414,6 +1632,184 @@ const requiredTimelinePlaybackCoverage = [
1414
1632
  "rendered-frame",
1415
1633
  ] satisfies readonly ToolcraftTimelinePlaybackCoverage[];
1416
1634
 
1635
+ function getToolcraftTransferModeEvidenceText(
1636
+ transferMode: ToolcraftTransferMode,
1637
+ ): string {
1638
+ return JSON.stringify(transferMode);
1639
+ }
1640
+
1641
+ function validateToolcraftVideoReferenceStudy(
1642
+ study: ToolcraftVideoReferenceStudyEvidence,
1643
+ acceptance: readonly ToolcraftComponentAcceptance[],
1644
+ ): string[] {
1645
+ const errors: string[] = [];
1646
+ const acceptanceById = new Map(acceptance.map((entry) => [entry.id, entry]));
1647
+ const frameIds = new Set<string>();
1648
+
1649
+ if (!study.referenceLocation.trim()) {
1650
+ errors.push(
1651
+ "videoReferenceStudy.referenceLocation must name the inspected video, GIF, screen recording, contact sheet, or extracted-frame folder.",
1652
+ );
1653
+ }
1654
+
1655
+ if (!study.extractionEvidence.trim()) {
1656
+ errors.push(
1657
+ "videoReferenceStudy.extractionEvidence must explain how frames were inspected or extracted before implementation.",
1658
+ );
1659
+ }
1660
+
1661
+ if (!study.behaviorDecomposition.trim()) {
1662
+ errors.push(
1663
+ "videoReferenceStudy.behaviorDecomposition must decompose the observed frame-to-frame changes into product behavior to preserve.",
1664
+ );
1665
+ }
1666
+
1667
+ if (study.storyboard.length < 4) {
1668
+ errors.push(
1669
+ "videoReferenceStudy.storyboard must include at least four timecoded frames so the reference is studied as motion, not a single screenshot.",
1670
+ );
1671
+ }
1672
+
1673
+ for (const [index, frame] of study.storyboard.entries()) {
1674
+ const frameLabel = frame.frameId.trim() || `#${index + 1}`;
1675
+
1676
+ if (!frame.frameId.trim()) {
1677
+ errors.push(`videoReferenceStudy.storyboard frame ${index + 1} must include a stable frameId.`);
1678
+ } else if (frameIds.has(frame.frameId)) {
1679
+ errors.push(
1680
+ `videoReferenceStudy.storyboard frameId "${frame.frameId}" is duplicated; each sampled frame needs a stable id.`,
1681
+ );
1682
+ } else {
1683
+ frameIds.add(frame.frameId);
1684
+ }
1685
+
1686
+ if (!Number.isFinite(frame.timeSeconds) || frame.timeSeconds < 0) {
1687
+ errors.push(
1688
+ `videoReferenceStudy.storyboard "${frameLabel}" must include a non-negative finite timeSeconds value.`,
1689
+ );
1690
+ }
1691
+
1692
+ if (!frame.frameSource.trim()) {
1693
+ errors.push(
1694
+ `videoReferenceStudy.storyboard "${frameLabel}" must cite the frame image, contact sheet cell, or source timecode inspected.`,
1695
+ );
1696
+ }
1697
+
1698
+ if (!frame.visualObservation.trim()) {
1699
+ errors.push(
1700
+ `videoReferenceStudy.storyboard "${frameLabel}" must describe the visible frame state.`,
1701
+ );
1702
+ }
1703
+
1704
+ if (!frame.behaviorObservation.trim()) {
1705
+ errors.push(
1706
+ `videoReferenceStudy.storyboard "${frameLabel}" must describe the behavior inferred from that frame.`,
1707
+ );
1708
+ }
1709
+ }
1710
+
1711
+ if (study.transitionAnalysis.length < 3) {
1712
+ errors.push(
1713
+ "videoReferenceStudy.transitionAnalysis must include at least three frame-to-frame deltas proving how behavior changes between sampled frames.",
1714
+ );
1715
+ }
1716
+
1717
+ for (const [index, transition] of study.transitionAnalysis.entries()) {
1718
+ const transitionLabel = transition.id.trim() || `#${index + 1}`;
1719
+
1720
+ if (!transition.id.trim()) {
1721
+ errors.push(
1722
+ `videoReferenceStudy.transitionAnalysis item ${index + 1} must include a stable id.`,
1723
+ );
1724
+ }
1725
+
1726
+ if (!frameIds.has(transition.fromFrameId)) {
1727
+ errors.push(
1728
+ `videoReferenceStudy.transitionAnalysis "${transitionLabel}" fromFrameId "${transition.fromFrameId}" must point to a storyboard frameId.`,
1729
+ );
1730
+ }
1731
+
1732
+ if (!frameIds.has(transition.toFrameId)) {
1733
+ errors.push(
1734
+ `videoReferenceStudy.transitionAnalysis "${transitionLabel}" toFrameId "${transition.toFrameId}" must point to a storyboard frameId.`,
1735
+ );
1736
+ }
1737
+
1738
+ if (!transition.behaviorDelta.trim()) {
1739
+ errors.push(
1740
+ `videoReferenceStudy.transitionAnalysis "${transitionLabel}" must describe the behavior delta between frames.`,
1741
+ );
1742
+ }
1743
+ }
1744
+
1745
+ if (study.acceptanceMapping.length === 0) {
1746
+ errors.push(
1747
+ "videoReferenceStudy.acceptanceMapping must map observed video behaviors to acceptance rows.",
1748
+ );
1749
+ }
1750
+
1751
+ for (const [index, mapping] of study.acceptanceMapping.entries()) {
1752
+ const mappingLabel = mapping.behavior.trim() || `#${index + 1}`;
1753
+
1754
+ if (!mapping.behavior.trim()) {
1755
+ errors.push(
1756
+ `videoReferenceStudy.acceptanceMapping item ${index + 1} must name the observed behavior.`,
1757
+ );
1758
+ }
1759
+
1760
+ if (mapping.frameIds.length === 0) {
1761
+ errors.push(
1762
+ `videoReferenceStudy.acceptanceMapping "${mappingLabel}" must cite storyboard frameIds that prove the behavior.`,
1763
+ );
1764
+ }
1765
+
1766
+ for (const frameId of mapping.frameIds) {
1767
+ if (!frameIds.has(frameId)) {
1768
+ errors.push(
1769
+ `videoReferenceStudy.acceptanceMapping "${mappingLabel}" frameId "${frameId}" must point to a storyboard frameId.`,
1770
+ );
1771
+ }
1772
+ }
1773
+
1774
+ const acceptanceId = mapping.acceptanceId.trim();
1775
+ const entry = acceptanceById.get(acceptanceId);
1776
+
1777
+ if (!acceptanceId) {
1778
+ errors.push(
1779
+ `videoReferenceStudy.acceptanceMapping "${mappingLabel}" must include acceptanceId for the test proving the copied behavior.`,
1780
+ );
1781
+ continue;
1782
+ }
1783
+
1784
+ if (!entry) {
1785
+ errors.push(
1786
+ `videoReferenceStudy.acceptanceMapping "${mappingLabel}" points to missing acceptanceId "${acceptanceId}".`,
1787
+ );
1788
+ continue;
1789
+ }
1790
+
1791
+ if (!entry.automated || !entry.automatedTestName.trim()) {
1792
+ errors.push(
1793
+ `${acceptanceId} must have automated coverage proving video reference behavior "${mappingLabel}".`,
1794
+ );
1795
+ }
1796
+
1797
+ if (!entry.browser || !entry.browserTestName.trim()) {
1798
+ errors.push(
1799
+ `${acceptanceId} must have browser coverage proving video reference behavior "${mappingLabel}".`,
1800
+ );
1801
+ }
1802
+
1803
+ if (!entry.expectedObservable.trim()) {
1804
+ errors.push(
1805
+ `${acceptanceId} must describe the observable result for video reference behavior "${mappingLabel}".`,
1806
+ );
1807
+ }
1808
+ }
1809
+
1810
+ return errors;
1811
+ }
1812
+
1417
1813
  function isModeSelectorControl(
1418
1814
  controlId: string,
1419
1815
  control: ToolcraftControlSchema,
@@ -1499,7 +1895,7 @@ function getToolcraftControlOrderErrors(schema: ResolvedToolcraftAppSchema): str
1499
1895
  let previousItem: ToolcraftControlOrderItem | undefined;
1500
1896
 
1501
1897
  for (const [controlId, control] of Object.entries(section.controls)) {
1502
- if (control.type === "panelActions") {
1898
+ if (control.type === "panelActions" || isRuntimeSetupControlTarget(control.target)) {
1503
1899
  continue;
1504
1900
  }
1505
1901
 
@@ -1528,6 +1924,107 @@ function getToolcraftControlOrderErrors(schema: ResolvedToolcraftAppSchema): str
1528
1924
  return errors;
1529
1925
  }
1530
1926
 
1927
+ function getToolcraftRuntimeSetupSectionErrors(
1928
+ schema: ResolvedToolcraftAppSchema,
1929
+ ): string[] {
1930
+ const errors: string[] = [];
1931
+ const controlsPanel = schema.panels.controls;
1932
+
1933
+ if (!controlsPanel) {
1934
+ return [
1935
+ "Generated Toolcraft apps must define a controls panel so the mandatory runtime Setup section is visible.",
1936
+ ];
1937
+ }
1938
+
1939
+ const sections = schema.panels.controls?.sections ?? [];
1940
+
1941
+ if (sections.length === 0) {
1942
+ return [
1943
+ 'Runtime Setup must be the first visible controls-panel section titled "Setup". Do not ship an empty controls panel.',
1944
+ ];
1945
+ }
1946
+
1947
+ const setupSection = sections[0];
1948
+ const setupTitle = setupSection?.title?.trim();
1949
+ const setupControls = Object.values(setupSection?.controls ?? {});
1950
+ const setupTargets = new Set(setupControls.map((control) => control.target));
1951
+ const hasSetupTarget = (target: string) => setupTargets.has(target);
1952
+
1953
+ if (setupTitle !== "Setup") {
1954
+ errors.push(
1955
+ 'Runtime Setup must be the first visible controls-panel section titled "Setup". Do not move Export Settings, Import Settings, canvas sizing, Resolution scale, or Timeline into app-authored sections.',
1956
+ );
1957
+ }
1958
+
1959
+ if (
1960
+ !setupControls.some(
1961
+ (control) =>
1962
+ control.type === "settingsTransfer" &&
1963
+ control.target === "runtime.settingsTransfer",
1964
+ )
1965
+ ) {
1966
+ errors.push(
1967
+ 'Runtime Setup must include settingsTransfer at target "runtime.settingsTransfer" so Export Settings and Import Settings are always visible.',
1968
+ );
1969
+ }
1970
+
1971
+ if (schema.canvas.enabled && schema.canvas.sizing.mode === "editable-output") {
1972
+ const missingCanvasTargets = [
1973
+ "canvas.aspectRatio",
1974
+ "canvas.size.width",
1975
+ "canvas.size.height",
1976
+ ].filter((target) => !hasSetupTarget(target));
1977
+
1978
+ if (missingCanvasTargets.length > 0) {
1979
+ errors.push(
1980
+ `Runtime Setup for editable-output canvas must include Aspect ratio, Canvas width, and Canvas height. Missing targets: ${missingCanvasTargets.join(", ")}.`,
1981
+ );
1982
+ }
1983
+ }
1984
+
1985
+ if (schema.canvas.renderScale.enabled && !hasSetupTarget("canvas.renderScale")) {
1986
+ errors.push(
1987
+ 'Runtime Setup must include Resolution scale at target "canvas.renderScale" whenever canvas.renderScale is enabled.',
1988
+ );
1989
+ }
1990
+
1991
+ if (schema.panels.timeline?.enabled) {
1992
+ if (!hasSetupTarget("panels.timeline.extended")) {
1993
+ errors.push(
1994
+ 'Runtime Setup must include the Timeline switch at target "panels.timeline.extended" whenever panels.timeline is enabled.',
1995
+ );
1996
+ }
1997
+ } else if (
1998
+ sections.some((section) =>
1999
+ Object.values(section.controls).some(
2000
+ (control) => control.target === "panels.timeline.extended",
2001
+ ),
2002
+ )
2003
+ ) {
2004
+ errors.push(
2005
+ 'Runtime Setup must not include the Timeline switch unless panels.timeline is enabled.',
2006
+ );
2007
+ }
2008
+
2009
+ for (const [sectionIndex, section] of sections.entries()) {
2010
+ const isRuntimeSetupSection = sectionIndex === 0 && section.title?.trim() === "Setup";
2011
+
2012
+ for (const [controlId, control] of Object.entries(section.controls)) {
2013
+ if (!isRuntimeSetupControlTarget(control.target) || isRuntimeSetupSection) {
2014
+ continue;
2015
+ }
2016
+
2017
+ const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
2018
+
2019
+ errors.push(
2020
+ `${sectionLabel} / ${controlId} uses runtime Setup target "${control.target}". Runtime Setup owns Export Settings, Import Settings, Aspect ratio, Canvas width, Canvas height, Resolution scale, and Timeline; do not declare these controls in app-authored sections.`,
2021
+ );
2022
+ }
2023
+ }
2024
+
2025
+ return errors;
2026
+ }
2027
+
1531
2028
  const genericControlSectionTitlePattern =
1532
2029
  /^(controls?|settings?|parameters?|options?|configuration|config|adjustments?)$/i;
1533
2030
 
@@ -1846,13 +2343,14 @@ function isToolcraftProductSectionControl(control: ToolcraftControlSchema): bool
1846
2343
  return (
1847
2344
  control.type !== "panelActions" &&
1848
2345
  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"
2346
+ !isRuntimeSetupControlTarget(control.target)
1853
2347
  );
1854
2348
  }
1855
2349
 
2350
+ function isToolcraftVisibleAcceptanceControl(control: ToolcraftControlSchema): boolean {
2351
+ return control.type === "panelActions" || isToolcraftProductSectionControl(control);
2352
+ }
2353
+
1856
2354
  function getToolcraftGenericControlLabelError({
1857
2355
  control,
1858
2356
  controlId,
@@ -2266,8 +2764,8 @@ function getToolcraftControlSectionGroupingErrors(
2266
2764
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
2267
2765
  const sectionTitle = section.title?.trim();
2268
2766
  const sectionLabel = getToolcraftSectionLabel(sectionTitle, sectionIndex);
2269
- const controls = Object.entries(section.controls).filter(
2270
- ([, control]) => control.type !== "panelActions",
2767
+ const controls = Object.entries(section.controls).filter(([, control]) =>
2768
+ isToolcraftProductSectionControl(control),
2271
2769
  );
2272
2770
 
2273
2771
  if (controls.length === 0) {
@@ -2445,7 +2943,7 @@ function getToolcraftControlSectionGroupingErrors(
2445
2943
  }
2446
2944
 
2447
2945
  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.`,
2946
+ `${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 for branch-specific controls inside that section instead of splitting branch controls into their own section. Do not use disabledWhen for product controls.`,
2449
2947
  );
2450
2948
  }
2451
2949
  }
@@ -2509,11 +3007,13 @@ export function collectToolcraftVisibleControls(
2509
3007
  schema: ResolvedToolcraftAppSchema = starterSchema,
2510
3008
  ): ToolcraftVisibleControl[] {
2511
3009
  return (schema.panels.controls?.sections ?? []).flatMap((section) =>
2512
- Object.entries(section.controls).map(([controlId, control]) => ({
2513
- control,
2514
- controlId,
2515
- sectionTitle: section.title,
2516
- })),
3010
+ Object.entries(section.controls)
3011
+ .filter(([, control]) => isToolcraftVisibleAcceptanceControl(control))
3012
+ .map(([controlId, control]) => ({
3013
+ control,
3014
+ controlId,
3015
+ sectionTitle: section.title,
3016
+ })),
2517
3017
  );
2518
3018
  }
2519
3019
 
@@ -2530,7 +3030,7 @@ export function getToolcraftControlOrder(
2530
3030
  ): ToolcraftControlOrderItem[] {
2531
3031
  return (schema.panels.controls?.sections ?? []).flatMap((section) =>
2532
3032
  Object.entries(section.controls)
2533
- .filter(([, control]) => control.type !== "panelActions")
3033
+ .filter(([, control]) => isToolcraftProductSectionControl(control))
2534
3034
  .map(([controlId, control]) => {
2535
3035
  const role = inferToolcraftControlOrderRole(controlId, control);
2536
3036
 
@@ -2569,6 +3069,7 @@ export function validateToolcraftAcceptanceCoverage(
2569
3069
  const layersEnabled = Boolean(schema.panels.layers);
2570
3070
  const controlTargets = new Set(controls.map(({ control }) => control.target));
2571
3071
  const animationIntent = transferMode.animationIntent;
3072
+ const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
2572
3073
  const animationControls = controls.filter(
2573
3074
  (visibleControl) =>
2574
3075
  visibleControl.control.type !== "panelActions" &&
@@ -2586,11 +3087,32 @@ export function validateToolcraftAcceptanceCoverage(
2586
3087
  "history.undo",
2587
3088
  ]);
2588
3089
 
3090
+ errors.push(...getToolcraftRuntimeSetupSectionErrors(schema));
2589
3091
  errors.push(...getToolcraftControlOrderErrors(schema));
2590
3092
  errors.push(...getToolcraftControlSectionGroupingErrors(schema, sectionInventory));
2591
3093
  errors.push(...getToolcraftControlSectionInventoryErrors(schema, sectionInventory));
2592
3094
 
2593
- for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
3095
+ const transferModeEvidenceText = getToolcraftTransferModeEvidenceText(transferMode);
3096
+
3097
+ if (
3098
+ videoReferenceEvidencePattern.test(transferModeEvidenceText) &&
3099
+ !transferMode.videoReferenceStudy
3100
+ ) {
3101
+ errors.push(
3102
+ "starterTransferMode cites a video reference, screen recording, GIF, extracted frames, or contact sheet; declare videoReferenceStudy with storyboard frames, frame-to-frame transition analysis, behavior decomposition, and acceptance mapping before implementation.",
3103
+ );
3104
+ }
3105
+
3106
+ if (transferMode.videoReferenceStudy) {
3107
+ errors.push(
3108
+ ...validateToolcraftVideoReferenceStudy(
3109
+ transferMode.videoReferenceStudy,
3110
+ acceptance,
3111
+ ),
3112
+ );
3113
+ }
3114
+
3115
+ for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
2594
3116
  const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
2595
3117
 
2596
3118
  for (const layoutGroup of section.layoutGroups ?? []) {
@@ -2672,8 +3194,8 @@ export function validateToolcraftAcceptanceCoverage(
2672
3194
  }
2673
3195
  }
2674
3196
 
2675
- const sectionControls = Object.entries(section.controls).filter(
2676
- ([, control]) => control.type !== "panelActions",
3197
+ const sectionControls = Object.entries(section.controls).filter(([, control]) =>
3198
+ isToolcraftProductSectionControl(control),
2677
3199
  );
2678
3200
 
2679
3201
  for (let index = 0; index < sectionControls.length - 1; index += 1) {
@@ -2709,7 +3231,6 @@ export function validateToolcraftAcceptanceCoverage(
2709
3231
  const panelActionsSectionIndex = getFirstPanelActionsSectionIndex(schema);
2710
3232
  const imageExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Image Export");
2711
3233
  const videoExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Video Export");
2712
- const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
2713
3234
  const expectedOutputSettingsIndex =
2714
3235
  imageExportSectionIndex >= 0 ? imageExportSectionIndex : videoExportSectionIndex;
2715
3236
  const finalExportSettingsIndex = hasVideoExportAction
@@ -2901,6 +3422,12 @@ export function validateToolcraftAcceptanceCoverage(
2901
3422
  }
2902
3423
  }
2903
3424
 
3425
+ if (hasVideoExportAction && !timelineMode) {
3426
+ errors.push(
3427
+ 'Apps with Export Video must enable the top Toolcraft timeline. Use panels.timeline mode "playback" for product animation transport, or mode "keyframes" when exported animation is driven by keyframes; autonomous no-timeline animation is only allowed when there is no video export.',
3428
+ );
3429
+ }
3430
+
2904
3431
  if (animationControls.length > 0 && !timelineMode && animationIntent?.mode !== "autonomous") {
2905
3432
  errors.push(
2906
3433
  [
@@ -2922,6 +3449,12 @@ export function validateToolcraftAcceptanceCoverage(
2922
3449
  );
2923
3450
  }
2924
3451
 
3452
+ if (hasVideoExportAction) {
3453
+ errors.push(
3454
+ 'starterTransferMode.animationIntent mode "autonomous" conflicts with Export Video. Video export creates product-time behavior, so the renderer and export must use the top Toolcraft timeline duration, loop, and deterministic timestamps.',
3455
+ );
3456
+ }
3457
+
2925
3458
  if (!animationIntent.reason.trim()) {
2926
3459
  errors.push(
2927
3460
  'starterTransferMode.animationIntent mode "autonomous" must include a reason explaining why the animation is decorative/self-running and does not need top timeline transport.',
@@ -2947,9 +3480,92 @@ export function validateToolcraftAcceptanceCoverage(
2947
3480
  );
2948
3481
  }
2949
3482
 
3483
+ if (
3484
+ transferMode.mode === "new-toolcraft-app" &&
3485
+ timelineMode === "playback" &&
3486
+ animationIntent?.mode !== "timeline-playback"
3487
+ ) {
3488
+ errors.push(
3489
+ 'panels.timeline mode "playback" requires starterTransferMode.animationIntent mode "timeline-playback" with loopDuration provenance.',
3490
+ );
3491
+ }
3492
+
3493
+ if (
3494
+ transferMode.mode === "new-toolcraft-app" &&
3495
+ timelineMode === "keyframes" &&
3496
+ animationIntent?.mode !== "timeline-keyframes"
3497
+ ) {
3498
+ errors.push(
3499
+ 'panels.timeline mode "keyframes" requires starterTransferMode.animationIntent mode "timeline-keyframes" with loopDuration provenance.',
3500
+ );
3501
+ }
3502
+
3503
+ if (
3504
+ timelineMode &&
3505
+ (animationIntent?.mode === "timeline-playback" ||
3506
+ animationIntent?.mode === "timeline-keyframes")
3507
+ ) {
3508
+ const timelineDefaultDurationSeconds = schema.panels.timeline?.defaultDurationSeconds;
3509
+ const loopDuration = getTimelineLoopDurationIntent(animationIntent);
3510
+
3511
+ if (!loopDuration) {
3512
+ errors.push(
3513
+ `starterTransferMode.animationIntent mode "${animationIntent.mode}" must declare loopDuration with source, seconds, and evidence. Do not let runtime/template fallback duration such as 8s stand in for product loop intent.`,
3514
+ );
3515
+ } else {
3516
+ if (
3517
+ !Number.isFinite(loopDuration.seconds) ||
3518
+ loopDuration.seconds <= 0
3519
+ ) {
3520
+ errors.push(
3521
+ `starterTransferMode.animationIntent.loopDuration.seconds must be a positive finite duration; received ${String(loopDuration.seconds)}.`,
3522
+ );
3523
+ }
3524
+
3525
+ if (!isValidTimelineLoopDurationSource(loopDuration.source)) {
3526
+ errors.push(
3527
+ `starterTransferMode.animationIntent.loopDuration.source must be "reference", "user-request", or "product-derived"; received "${String(loopDuration.source)}". Runtime/template fallback is not a valid loop-duration source.`,
3528
+ );
3529
+ }
3530
+
3531
+ if (!loopDuration.evidence.trim()) {
3532
+ errors.push(
3533
+ "starterTransferMode.animationIntent.loopDuration.evidence must explain where the initial loop duration came from, such as reference timing, an explicit user request, or a product-derived timing rule.",
3534
+ );
3535
+ }
3536
+
3537
+ if (runtimeDefaultLoopDurationEvidencePattern.test(loopDuration.evidence)) {
3538
+ errors.push(
3539
+ "starterTransferMode.animationIntent.loopDuration.evidence must not cite the runtime/template fallback 8s default as the product loop source. Use reference timing, an explicit user request, or a product-derived timing rule.",
3540
+ );
3541
+ }
3542
+
3543
+ if (typeof timelineDefaultDurationSeconds !== "number") {
3544
+ errors.push(
3545
+ "Timeline playback/keyframe apps must set panels.timeline.defaultDurationSeconds to the declared loopDuration.seconds so the initial UI duration is not the runtime fallback.",
3546
+ );
3547
+ } else if (
3548
+ Number.isFinite(loopDuration.seconds) &&
3549
+ Math.abs(timelineDefaultDurationSeconds - loopDuration.seconds) > 0.001
3550
+ ) {
3551
+ errors.push(
3552
+ `panels.timeline.defaultDurationSeconds (${timelineDefaultDurationSeconds}) must match starterTransferMode.animationIntent.loopDuration.seconds (${loopDuration.seconds}).`,
3553
+ );
3554
+ }
3555
+ }
3556
+ }
3557
+
2950
3558
  if (transferMode.mode === "reference-runtime-clone") {
2951
3559
  const declaredReferenceCoverage = new Set(transferMode.behaviorCoverage);
2952
3560
  const referenceTimeline = transferMode.referenceTimeline;
3561
+ const referenceStudy = transferMode.referenceStudy;
3562
+ const referenceFeatureInventory = transferMode.referenceFeatureInventory ?? [];
3563
+ const acceptanceById = new Map(acceptance.map((entry) => [entry.id, entry]));
3564
+ const referenceFeatureIds = new Set<string>();
3565
+ const referenceFeatureAcceptanceIds = new Set<string>();
3566
+ const referenceCoverageFromInventory = new Set<ToolcraftReferenceCoverage>();
3567
+ const referenceTimelineCoverageFromInventory =
3568
+ new Set<ToolcraftReferenceTimelineCoverage>();
2953
3569
 
2954
3570
  if (!schema.assembly.surfaces.canvas.enabled) {
2955
3571
  errors.push(
@@ -2969,6 +3585,180 @@ export function validateToolcraftAcceptanceCoverage(
2969
3585
  );
2970
3586
  }
2971
3587
 
3588
+ if (!referenceStudy) {
3589
+ errors.push(
3590
+ "reference-runtime-clone transferMode must declare referenceStudy proving the reference was inspected and, when runnable or reconstructable, run or restored locally before implementation.",
3591
+ );
3592
+ } else {
3593
+ if (!referenceStudyStatusValues.has(referenceStudy.status)) {
3594
+ errors.push(
3595
+ 'referenceStudy.status must be "ran-original", "restored-local", or "source-inspection-only".',
3596
+ );
3597
+ }
3598
+
3599
+ if (!referenceStudy.referenceLocation.trim()) {
3600
+ errors.push(
3601
+ "referenceStudy.referenceLocation must name the inspected reference folder, URL, artifact, or source location.",
3602
+ );
3603
+ }
3604
+
3605
+ if (!referenceStudy.sourceEvidence.trim()) {
3606
+ errors.push(
3607
+ "referenceStudy.sourceEvidence must summarize the source/runtime files, routes, schemas, or assets inspected.",
3608
+ );
3609
+ }
3610
+
3611
+ if (!referenceStudy.behaviorEvidence.trim()) {
3612
+ errors.push(
3613
+ "referenceStudy.behaviorEvidence must summarize the runtime/browser behavior checked from the original or restored reference.",
3614
+ );
3615
+ }
3616
+
3617
+ if (!referenceStudy.reproductionSteps.trim()) {
3618
+ errors.push(
3619
+ "referenceStudy.reproductionSteps must record how the reference was run, restored in the Toolcraft environment, or why source-only inspection was used.",
3620
+ );
3621
+ }
3622
+
3623
+ if (referenceStudy.status === "source-inspection-only") {
3624
+ if (!referenceStudy.sourceOnlyReason?.trim()) {
3625
+ errors.push(
3626
+ 'referenceStudy.status "source-inspection-only" requires sourceOnlyReason explaining why the reference could not be run or restored locally.',
3627
+ );
3628
+ } else if (
3629
+ !referenceStudySourceOnlyReasonPattern.test(referenceStudy.sourceOnlyReason)
3630
+ ) {
3631
+ errors.push(
3632
+ 'referenceStudy.sourceOnlyReason must state the concrete blocker that made running or restoring the reference unavailable.',
3633
+ );
3634
+ }
3635
+ }
3636
+ }
3637
+
3638
+ if (referenceFeatureInventory.length === 0) {
3639
+ errors.push(
3640
+ "reference-runtime-clone transferMode must declare referenceFeatureInventory with every user-visible and output-affecting behavior from the inspected reference, mapped to Toolcraft implementation and acceptance coverage.",
3641
+ );
3642
+ }
3643
+
3644
+ for (const [index, feature] of referenceFeatureInventory.entries()) {
3645
+ const featureLabel = feature.id.trim() || `#${index + 1}`;
3646
+
3647
+ if (!feature.id.trim()) {
3648
+ errors.push(
3649
+ `referenceFeatureInventory item ${index + 1} must include a stable id.`,
3650
+ );
3651
+ } else if (referenceFeatureIds.has(feature.id)) {
3652
+ errors.push(
3653
+ `referenceFeatureInventory id "${feature.id}" is duplicated; each reference feature must be inventoried once.`,
3654
+ );
3655
+ } else {
3656
+ referenceFeatureIds.add(feature.id);
3657
+ }
3658
+
3659
+ if (!feature.featureName.trim()) {
3660
+ errors.push(
3661
+ `referenceFeatureInventory "${featureLabel}" must include a short featureName.`,
3662
+ );
3663
+ }
3664
+
3665
+ if (!feature.sourceEvidence.trim()) {
3666
+ errors.push(
3667
+ `referenceFeatureInventory "${featureLabel}" must include sourceEvidence from the inspected reference source, runtime, UI, or browser behavior.`,
3668
+ );
3669
+ }
3670
+
3671
+ if (!feature.behaviorEvidence.trim()) {
3672
+ errors.push(
3673
+ `referenceFeatureInventory "${featureLabel}" must include behaviorEvidence from the original, restored, or source-only reference study proving this feature was observed before Toolcraft mapping.`,
3674
+ );
3675
+ }
3676
+
3677
+ if (!feature.referenceBehavior.trim()) {
3678
+ errors.push(
3679
+ `referenceFeatureInventory "${featureLabel}" must describe the referenceBehavior to preserve.`,
3680
+ );
3681
+ }
3682
+
3683
+ if (!feature.toolcraftMapping.trim()) {
3684
+ errors.push(
3685
+ `referenceFeatureInventory "${featureLabel}" must describe the Toolcraft mapping for the preserved behavior.`,
3686
+ );
3687
+ }
3688
+
3689
+ if (!referenceFeatureStatusValues.has(feature.status)) {
3690
+ errors.push(
3691
+ `referenceFeatureInventory "${featureLabel}" status must be "ported", "toolcraft-native", or "intentionally-changed".`,
3692
+ );
3693
+ }
3694
+
3695
+ if (feature.status === "intentionally-changed") {
3696
+ if (!feature.userApprovedChangeReason?.trim()) {
3697
+ errors.push(
3698
+ `referenceFeatureInventory "${featureLabel}" status "intentionally-changed" requires userApprovedChangeReason.`,
3699
+ );
3700
+ } else if (
3701
+ !explicitReferenceChangeReasonPattern.test(feature.userApprovedChangeReason)
3702
+ ) {
3703
+ errors.push(
3704
+ `referenceFeatureInventory "${featureLabel}" userApprovedChangeReason must cite explicit user approval or redesign/change-request evidence.`,
3705
+ );
3706
+ }
3707
+ }
3708
+
3709
+ const acceptanceId = feature.acceptanceId.trim();
3710
+
3711
+ if (!acceptanceId) {
3712
+ errors.push(
3713
+ `referenceFeatureInventory "${featureLabel}" must include acceptanceId for the test proving the mapped reference behavior.`,
3714
+ );
3715
+ continue;
3716
+ }
3717
+
3718
+ referenceFeatureAcceptanceIds.add(acceptanceId);
3719
+
3720
+ const entry = acceptanceById.get(acceptanceId);
3721
+
3722
+ if (!entry) {
3723
+ errors.push(
3724
+ `referenceFeatureInventory "${featureLabel}" points to missing acceptanceId "${acceptanceId}".`,
3725
+ );
3726
+ continue;
3727
+ }
3728
+
3729
+ if (!entry.referenceCoverage && !entry.referenceTimelineCoverage) {
3730
+ errors.push(
3731
+ `referenceFeatureInventory "${featureLabel}" acceptanceId "${acceptanceId}" must point to an acceptance entry with referenceCoverage or referenceTimelineCoverage.`,
3732
+ );
3733
+ }
3734
+
3735
+ if (entry.referenceCoverage) {
3736
+ referenceCoverageFromInventory.add(entry.referenceCoverage);
3737
+ }
3738
+
3739
+ if (entry.referenceTimelineCoverage) {
3740
+ referenceTimelineCoverageFromInventory.add(entry.referenceTimelineCoverage);
3741
+ }
3742
+
3743
+ if (!entry.automated || !entry.automatedTestName.trim()) {
3744
+ errors.push(
3745
+ `${acceptanceId} must have automated coverage proving inventoried reference feature "${featureLabel}".`,
3746
+ );
3747
+ }
3748
+
3749
+ if (!entry.browser || !entry.browserTestName.trim()) {
3750
+ errors.push(
3751
+ `${acceptanceId} must have browser coverage proving inventoried reference feature "${featureLabel}".`,
3752
+ );
3753
+ }
3754
+
3755
+ if (!entry.expectedObservable.trim()) {
3756
+ errors.push(
3757
+ `${acceptanceId} must describe the observable result for inventoried reference feature "${featureLabel}".`,
3758
+ );
3759
+ }
3760
+ }
3761
+
2972
3762
  for (const coverage of requiredReferenceCloneCoverage) {
2973
3763
  if (!declaredReferenceCoverage.has(coverage)) {
2974
3764
  errors.push(
@@ -3008,6 +3798,14 @@ export function validateToolcraftAcceptanceCoverage(
3008
3798
  }
3009
3799
  }
3010
3800
 
3801
+ for (const coverage of declaredReferenceCoverage) {
3802
+ if (!referenceCoverageFromInventory.has(coverage)) {
3803
+ errors.push(
3804
+ `reference-runtime-clone behaviorCoverage "${coverage}" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceCoverage.`,
3805
+ );
3806
+ }
3807
+ }
3808
+
3011
3809
  if (!referenceTimeline) {
3012
3810
  errors.push(
3013
3811
  'reference-runtime-clone transferMode must declare referenceTimeline with mode "none", "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline".',
@@ -3021,6 +3819,14 @@ export function validateToolcraftAcceptanceCoverage(
3021
3819
  (coverage) => toolcraftReferenceTimelineCoverage.has(coverage),
3022
3820
  );
3023
3821
 
3822
+ for (const coverage of declaredReferenceTimelineCoverage) {
3823
+ if (!referenceTimelineCoverageFromInventory.has(coverage)) {
3824
+ errors.push(
3825
+ `referenceTimeline behaviorCoverage "${coverage}" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceTimelineCoverage.`,
3826
+ );
3827
+ }
3828
+ }
3829
+
3024
3830
  if (referenceTimeline.mode === "none" && declaredReferenceTimelineCoverage.size > 0) {
3025
3831
  errors.push(
3026
3832
  'referenceTimeline mode "none" must not declare reference timeline behaviorCoverage.',
@@ -3058,6 +3864,57 @@ export function validateToolcraftAcceptanceCoverage(
3058
3864
  );
3059
3865
  }
3060
3866
 
3867
+ if (
3868
+ referenceTimeline.mode === "toolcraft-playback" ||
3869
+ referenceTimeline.mode === "toolcraft-keyframes"
3870
+ ) {
3871
+ const timelineDefaultDurationSeconds = schema.panels.timeline?.defaultDurationSeconds;
3872
+ const loopDuration = referenceTimeline.loopDuration;
3873
+
3874
+ if (!loopDuration) {
3875
+ errors.push(
3876
+ `referenceTimeline mode "${referenceTimeline.mode}" must declare loopDuration with source, seconds, and evidence. Do not let runtime/template fallback duration such as 8s stand in for reference loop intent.`,
3877
+ );
3878
+ } else {
3879
+ if (!Number.isFinite(loopDuration.seconds) || loopDuration.seconds <= 0) {
3880
+ errors.push(
3881
+ `referenceTimeline.loopDuration.seconds must be a positive finite duration; received ${String(loopDuration.seconds)}.`,
3882
+ );
3883
+ }
3884
+
3885
+ if (!isValidTimelineLoopDurationSource(loopDuration.source)) {
3886
+ errors.push(
3887
+ `referenceTimeline.loopDuration.source must be "reference", "user-request", or "product-derived"; received "${String(loopDuration.source)}". Runtime/template fallback is not a valid loop-duration source.`,
3888
+ );
3889
+ }
3890
+
3891
+ if (!loopDuration.evidence.trim()) {
3892
+ errors.push(
3893
+ "referenceTimeline.loopDuration.evidence must explain where the reference loop duration came from, such as measured reference timing, an explicit user request, or a product-derived timing rule.",
3894
+ );
3895
+ }
3896
+
3897
+ if (runtimeDefaultLoopDurationEvidencePattern.test(loopDuration.evidence)) {
3898
+ errors.push(
3899
+ "referenceTimeline.loopDuration.evidence must not cite the runtime/template fallback 8s default as the reference loop source. Use measured reference timing, an explicit user request, or a product-derived timing rule.",
3900
+ );
3901
+ }
3902
+
3903
+ if (typeof timelineDefaultDurationSeconds !== "number") {
3904
+ errors.push(
3905
+ "Toolcraft reference playback/keyframe timelines must set panels.timeline.defaultDurationSeconds to referenceTimeline.loopDuration.seconds so the initial UI duration is not the runtime fallback.",
3906
+ );
3907
+ } else if (
3908
+ Number.isFinite(loopDuration.seconds) &&
3909
+ Math.abs(timelineDefaultDurationSeconds - loopDuration.seconds) > 0.001
3910
+ ) {
3911
+ errors.push(
3912
+ `panels.timeline.defaultDurationSeconds (${timelineDefaultDurationSeconds}) must match referenceTimeline.loopDuration.seconds (${loopDuration.seconds}).`,
3913
+ );
3914
+ }
3915
+ }
3916
+ }
3917
+
3061
3918
  if (
3062
3919
  referenceTimeline.mode === "toolcraft-playback" &&
3063
3920
  declaredReferenceTimelineCoverage.has("keyframes")
@@ -3141,6 +3998,17 @@ export function validateToolcraftAcceptanceCoverage(
3141
3998
  }
3142
3999
  }
3143
4000
  }
4001
+
4002
+ for (const entry of acceptance) {
4003
+ if (
4004
+ (entry.referenceCoverage || entry.referenceTimelineCoverage) &&
4005
+ !referenceFeatureAcceptanceIds.has(entry.id)
4006
+ ) {
4007
+ errors.push(
4008
+ `${entry.id} declares reference coverage but is not mapped from referenceFeatureInventory. Every reference acceptance row must correspond to an inventoried reference feature.`,
4009
+ );
4010
+ }
4011
+ }
3144
4012
  } else {
3145
4013
  for (const entry of acceptance) {
3146
4014
  if (entry.referenceCoverage) {
@@ -3213,19 +4081,32 @@ export function validateToolcraftAcceptanceCoverage(
3213
4081
  errors.push(
3214
4082
  `${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.`,
3215
4083
  );
3216
- } else if (hasTimelinePlaybackCoveragePart(playbackEntry.timelinePlaybackCoverage, "duration")) {
3217
- const durationEvidenceText = [
4084
+ } else {
4085
+ const playbackEvidenceText = [
3218
4086
  playbackEntry.automatedTestName,
3219
4087
  playbackEntry.browserTestName,
3220
4088
  playbackEntry.expectedObservable,
3221
4089
  playbackEntry.userAction,
3222
4090
  ].join(" ");
3223
4091
 
3224
- if (!/\bduration\b/i.test(durationEvidenceText) || !/\b(edit|change|commit|enter|set)\w*\b/i.test(durationEvidenceText)) {
4092
+ if (
4093
+ hasTimelinePlaybackCoveragePart(playbackEntry.timelinePlaybackCoverage, "duration") &&
4094
+ (!/\bduration\b/i.test(playbackEvidenceText) ||
4095
+ !/\b(edit|change|commit|enter|set)\w*\b/i.test(playbackEvidenceText))
4096
+ ) {
3225
4097
  errors.push(
3226
4098
  `${playbackEntry.id} timelinePlaybackCoverage "duration" must describe editing/changing the timeline duration through the UI and proving the renderer follows state.timeline.durationSeconds.`,
3227
4099
  );
3228
4100
  }
4101
+
4102
+ if (
4103
+ hasTimelinePlaybackCoveragePart(playbackEntry.timelinePlaybackCoverage, "loop") &&
4104
+ !hasSeamlessForwardLoopEvidence(playbackEvidenceText)
4105
+ ) {
4106
+ errors.push(
4107
+ `${playbackEntry.id} timelinePlaybackCoverage "loop" must prove a seamless forward-only product loop: motion advances in one direction, avoids mirror/yoyo/ping-pong/reverse fallbacks, first and last frames stitch without a visible jump, and the same seam holds after changing timeline duration.`,
4108
+ );
4109
+ }
3229
4110
  }
3230
4111
  }
3231
4112
 
@@ -3275,84 +4156,83 @@ export function validateToolcraftAcceptanceCoverage(
3275
4156
  }
3276
4157
  }
3277
4158
 
3278
- if (schema.persistence.storage === "localStorage") {
3279
- const persistenceEntry = acceptance.find(
4159
+ if (
4160
+ schema.canvas.enabled &&
4161
+ schema.canvas.upload &&
4162
+ schema.canvas.sizing.mode === "intrinsic-media"
4163
+ ) {
4164
+ const intrinsicCanvasSizingEntry = acceptance.find(
3280
4165
  (entry) =>
3281
4166
  entry.kind === "runtime" &&
3282
- entry.persistenceCoverage === "reload",
4167
+ entry.canvasSizingCoverage === "intrinsic-media-size",
3283
4168
  );
3284
4169
 
3285
- if (!persistenceEntry) {
4170
+ if (!intrinsicCanvasSizingEntry) {
3286
4171
  errors.push(
3287
- '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.',
4172
+ 'canvas.sizing mode "intrinsic-media" with upload requires a runtime acceptance entry with canvasSizingCoverage "intrinsic-media-size" proving the app is a true media-viewer/source-native product where imported media natural dimensions intentionally own canvas.size. Uploaded background/source images inside product canvases must use "editable-output" and keep the current canvas size.',
3288
4173
  );
3289
4174
  } else {
3290
- const evidenceText = getAcceptanceEvidenceText(persistenceEntry);
4175
+ const evidenceText = getAcceptanceEvidenceText(intrinsicCanvasSizingEntry);
3291
4176
 
3292
- if (!persistenceEntry.automated || !persistenceEntry.automatedTestName.trim()) {
3293
- errors.push(
3294
- `${persistenceEntry.id} must have automated coverage proving persistence reload behavior.`,
3295
- );
3296
- }
3297
-
3298
- if (!persistenceEntry.browser || !persistenceEntry.browserTestName.trim()) {
4177
+ if (
4178
+ !/(media[- ]viewer|source[- ]native|natural dimension|natural size|intrinsic media|intrinsic size|media owns canvas|canvas\.size)/i.test(
4179
+ evidenceText,
4180
+ )
4181
+ ) {
3299
4182
  errors.push(
3300
- `${persistenceEntry.id} must have browser coverage proving persistence reload behavior.`,
4183
+ `${intrinsicCanvasSizingEntry.id} canvasSizingCoverage "intrinsic-media-size" must explain why uploaded media natural dimensions intentionally own canvas.size instead of behaving as background/source material inside the current editable output canvas.`,
3301
4184
  );
3302
4185
  }
3303
4186
 
3304
- if (!persistenceEntry.expectedObservable.trim()) {
4187
+ if (!intrinsicCanvasSizingEntry.automated || !intrinsicCanvasSizingEntry.automatedTestName.trim()) {
3305
4188
  errors.push(
3306
- `${persistenceEntry.id} must describe the persisted state observable after reload.`,
4189
+ `${intrinsicCanvasSizingEntry.id} must have automated coverage proving intrinsic media sizing.`,
3307
4190
  );
3308
4191
  }
3309
4192
 
3310
- if (!/\b(reload|refresh|reopen|page\.reload)\b/i.test(evidenceText)) {
4193
+ if (!intrinsicCanvasSizingEntry.browser || !intrinsicCanvasSizingEntry.browserTestName.trim()) {
3311
4194
  errors.push(
3312
- `${persistenceEntry.id} persistenceCoverage "reload" must describe changing a user-facing setting, reloading the browser page, and observing the restored value/output.`,
4195
+ `${intrinsicCanvasSizingEntry.id} must have browser coverage proving intrinsic media sizing.`,
3313
4196
  );
3314
4197
  }
3315
4198
  }
3316
4199
  }
3317
4200
 
3318
- const settingsTransferEligibility = getToolcraftSettingsTransferEligibility({
3319
- panels: schema.panels,
3320
- });
3321
-
3322
- if (settingsTransferEligibility.eligible && !schema.settingsTransfer.enabled) {
3323
- const settingsTransferOptOutEntry = acceptance.find(
4201
+ if (schema.persistence.storage === "localStorage") {
4202
+ const persistenceEntry = acceptance.find(
3324
4203
  (entry) =>
3325
4204
  entry.kind === "runtime" &&
3326
- entry.target === "runtime.settingsTransfer" &&
3327
- entry.settingsTransferCoverage === "opt-out",
4205
+ entry.persistenceCoverage === "reload",
3328
4206
  );
3329
4207
 
3330
- if (!settingsTransferOptOutEntry) {
4208
+ if (!persistenceEntry) {
3331
4209
  errors.push(
3332
- [
3333
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
3334
- `Eligibility: ${settingsTransferEligibility.controlCount} product controls, ${settingsTransferEligibility.sectionCount} product sections, weighted score ${settingsTransferEligibility.score}, reasons ${settingsTransferEligibility.reasons.join(", ")}.`,
3335
- '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.',
3336
- ].join(" "),
4210
+ '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.',
3337
4211
  );
3338
4212
  } else {
3339
- const evidenceText = getAcceptanceEvidenceText(settingsTransferOptOutEntry);
4213
+ const evidenceText = getAcceptanceEvidenceText(persistenceEntry);
3340
4214
 
3341
- if (!settingsTransferOptOutEntry.automated || !settingsTransferOptOutEntry.automatedTestName.trim()) {
4215
+ if (!persistenceEntry.automated || !persistenceEntry.automatedTestName.trim()) {
3342
4216
  errors.push(
3343
- `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have automated coverage proving the app intentionally omits portable settings.`,
4217
+ `${persistenceEntry.id} must have automated coverage proving persistence reload behavior.`,
3344
4218
  );
3345
4219
  }
3346
4220
 
3347
- if (!settingsTransferOptOutEntry.browser || !settingsTransferOptOutEntry.browserTestName.trim()) {
4221
+ if (!persistenceEntry.browser || !persistenceEntry.browserTestName.trim()) {
3348
4222
  errors.push(
3349
- `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have browser coverage proving the app intentionally omits portable settings.`,
4223
+ `${persistenceEntry.id} must have browser coverage proving persistence reload behavior.`,
3350
4224
  );
3351
4225
  }
3352
4226
 
3353
- if (!settingsTransferOptOutReasonPattern.test(evidenceText)) {
4227
+ if (!persistenceEntry.expectedObservable.trim()) {
4228
+ errors.push(
4229
+ `${persistenceEntry.id} must describe the persisted state observable after reload.`,
4230
+ );
4231
+ }
4232
+
4233
+ if (!/\b(reload|refresh|reopen|page\.reload)\b/i.test(evidenceText)) {
3354
4234
  errors.push(
3355
- `${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.`,
4235
+ `${persistenceEntry.id} persistenceCoverage "reload" must describe changing a user-facing setting, reloading the browser page, and observing the restored value/output.`,
3356
4236
  );
3357
4237
  }
3358
4238
  }
@@ -3467,6 +4347,18 @@ export function validateToolcraftAcceptanceCoverage(
3467
4347
  );
3468
4348
  }
3469
4349
 
4350
+ if (control.disabled === true) {
4351
+ errors.push(
4352
+ `${label} sets disabled: true. Generated product panels should show only controls usable in the current state; hide unavailable product controls with visibleWhen instead of rendering disabled controls.`,
4353
+ );
4354
+ }
4355
+
4356
+ if (control.disabledWhen) {
4357
+ errors.push(
4358
+ `${label} uses disabledWhen. Generated product panels should show only controls usable in the current state; hide unavailable product controls with visibleWhen instead of rendering disabled controls.`,
4359
+ );
4360
+ }
4361
+
3470
4362
  if (!entry) {
3471
4363
  errors.push(`${label} is missing an acceptance entry.`);
3472
4364
  continue;
@@ -3499,7 +4391,18 @@ export function validateToolcraftAcceptanceCoverage(
3499
4391
  }
3500
4392
 
3501
4393
  if (control.type === "fileDrop") {
3502
- errors.push(...getFileDropLifecycleCoverageErrors(label, entry));
4394
+ const hasDefaultMediaAssets = schema.media.defaultAssets.some(
4395
+ (asset) => asset.sourceTarget === control.target,
4396
+ );
4397
+
4398
+ errors.push(
4399
+ ...getFileDropLifecycleCoverageErrors(
4400
+ label,
4401
+ control,
4402
+ entry,
4403
+ hasDefaultMediaAssets,
4404
+ ),
4405
+ );
3503
4406
  }
3504
4407
 
3505
4408
  if (
@@ -3518,17 +4421,6 @@ export function validateToolcraftAcceptanceCoverage(
3518
4421
  errors.push(...getBuiltInFitCheckErrors(label, entry, control));
3519
4422
  }
3520
4423
 
3521
- if (
3522
- control.disabledWhen &&
3523
- !/\b(disabled|unavailable|inactive|not editable|not meaningful|no effect|without effect)\b/i.test(
3524
- getAcceptanceEvidenceText(entry),
3525
- )
3526
- ) {
3527
- errors.push(
3528
- `${label} uses disabledWhen and acceptance must prove the control becomes disabled/unavailable when ${control.disabledWhen.target} reaches the disabling value.`,
3529
- );
3530
- }
3531
-
3532
4424
  if (
3533
4425
  control.visibleWhen &&
3534
4426
  !/\b(visible|shown|show|appears|hidden|hide|hides|not visible|disappear|unavailable)\b/i.test(
@@ -3663,7 +4555,12 @@ export function validateToolcraftAcceptanceCoverage(
3663
4555
  }
3664
4556
 
3665
4557
  for (const entry of acceptance) {
3666
- if (entry.kind === "control" && entry.target && !controlTargets.has(entry.target)) {
4558
+ if (
4559
+ entry.kind === "control" &&
4560
+ entry.target &&
4561
+ !controlTargets.has(entry.target) &&
4562
+ !isRuntimeSetupControlTarget(entry.target)
4563
+ ) {
3667
4564
  errors.push(`${entry.id} points to missing control target ${entry.target}.`);
3668
4565
  }
3669
4566