@pixel-point/toolcraft 0.0.8 → 0.0.11

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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  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 +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  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 +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -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 +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  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 +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -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"
@@ -147,6 +219,7 @@ export type ToolcraftCustomControlCoverage =
147
219
  const builtInToolcraftControlTypeValues = [
148
220
  "actions",
149
221
  "anchorGrid",
222
+ "aspectRatio",
150
223
  "channelMixer",
151
224
  "checkbox",
152
225
  "code",
@@ -170,8 +243,8 @@ const builtInToolcraftControlTypeValues = [
170
243
  "text",
171
244
  "vector",
172
245
  ] as const;
173
- const settingsTransferOptOutReasonPattern =
174
- /\b(ephemeral|temporary|one-off|not portable|session-only)\b/i;
246
+ const insufficientFixedCanvasSizingReasonPattern =
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;
175
248
 
176
249
  export type ToolcraftBuiltInControlType =
177
250
  (typeof builtInToolcraftControlTypeValues)[number];
@@ -187,14 +260,18 @@ export type ToolcraftTransferMode =
187
260
  | {
188
261
  animationIntent?: ToolcraftAnimationIntent;
189
262
  mode: "new-toolcraft-app";
263
+ videoReferenceStudy?: ToolcraftVideoReferenceStudyEvidence;
190
264
  }
191
265
  | {
192
266
  animationIntent?: ToolcraftAnimationIntent;
193
267
  behaviorCoverage: readonly ToolcraftReferenceCoverage[];
194
268
  mode: "reference-runtime-clone";
269
+ referenceFeatureInventory?: readonly ToolcraftReferenceFeatureInventoryItem[];
195
270
  referenceName: string;
271
+ referenceStudy?: ToolcraftReferenceStudyEvidence;
196
272
  referenceTimeline: ToolcraftReferenceTimelineContract;
197
273
  sourceOfTruth: "reference-runtime";
274
+ videoReferenceStudy?: ToolcraftVideoReferenceStudyEvidence;
198
275
  };
199
276
 
200
277
  export type ToolcraftProductReadiness =
@@ -264,6 +341,15 @@ export type ToolcraftControlOrderItem = {
264
341
  type: string;
265
342
  };
266
343
 
344
+ export type ToolcraftControlSectionInventoryEntry = {
345
+ entity?: string;
346
+ groupingReason: string;
347
+ splitReason?: string;
348
+ targets: readonly string[];
349
+ title: string;
350
+ workflowStage?: string;
351
+ };
352
+
267
353
  export const starterTransferMode: ToolcraftTransferMode = {
268
354
  animationIntent: { mode: "none" },
269
355
  mode: "new-toolcraft-app",
@@ -277,10 +363,16 @@ export const starterProductReadiness: ToolcraftProductReadiness = {
277
363
 
278
364
  export const starterAcceptance: readonly ToolcraftComponentAcceptance[] = [];
279
365
 
366
+ export const starterControlSectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [];
367
+
280
368
  function getActionValue(action: ToolcraftActionSchema | string): string {
281
369
  return typeof action === "string" ? action : action.value;
282
370
  }
283
371
 
372
+ function getActionLabelText(action: ToolcraftActionSchema | string): string {
373
+ return typeof action === "string" ? action : (action.label ?? action.value);
374
+ }
375
+
284
376
  function getActionSearchText(action: ToolcraftActionSchema | string): string {
285
377
  return typeof action === "string" ? action : `${action.label} ${action.value} ${action.command ?? ""}`;
286
378
  }
@@ -289,6 +381,19 @@ function isCanvasSizeTarget(target: string): boolean {
289
381
  return target === "canvas.size.width" || target === "canvas.size.height";
290
382
  }
291
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
+
292
397
  function isResetPanelAction(action: ToolcraftActionSchema | string): boolean {
293
398
  return /\breset\b/i.test(getActionSearchText(action));
294
399
  }
@@ -386,9 +491,70 @@ function getAcceptanceEvidenceText(entry: ToolcraftComponentAcceptance): string
386
491
  ].join(" ");
387
492
  }
388
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
+
389
553
  function getFileDropLifecycleCoverageErrors(
390
554
  label: string,
555
+ control: ToolcraftControlSchema,
391
556
  entry: ToolcraftComponentAcceptance,
557
+ hasDefaultMediaAssets: boolean,
392
558
  ): string[] {
393
559
  const errors: string[] = [];
394
560
  const evidenceText = getAcceptanceEvidenceText(entry);
@@ -407,10 +573,59 @@ function getFileDropLifecycleCoverageErrors(
407
573
  !/\b(reset|reset controls|section reset|global reset)\b/i.test(evidenceText)
408
574
  ) {
409
575
  errors.push(
410
- `${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.`,
411
577
  );
412
578
  }
413
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
+
414
629
  return errors;
415
630
  }
416
631
 
@@ -873,6 +1088,64 @@ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
873
1088
  return typeof control.label === "string" && control.label.trim().length > 0;
874
1089
  }
875
1090
 
1091
+ const shortSingleLineTextMaxLength = 80;
1092
+ const longTextControlEvidencePattern =
1093
+ /\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;
1094
+ const shortSingleLineTextIntentPattern =
1095
+ /\b(button|label|title|name|caption|headline|cta|badge|token|word|phrase|single-line|one-line|short\s+text)\b/i;
1096
+
1097
+ function getControlDefaultString(control: ToolcraftControlSchema): string | undefined {
1098
+ return typeof control.defaultValue === "string" ? control.defaultValue : undefined;
1099
+ }
1100
+
1101
+ function getTextValueControlIntentText(
1102
+ controlId: string,
1103
+ control: ToolcraftControlSchema,
1104
+ ): string {
1105
+ return [
1106
+ controlId,
1107
+ control.target,
1108
+ getControlLabelText(control),
1109
+ control.description ?? "",
1110
+ ]
1111
+ .join(" ")
1112
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2");
1113
+ }
1114
+
1115
+ function getCodeTextareaControlError({
1116
+ control,
1117
+ controlId,
1118
+ label,
1119
+ }: {
1120
+ control: ToolcraftControlSchema;
1121
+ controlId: string;
1122
+ label: string;
1123
+ }): string | undefined {
1124
+ if (control.type !== "code") {
1125
+ return undefined;
1126
+ }
1127
+
1128
+ const defaultValue = getControlDefaultString(control);
1129
+ const intentText = getTextValueControlIntentText(controlId, control);
1130
+ const hasLongTextEvidence =
1131
+ longTextControlEvidencePattern.test(intentText) ||
1132
+ (defaultValue !== undefined &&
1133
+ (defaultValue.length > shortSingleLineTextMaxLength ||
1134
+ /[\r\n]/.test(defaultValue)));
1135
+ const looksLikeShortSingleLineText =
1136
+ shortSingleLineTextIntentPattern.test(intentText) ||
1137
+ (defaultValue !== undefined &&
1138
+ defaultValue.trim().length > 0 &&
1139
+ defaultValue.trim().length <= shortSingleLineTextMaxLength &&
1140
+ !/[\r\n]/.test(defaultValue));
1141
+
1142
+ if (!looksLikeShortSingleLineText || hasLongTextEvidence) {
1143
+ return undefined;
1144
+ }
1145
+
1146
+ 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.`;
1147
+ }
1148
+
876
1149
  const singleCurveSemanticPattern =
877
1150
  /\b(acceleration|accel|bend|easing|ease|response|depth|mask|opacity|alpha|motion|velocity|threshold|falloff|remap|remapping)\b|speed\s+profile|mapping\s+curve|curve\s+mapping/i;
878
1151
  const rgbCurveSemanticPattern =
@@ -931,6 +1204,33 @@ function getToggleControlLabelError(
931
1204
  return undefined;
932
1205
  }
933
1206
 
1207
+ function getSingleActionsControlLabelError(
1208
+ control: ToolcraftControlSchema,
1209
+ ): string | undefined {
1210
+ if (control.type !== "actions") {
1211
+ return undefined;
1212
+ }
1213
+
1214
+ const actions = getControlActions(control);
1215
+
1216
+ if (actions.length !== 1) {
1217
+ return undefined;
1218
+ }
1219
+
1220
+ const label = getControlLabelText(control).trim();
1221
+ const actionLabel = getActionLabelText(actions[0] as ToolcraftActionSchema | string).trim();
1222
+
1223
+ if (
1224
+ label &&
1225
+ actionLabel &&
1226
+ normalizeToolcraftSemanticText(label) === normalizeToolcraftSemanticText(actionLabel)
1227
+ ) {
1228
+ 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".`;
1229
+ }
1230
+
1231
+ return undefined;
1232
+ }
1233
+
934
1234
  const maxInlineSwitchLabelLength = 16;
935
1235
  const maxInlineSwitchLabelWordCount = 2;
936
1236
 
@@ -1281,6 +1581,27 @@ const customReferenceTimelineCoverage = new Set<ToolcraftReferenceTimelineCovera
1281
1581
  "trim-range",
1282
1582
  ]);
1283
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
+
1284
1605
  const timelineTransportControlPattern =
1285
1606
  /\b(play|pause|paused|resume|animate|restart)\b/i;
1286
1607
 
@@ -1311,6 +1632,184 @@ const requiredTimelinePlaybackCoverage = [
1311
1632
  "rendered-frame",
1312
1633
  ] satisfies readonly ToolcraftTimelinePlaybackCoverage[];
1313
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
+
1314
1813
  function isModeSelectorControl(
1315
1814
  controlId: string,
1316
1815
  control: ToolcraftControlSchema,
@@ -1319,7 +1818,7 @@ function isModeSelectorControl(
1319
1818
  return false;
1320
1819
  }
1321
1820
 
1322
- return /mode|type|filter|blend|style|preset|variant/i.test(
1821
+ return /mode|type|filter|style|preset|variant/i.test(
1323
1822
  `${controlId} ${control.target} ${getControlLabelText(control)}`,
1324
1823
  );
1325
1824
  }
@@ -1396,7 +1895,7 @@ function getToolcraftControlOrderErrors(schema: ResolvedToolcraftAppSchema): str
1396
1895
  let previousItem: ToolcraftControlOrderItem | undefined;
1397
1896
 
1398
1897
  for (const [controlId, control] of Object.entries(section.controls)) {
1399
- if (control.type === "panelActions") {
1898
+ if (control.type === "panelActions" || isRuntimeSetupControlTarget(control.target)) {
1400
1899
  continue;
1401
1900
  }
1402
1901
 
@@ -1425,17 +1924,118 @@ function getToolcraftControlOrderErrors(schema: ResolvedToolcraftAppSchema): str
1425
1924
  return errors;
1426
1925
  }
1427
1926
 
1428
- const genericControlSectionTitlePattern =
1429
- /^(controls?|settings?|parameters?|options?|configuration|config|adjustments?)$/i;
1927
+ function getToolcraftRuntimeSetupSectionErrors(
1928
+ schema: ResolvedToolcraftAppSchema,
1929
+ ): string[] {
1930
+ const errors: string[] = [];
1931
+ const controlsPanel = schema.panels.controls;
1430
1932
 
1431
- const controlTypeSectionTitlePattern =
1432
- /^(sliders?|colors?|colours?|inputs?|selects?|switches?|checkboxes?|toggles?|buttons?|actions?)$/i;
1933
+ if (!controlsPanel) {
1934
+ return [
1935
+ "Generated Toolcraft apps must define a controls panel so the mandatory runtime Setup section is visible.",
1936
+ ];
1937
+ }
1433
1938
 
1434
- const weakControlLabelContextSectionTitlePattern =
1435
- /^(appearance|look|looks|properties?|style|styles|values?|visuals?)$/i;
1939
+ const sections = schema.panels.controls?.sections ?? [];
1436
1940
 
1437
- const broadControlSectionTitlePattern =
1438
- /^(animation|export|flow|icon|logo|motion|output|scene|shape|shapes|text|typography|visual|visuals)$/i;
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
+
2028
+ const genericControlSectionTitlePattern =
2029
+ /^(controls?|settings?|parameters?|options?|configuration|config|adjustments?)$/i;
2030
+
2031
+ const controlTypeSectionTitlePattern =
2032
+ /^(sliders?|colors?|colours?|inputs?|selects?|switches?|checkboxes?|toggles?|buttons?|actions?)$/i;
2033
+
2034
+ const weakControlLabelContextSectionTitlePattern =
2035
+ /^(appearance|look|looks|properties?|style|styles|values?|visuals?)$/i;
2036
+
2037
+ const broadControlSectionTitlePattern =
2038
+ /^(animation|export|flow|icon|logo|motion|output|scene|shape|shapes|text|typography|visual|visuals)$/i;
1439
2039
 
1440
2040
  const genericControlLabelPattern =
1441
2041
  /^(angle|amount|blur|brightness|color|contrast|count|density|depth|frequency|height|hue|intensity|offset|opacity|phase|position|quality|radius|rotation|saturation|scale|size|spacing|speed|strength|threshold|tint|width)$/i;
@@ -1445,7 +2045,7 @@ const maxHardControlsPerSection = 10;
1445
2045
 
1446
2046
  const controlSemanticClusterPatterns: ReadonlyArray<readonly [string, RegExp]> = [
1447
2047
  ["input", /\b(upload|source|prompt|content|text|phrase|copy|message|file|media|image)\b/i],
1448
- ["mode", /\b(mode|type|preset|style|variant|blend|filter|layout|format|quality)\b/i],
2048
+ ["mode", /\b(mode|type|preset|style|variant|filter|layout|format|quality)\b/i],
1449
2049
  ["motion", /\b(animation|speed|velocity|accel|acceleration|correlation|duration|timing|loop|phase|fps|rate)\b/i],
1450
2050
  ["geometry", /\b(width|height|size|scale|position|offset|anchor|origin|target|radius|distance|spread|bend|curve|curves|path|shape|grid|gap)\b/i],
1451
2051
  ["density", /\b(fill|density|amount|count|ratio|word|words|letter|letters|particle|particles|layer|layers|island|islands)\b/i],
@@ -1683,6 +2283,74 @@ function getToolcraftControlSemanticCluster(
1683
2283
  return inferToolcraftControlOrderRole(controlId, control);
1684
2284
  }
1685
2285
 
2286
+ function normalizeToolcraftConditionValue(value: unknown): string | null {
2287
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2288
+ const normalized = normalizeToolcraftSemanticText(String(value));
2289
+ return normalized || null;
2290
+ }
2291
+
2292
+ return null;
2293
+ }
2294
+
2295
+ function getToolcraftConditionBranchTexts(
2296
+ condition: ToolcraftControlConditionSchema,
2297
+ gateControl: ToolcraftControlSchema,
2298
+ ): string[] {
2299
+ const rawValues =
2300
+ "equals" in condition
2301
+ ? [condition.equals]
2302
+ : Array.isArray(condition.oneOf)
2303
+ ? [...condition.oneOf]
2304
+ : [];
2305
+ const normalizedValues = rawValues
2306
+ .map(normalizeToolcraftConditionValue)
2307
+ .filter((value): value is string => Boolean(value));
2308
+ const optionLabels = (gateControl.options ?? [])
2309
+ .filter((option) =>
2310
+ normalizedValues.includes(normalizeToolcraftSemanticText(option.value)),
2311
+ )
2312
+ .map((option) => normalizeToolcraftSemanticText(option.label))
2313
+ .filter(Boolean);
2314
+
2315
+ return [...new Set([...normalizedValues, ...optionLabels])];
2316
+ }
2317
+
2318
+ function sectionTitleLooksLikeDependencyBranch({
2319
+ condition,
2320
+ gateControl,
2321
+ sectionTitle,
2322
+ }: {
2323
+ condition: ToolcraftControlConditionSchema;
2324
+ gateControl: ToolcraftControlSchema;
2325
+ sectionTitle: string | undefined;
2326
+ }): boolean {
2327
+ const sectionText = normalizeToolcraftSemanticText(sectionTitle);
2328
+
2329
+ if (!sectionText) {
2330
+ return false;
2331
+ }
2332
+
2333
+ return getToolcraftConditionBranchTexts(condition, gateControl).some(
2334
+ (branchText) =>
2335
+ branchText.length > 2 &&
2336
+ (sectionText === branchText ||
2337
+ sectionText.includes(branchText) ||
2338
+ branchText.includes(sectionText)),
2339
+ );
2340
+ }
2341
+
2342
+ function isToolcraftProductSectionControl(control: ToolcraftControlSchema): boolean {
2343
+ return (
2344
+ control.type !== "panelActions" &&
2345
+ control.type !== "settingsTransfer" &&
2346
+ !isRuntimeSetupControlTarget(control.target)
2347
+ );
2348
+ }
2349
+
2350
+ function isToolcraftVisibleAcceptanceControl(control: ToolcraftControlSchema): boolean {
2351
+ return control.type === "panelActions" || isToolcraftProductSectionControl(control);
2352
+ }
2353
+
1686
2354
  function getToolcraftGenericControlLabelError({
1687
2355
  control,
1688
2356
  controlId,
@@ -1887,8 +2555,187 @@ function getToolcraftColorBankLabelErrors({
1887
2555
  return errors;
1888
2556
  }
1889
2557
 
2558
+ function getToolcraftSectionInventoryByTitle(
2559
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
2560
+ ): Map<string, ToolcraftControlSectionInventoryEntry> {
2561
+ return new Map(
2562
+ sectionInventory.map((entry) => [entry.title.trim(), entry]),
2563
+ );
2564
+ }
2565
+
2566
+ function hasToolcraftInventorySplitEvidence({
2567
+ sectionInventoryByTitle,
2568
+ sections,
2569
+ }: {
2570
+ sectionInventoryByTitle: ReadonlyMap<string, ToolcraftControlSectionInventoryEntry>;
2571
+ sections: ReadonlySet<string>;
2572
+ }): boolean {
2573
+ const entries = [...sections].map((sectionLabel) =>
2574
+ sectionInventoryByTitle.get(sectionLabel),
2575
+ );
2576
+
2577
+ return (
2578
+ entries.length === sections.size &&
2579
+ entries.every(
2580
+ (entry) =>
2581
+ entry &&
2582
+ (entry.workflowStage?.trim().length ?? 0) > 0 &&
2583
+ (entry.splitReason?.trim().length ?? 0) >= 12,
2584
+ )
2585
+ );
2586
+ }
2587
+
2588
+ function getToolcraftControlSectionInventoryErrors(
2589
+ schema: ResolvedToolcraftAppSchema,
2590
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
2591
+ ): string[] {
2592
+ const errors: string[] = [];
2593
+
2594
+ if (sectionInventory.length === 0) {
2595
+ return errors;
2596
+ }
2597
+
2598
+ const schemaSections = (schema.panels.controls?.sections ?? []).flatMap(
2599
+ (section, sectionIndex) => {
2600
+ const targets = Object.values(section.controls)
2601
+ .filter(isToolcraftProductSectionControl)
2602
+ .map((control) => control.target);
2603
+
2604
+ if (targets.length === 0) {
2605
+ return [];
2606
+ }
2607
+
2608
+ return [
2609
+ {
2610
+ label: getToolcraftSectionLabel(section.title, sectionIndex),
2611
+ targets,
2612
+ },
2613
+ ];
2614
+ },
2615
+ );
2616
+ const schemaSectionTitles = new Set(schemaSections.map((section) => section.label));
2617
+ const schemaTargetToSection = new Map<string, string>();
2618
+
2619
+ for (const section of schemaSections) {
2620
+ for (const target of section.targets) {
2621
+ schemaTargetToSection.set(target, section.label);
2622
+ }
2623
+ }
2624
+
2625
+ const inventoryTitleCounts = new Map<string, number>();
2626
+ const inventoryTargetToSection = new Map<string, string>();
2627
+
2628
+ for (const entry of sectionInventory) {
2629
+ const title = entry.title.trim();
2630
+ const entity = entry.entity?.trim() ?? "";
2631
+ const workflowStage = entry.workflowStage?.trim() ?? "";
2632
+ const groupingReason = entry.groupingReason.trim();
2633
+ const splitReason = entry.splitReason?.trim() ?? "";
2634
+
2635
+ if (!title) {
2636
+ errors.push(
2637
+ "Control Section Inventory contains an entry without a section title.",
2638
+ );
2639
+ continue;
2640
+ }
2641
+
2642
+ inventoryTitleCounts.set(title, (inventoryTitleCounts.get(title) ?? 0) + 1);
2643
+
2644
+ if (!schemaSectionTitles.has(title)) {
2645
+ errors.push(
2646
+ `Control Section Inventory references "${title}", but no rendered product controls section uses that title.`,
2647
+ );
2648
+ }
2649
+
2650
+ if (!entity && !workflowStage) {
2651
+ errors.push(
2652
+ `Control Section Inventory entry "${title}" must declare entity or workflowStage so grouping is based on product meaning, not UI layout.`,
2653
+ );
2654
+ }
2655
+
2656
+ if (groupingReason.length < 12) {
2657
+ errors.push(
2658
+ `Control Section Inventory entry "${title}" must include a concrete groupingReason explaining why these controls belong together.`,
2659
+ );
2660
+ }
2661
+
2662
+ if (splitReason && splitReason.length < 12) {
2663
+ errors.push(
2664
+ `Control Section Inventory entry "${title}" splitReason is too vague. Explain the product workflow split or omit splitReason.`,
2665
+ );
2666
+ }
2667
+
2668
+ if (entry.targets.length === 0) {
2669
+ errors.push(
2670
+ `Control Section Inventory entry "${title}" must list the product control targets rendered in that section.`,
2671
+ );
2672
+ }
2673
+
2674
+ for (const target of entry.targets) {
2675
+ const schemaSection = schemaTargetToSection.get(target);
2676
+
2677
+ if (!schemaSection) {
2678
+ errors.push(
2679
+ `Control Section Inventory entry "${title}" lists target "${target}", but that target is not rendered by any product controls section.`,
2680
+ );
2681
+ continue;
2682
+ }
2683
+
2684
+ if (schemaSection !== title) {
2685
+ errors.push(
2686
+ `Control Section Inventory entry "${title}" lists target "${target}", but the schema renders it in "${schemaSection}". Update the schema grouping or the inventory.`,
2687
+ );
2688
+ }
2689
+
2690
+ const existingSection = inventoryTargetToSection.get(target);
2691
+
2692
+ if (existingSection && existingSection !== title) {
2693
+ errors.push(
2694
+ `Control Section Inventory lists target "${target}" in both "${existingSection}" and "${title}". Each product control target belongs to exactly one section inventory entry.`,
2695
+ );
2696
+ }
2697
+
2698
+ inventoryTargetToSection.set(target, title);
2699
+ }
2700
+ }
2701
+
2702
+ for (const [title, count] of inventoryTitleCounts) {
2703
+ if (count > 1) {
2704
+ errors.push(
2705
+ `Control Section Inventory repeats section "${title}" ${count} times. Section inventory entries must be unique.`,
2706
+ );
2707
+ }
2708
+ }
2709
+
2710
+ for (const section of schemaSections) {
2711
+ const entry = sectionInventory.find(
2712
+ (inventoryEntry) => inventoryEntry.title.trim() === section.label,
2713
+ );
2714
+
2715
+ if (!entry) {
2716
+ errors.push(
2717
+ `Control Section Inventory is missing product section "${section.label}". Add its entity/workflow stage, targets, and groupingReason.`,
2718
+ );
2719
+ continue;
2720
+ }
2721
+
2722
+ const inventoryTargets = new Set(entry.targets);
2723
+
2724
+ for (const target of section.targets) {
2725
+ if (!inventoryTargets.has(target)) {
2726
+ errors.push(
2727
+ `Control Section Inventory entry "${section.label}" is missing rendered target "${target}". The inventory must cover every product control in the section.`,
2728
+ );
2729
+ }
2730
+ }
2731
+ }
2732
+
2733
+ return errors;
2734
+ }
2735
+
1890
2736
  function getToolcraftControlSectionGroupingErrors(
1891
2737
  schema: ResolvedToolcraftAppSchema,
2738
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [],
1892
2739
  ): string[] {
1893
2740
  const errors: string[] = [];
1894
2741
  const visibleControls: Array<{
@@ -1896,17 +2743,29 @@ function getToolcraftControlSectionGroupingErrors(
1896
2743
  controlId: string;
1897
2744
  loosePrefix: string | null;
1898
2745
  sectionLabel: string;
2746
+ sectionTitle: string | undefined;
1899
2747
  }> = [];
1900
2748
  const strictPrefixSections = new Map<string, Set<string>>();
1901
2749
  const loosePrefixSections = new Map<string, Set<string>>();
1902
2750
  const colorSectionLoosePrefixes = new Map<string, string>();
2751
+ const sectionInventoryByTitle = getToolcraftSectionInventoryByTitle(sectionInventory);
1903
2752
  const sectionTitleCounts = new Map<string, { count: number; label: string }>();
2753
+ const controlsByTarget = new Map<
2754
+ string,
2755
+ {
2756
+ control: ToolcraftControlSchema;
2757
+ controlId: string;
2758
+ loosePrefix: string | null;
2759
+ sectionLabel: string;
2760
+ sectionTitle: string | undefined;
2761
+ }
2762
+ >();
1904
2763
 
1905
2764
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
1906
2765
  const sectionTitle = section.title?.trim();
1907
2766
  const sectionLabel = getToolcraftSectionLabel(sectionTitle, sectionIndex);
1908
- const controls = Object.entries(section.controls).filter(
1909
- ([, control]) => control.type !== "panelActions",
2767
+ const controls = Object.entries(section.controls).filter(([, control]) =>
2768
+ isToolcraftProductSectionControl(control),
1910
2769
  );
1911
2770
 
1912
2771
  if (controls.length === 0) {
@@ -1945,11 +2804,8 @@ function getToolcraftControlSectionGroupingErrors(
1945
2804
  .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
1946
2805
  .filter((prefix): prefix is string => Boolean(prefix)),
1947
2806
  );
1948
- const productControls = controls.filter(
1949
- ([, control]) =>
1950
- control.type !== "settingsTransfer" &&
1951
- control.target !== "canvas.size.width" &&
1952
- control.target !== "canvas.size.height",
2807
+ const productControls = controls.filter(([, control]) =>
2808
+ isToolcraftProductSectionControl(control),
1953
2809
  );
1954
2810
  const semanticClusters = new Set(
1955
2811
  productControls.map(([controlId, control]) =>
@@ -2015,6 +2871,14 @@ function getToolcraftControlSectionGroupingErrors(
2015
2871
  controlId,
2016
2872
  loosePrefix,
2017
2873
  sectionLabel,
2874
+ sectionTitle,
2875
+ });
2876
+ controlsByTarget.set(control.target, {
2877
+ control,
2878
+ controlId,
2879
+ loosePrefix,
2880
+ sectionLabel,
2881
+ sectionTitle,
2018
2882
  });
2019
2883
 
2020
2884
  if (strictPrefix) {
@@ -2048,6 +2912,42 @@ function getToolcraftControlSectionGroupingErrors(
2048
2912
  }
2049
2913
  }
2050
2914
 
2915
+ for (const item of visibleControls) {
2916
+ for (const [conditionName, condition] of [
2917
+ ["visibleWhen", item.control.visibleWhen],
2918
+ ["disabledWhen", item.control.disabledWhen],
2919
+ ] as const) {
2920
+ if (!condition || !item.loosePrefix) {
2921
+ continue;
2922
+ }
2923
+
2924
+ const gateControl = controlsByTarget.get(condition.target);
2925
+
2926
+ if (!gateControl || gateControl.sectionLabel === item.sectionLabel) {
2927
+ continue;
2928
+ }
2929
+
2930
+ const sharesTargetEntity =
2931
+ gateControl.loosePrefix !== null &&
2932
+ gateControl.loosePrefix === item.loosePrefix;
2933
+ const looksLikeBranchSection =
2934
+ conditionName === "visibleWhen" &&
2935
+ sectionTitleLooksLikeDependencyBranch({
2936
+ condition,
2937
+ gateControl: gateControl.control,
2938
+ sectionTitle: item.sectionTitle,
2939
+ });
2940
+
2941
+ if (!sharesTargetEntity && !looksLikeBranchSection) {
2942
+ continue;
2943
+ }
2944
+
2945
+ errors.push(
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.`,
2947
+ );
2948
+ }
2949
+ }
2950
+
2051
2951
  const fontPickerControls = visibleControls.filter(
2052
2952
  (item) => item.control.type === "fontPicker" && item.loosePrefix,
2053
2953
  );
@@ -2080,9 +2980,12 @@ function getToolcraftControlSectionGroupingErrors(
2080
2980
  }
2081
2981
 
2082
2982
  for (const [prefix, sections] of strictPrefixSections) {
2083
- if (sections.size > 1) {
2983
+ if (
2984
+ sections.size > 1 &&
2985
+ !hasToolcraftInventorySplitEvidence({ sectionInventoryByTitle, sections })
2986
+ ) {
2084
2987
  errors.push(
2085
- `Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the spec names a real workflow split.`,
2988
+ `Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the Control Section Inventory declares workflowStage and splitReason for every split section.`,
2086
2989
  );
2087
2990
  }
2088
2991
  }
@@ -2104,11 +3007,13 @@ export function collectToolcraftVisibleControls(
2104
3007
  schema: ResolvedToolcraftAppSchema = starterSchema,
2105
3008
  ): ToolcraftVisibleControl[] {
2106
3009
  return (schema.panels.controls?.sections ?? []).flatMap((section) =>
2107
- Object.entries(section.controls).map(([controlId, control]) => ({
2108
- control,
2109
- controlId,
2110
- sectionTitle: section.title,
2111
- })),
3010
+ Object.entries(section.controls)
3011
+ .filter(([, control]) => isToolcraftVisibleAcceptanceControl(control))
3012
+ .map(([controlId, control]) => ({
3013
+ control,
3014
+ controlId,
3015
+ sectionTitle: section.title,
3016
+ })),
2112
3017
  );
2113
3018
  }
2114
3019
 
@@ -2125,7 +3030,7 @@ export function getToolcraftControlOrder(
2125
3030
  ): ToolcraftControlOrderItem[] {
2126
3031
  return (schema.panels.controls?.sections ?? []).flatMap((section) =>
2127
3032
  Object.entries(section.controls)
2128
- .filter(([, control]) => control.type !== "panelActions")
3033
+ .filter(([, control]) => isToolcraftProductSectionControl(control))
2129
3034
  .map(([controlId, control]) => {
2130
3035
  const role = inferToolcraftControlOrderRole(controlId, control);
2131
3036
 
@@ -2151,6 +3056,7 @@ export function validateToolcraftAcceptanceCoverage(
2151
3056
  schema: ResolvedToolcraftAppSchema = starterSchema,
2152
3057
  acceptance: readonly ToolcraftComponentAcceptance[] = starterAcceptance,
2153
3058
  transferMode: ToolcraftTransferMode = starterTransferMode,
3059
+ sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = starterControlSectionInventory,
2154
3060
  ): string[] {
2155
3061
  const errors: string[] = [];
2156
3062
  const controls = collectToolcraftVisibleControls(schema);
@@ -2163,6 +3069,7 @@ export function validateToolcraftAcceptanceCoverage(
2163
3069
  const layersEnabled = Boolean(schema.panels.layers);
2164
3070
  const controlTargets = new Set(controls.map(({ control }) => control.target));
2165
3071
  const animationIntent = transferMode.animationIntent;
3072
+ const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
2166
3073
  const animationControls = controls.filter(
2167
3074
  (visibleControl) =>
2168
3075
  visibleControl.control.type !== "panelActions" &&
@@ -2180,8 +3087,30 @@ export function validateToolcraftAcceptanceCoverage(
2180
3087
  "history.undo",
2181
3088
  ]);
2182
3089
 
3090
+ errors.push(...getToolcraftRuntimeSetupSectionErrors(schema));
2183
3091
  errors.push(...getToolcraftControlOrderErrors(schema));
2184
- errors.push(...getToolcraftControlSectionGroupingErrors(schema));
3092
+ errors.push(...getToolcraftControlSectionGroupingErrors(schema, sectionInventory));
3093
+ errors.push(...getToolcraftControlSectionInventoryErrors(schema, sectionInventory));
3094
+
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
+ }
2185
3114
 
2186
3115
  for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
2187
3116
  const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
@@ -2201,6 +3130,16 @@ export function validateToolcraftAcceptanceCoverage(
2201
3130
  );
2202
3131
  }
2203
3132
 
3133
+ const segmentedIds = layoutGroup.controls.filter(
3134
+ (controlId) => section.controls[controlId]?.type === "segmented",
3135
+ );
3136
+
3137
+ if (segmentedIds.length > 0) {
3138
+ errors.push(
3139
+ `${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.`,
3140
+ );
3141
+ }
3142
+
2204
3143
  const switchEntries = layoutGroup.controls
2205
3144
  .map((controlId) => [controlId, section.controls[controlId]] as const)
2206
3145
  .filter(
@@ -2242,11 +3181,21 @@ export function validateToolcraftAcceptanceCoverage(
2242
3181
  `${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes toggle label ${unsafeBooleanLabels.map(([controlId, control]) => `${controlId} "${getInlineSwitchLabelText(controlId, control)}"`).join(", ")} that is too long for a compact toggle-plus-parameter row. Keep the toggle label short, such as "Include" inside Background, or stack the controls.`,
2243
3182
  );
2244
3183
  }
3184
+
3185
+ const visibleParameterLabels = parameterEntries.filter(
3186
+ ([, control]) => control.label !== false,
3187
+ );
3188
+
3189
+ if (visibleParameterLabels.length > 0) {
3190
+ errors.push(
3191
+ `${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.`,
3192
+ );
3193
+ }
2245
3194
  }
2246
3195
  }
2247
3196
 
2248
- const sectionControls = Object.entries(section.controls).filter(
2249
- ([, control]) => control.type !== "panelActions",
3197
+ const sectionControls = Object.entries(section.controls).filter(([, control]) =>
3198
+ isToolcraftProductSectionControl(control),
2250
3199
  );
2251
3200
 
2252
3201
  for (let index = 0; index < sectionControls.length - 1; index += 1) {
@@ -2282,7 +3231,6 @@ export function validateToolcraftAcceptanceCoverage(
2282
3231
  const panelActionsSectionIndex = getFirstPanelActionsSectionIndex(schema);
2283
3232
  const imageExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Image Export");
2284
3233
  const videoExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Video Export");
2285
- const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
2286
3234
  const expectedOutputSettingsIndex =
2287
3235
  imageExportSectionIndex >= 0 ? imageExportSectionIndex : videoExportSectionIndex;
2288
3236
  const finalExportSettingsIndex = hasVideoExportAction
@@ -2474,6 +3422,12 @@ export function validateToolcraftAcceptanceCoverage(
2474
3422
  }
2475
3423
  }
2476
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
+
2477
3431
  if (animationControls.length > 0 && !timelineMode && animationIntent?.mode !== "autonomous") {
2478
3432
  errors.push(
2479
3433
  [
@@ -2495,9 +3449,15 @@ export function validateToolcraftAcceptanceCoverage(
2495
3449
  );
2496
3450
  }
2497
3451
 
2498
- if (!animationIntent.reason.trim()) {
3452
+ if (hasVideoExportAction) {
2499
3453
  errors.push(
2500
- 'starterTransferMode.animationIntent mode "autonomous" must include a reason explaining why the animation is decorative/self-running and does not need top timeline transport.',
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
+
3458
+ if (!animationIntent.reason.trim()) {
3459
+ errors.push(
3460
+ 'starterTransferMode.animationIntent mode "autonomous" must include a reason explaining why the animation is decorative/self-running and does not need top timeline transport.',
2501
3461
  );
2502
3462
  }
2503
3463
 
@@ -2520,9 +3480,92 @@ export function validateToolcraftAcceptanceCoverage(
2520
3480
  );
2521
3481
  }
2522
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
+
2523
3558
  if (transferMode.mode === "reference-runtime-clone") {
2524
3559
  const declaredReferenceCoverage = new Set(transferMode.behaviorCoverage);
2525
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>();
2526
3569
 
2527
3570
  if (!schema.assembly.surfaces.canvas.enabled) {
2528
3571
  errors.push(
@@ -2542,6 +3585,180 @@ export function validateToolcraftAcceptanceCoverage(
2542
3585
  );
2543
3586
  }
2544
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
+
2545
3762
  for (const coverage of requiredReferenceCloneCoverage) {
2546
3763
  if (!declaredReferenceCoverage.has(coverage)) {
2547
3764
  errors.push(
@@ -2581,6 +3798,14 @@ export function validateToolcraftAcceptanceCoverage(
2581
3798
  }
2582
3799
  }
2583
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
+
2584
3809
  if (!referenceTimeline) {
2585
3810
  errors.push(
2586
3811
  'reference-runtime-clone transferMode must declare referenceTimeline with mode "none", "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline".',
@@ -2594,6 +3819,14 @@ export function validateToolcraftAcceptanceCoverage(
2594
3819
  (coverage) => toolcraftReferenceTimelineCoverage.has(coverage),
2595
3820
  );
2596
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
+
2597
3830
  if (referenceTimeline.mode === "none" && declaredReferenceTimelineCoverage.size > 0) {
2598
3831
  errors.push(
2599
3832
  'referenceTimeline mode "none" must not declare reference timeline behaviorCoverage.',
@@ -2631,6 +3864,57 @@ export function validateToolcraftAcceptanceCoverage(
2631
3864
  );
2632
3865
  }
2633
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
+
2634
3918
  if (
2635
3919
  referenceTimeline.mode === "toolcraft-playback" &&
2636
3920
  declaredReferenceTimelineCoverage.has("keyframes")
@@ -2714,6 +3998,17 @@ export function validateToolcraftAcceptanceCoverage(
2714
3998
  }
2715
3999
  }
2716
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
+ }
2717
4012
  } else {
2718
4013
  for (const entry of acceptance) {
2719
4014
  if (entry.referenceCoverage) {
@@ -2786,23 +4081,42 @@ export function validateToolcraftAcceptanceCoverage(
2786
4081
  errors.push(
2787
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.`,
2788
4083
  );
2789
- } else if (hasTimelinePlaybackCoveragePart(playbackEntry.timelinePlaybackCoverage, "duration")) {
2790
- const durationEvidenceText = [
4084
+ } else {
4085
+ const playbackEvidenceText = [
2791
4086
  playbackEntry.automatedTestName,
2792
4087
  playbackEntry.browserTestName,
2793
4088
  playbackEntry.expectedObservable,
2794
4089
  playbackEntry.userAction,
2795
4090
  ].join(" ");
2796
4091
 
2797
- 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
+ ) {
2798
4097
  errors.push(
2799
4098
  `${playbackEntry.id} timelinePlaybackCoverage "duration" must describe editing/changing the timeline duration through the UI and proving the renderer follows state.timeline.durationSeconds.`,
2800
4099
  );
2801
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
+ }
2802
4110
  }
2803
4111
  }
2804
4112
 
2805
4113
  if (schema.canvas.sizing.mode === "fixed-output") {
4114
+ if (schemaHasPngExportPanelAction(schema) || schemaHasVideoExportPanelAction(schema)) {
4115
+ errors.push(
4116
+ '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".',
4117
+ );
4118
+ }
4119
+
2806
4120
  const fixedCanvasSizingEntry = acceptance.find(
2807
4121
  (entry) =>
2808
4122
  entry.kind === "runtime" &&
@@ -2811,14 +4125,20 @@ export function validateToolcraftAcceptanceCoverage(
2811
4125
 
2812
4126
  if (!fixedCanvasSizingEntry) {
2813
4127
  errors.push(
2814
- 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. A user-provided base/default size should normally use "editable-output".',
4128
+ 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. Product/output apps must use "editable-output"; user-provided, reference, fixed-format, or base/default sizes belong in canvas.size as editable initial values.',
2815
4129
  );
2816
4130
  } else {
2817
4131
  const evidenceText = getAcceptanceEvidenceText(fixedCanvasSizingEntry);
2818
4132
 
2819
4133
  if (!/(fixed|locked|non-editable|not user-editable|must not edit|reference-defined|product-defined)/i.test(evidenceText)) {
2820
4134
  errors.push(
2821
- `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the product output dimensions are intentionally fixed, not merely initialized from a default size.`,
4135
+ `${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.`,
4136
+ );
4137
+ }
4138
+
4139
+ if (insufficientFixedCanvasSizingReasonPattern.test(evidenceText)) {
4140
+ errors.push(
4141
+ `${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" cannot be justified by the reference or previous app lacking a size editor. Exportable product apps must use editable-output; put reference, product-defined, or fixed-format dimensions in canvas.size as initial values instead of hiding size controls.`,
2822
4142
  );
2823
4143
  }
2824
4144
 
@@ -2836,84 +4156,83 @@ export function validateToolcraftAcceptanceCoverage(
2836
4156
  }
2837
4157
  }
2838
4158
 
2839
- if (schema.persistence.storage === "localStorage") {
2840
- 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(
2841
4165
  (entry) =>
2842
4166
  entry.kind === "runtime" &&
2843
- entry.persistenceCoverage === "reload",
4167
+ entry.canvasSizingCoverage === "intrinsic-media-size",
2844
4168
  );
2845
4169
 
2846
- if (!persistenceEntry) {
4170
+ if (!intrinsicCanvasSizingEntry) {
2847
4171
  errors.push(
2848
- '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.',
2849
4173
  );
2850
4174
  } else {
2851
- const evidenceText = getAcceptanceEvidenceText(persistenceEntry);
4175
+ const evidenceText = getAcceptanceEvidenceText(intrinsicCanvasSizingEntry);
2852
4176
 
2853
- if (!persistenceEntry.automated || !persistenceEntry.automatedTestName.trim()) {
2854
- errors.push(
2855
- `${persistenceEntry.id} must have automated coverage proving persistence reload behavior.`,
2856
- );
2857
- }
2858
-
2859
- 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
+ ) {
2860
4182
  errors.push(
2861
- `${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.`,
2862
4184
  );
2863
4185
  }
2864
4186
 
2865
- if (!persistenceEntry.expectedObservable.trim()) {
4187
+ if (!intrinsicCanvasSizingEntry.automated || !intrinsicCanvasSizingEntry.automatedTestName.trim()) {
2866
4188
  errors.push(
2867
- `${persistenceEntry.id} must describe the persisted state observable after reload.`,
4189
+ `${intrinsicCanvasSizingEntry.id} must have automated coverage proving intrinsic media sizing.`,
2868
4190
  );
2869
4191
  }
2870
4192
 
2871
- if (!/\b(reload|refresh|reopen|page\.reload)\b/i.test(evidenceText)) {
4193
+ if (!intrinsicCanvasSizingEntry.browser || !intrinsicCanvasSizingEntry.browserTestName.trim()) {
2872
4194
  errors.push(
2873
- `${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.`,
2874
4196
  );
2875
4197
  }
2876
4198
  }
2877
4199
  }
2878
4200
 
2879
- const settingsTransferEligibility = getToolcraftSettingsTransferEligibility({
2880
- panels: schema.panels,
2881
- });
2882
-
2883
- if (settingsTransferEligibility.eligible && !schema.settingsTransfer.enabled) {
2884
- const settingsTransferOptOutEntry = acceptance.find(
4201
+ if (schema.persistence.storage === "localStorage") {
4202
+ const persistenceEntry = acceptance.find(
2885
4203
  (entry) =>
2886
4204
  entry.kind === "runtime" &&
2887
- entry.target === "runtime.settingsTransfer" &&
2888
- entry.settingsTransferCoverage === "opt-out",
4205
+ entry.persistenceCoverage === "reload",
2889
4206
  );
2890
4207
 
2891
- if (!settingsTransferOptOutEntry) {
4208
+ if (!persistenceEntry) {
2892
4209
  errors.push(
2893
- [
2894
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
2895
- `Eligibility: ${settingsTransferEligibility.controlCount} product controls, ${settingsTransferEligibility.sectionCount} product sections, weighted score ${settingsTransferEligibility.score}, reasons ${settingsTransferEligibility.reasons.join(", ")}.`,
2896
- '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.',
2897
- ].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.',
2898
4211
  );
2899
4212
  } else {
2900
- const evidenceText = getAcceptanceEvidenceText(settingsTransferOptOutEntry);
4213
+ const evidenceText = getAcceptanceEvidenceText(persistenceEntry);
4214
+
4215
+ if (!persistenceEntry.automated || !persistenceEntry.automatedTestName.trim()) {
4216
+ errors.push(
4217
+ `${persistenceEntry.id} must have automated coverage proving persistence reload behavior.`,
4218
+ );
4219
+ }
2901
4220
 
2902
- if (!settingsTransferOptOutEntry.automated || !settingsTransferOptOutEntry.automatedTestName.trim()) {
4221
+ if (!persistenceEntry.browser || !persistenceEntry.browserTestName.trim()) {
2903
4222
  errors.push(
2904
- `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have automated coverage proving the app intentionally omits portable settings.`,
4223
+ `${persistenceEntry.id} must have browser coverage proving persistence reload behavior.`,
2905
4224
  );
2906
4225
  }
2907
4226
 
2908
- if (!settingsTransferOptOutEntry.browser || !settingsTransferOptOutEntry.browserTestName.trim()) {
4227
+ if (!persistenceEntry.expectedObservable.trim()) {
2909
4228
  errors.push(
2910
- `${settingsTransferOptOutEntry.id} settingsTransferCoverage "opt-out" must have browser coverage proving the app intentionally omits portable settings.`,
4229
+ `${persistenceEntry.id} must describe the persisted state observable after reload.`,
2911
4230
  );
2912
4231
  }
2913
4232
 
2914
- if (!settingsTransferOptOutReasonPattern.test(evidenceText)) {
4233
+ if (!/\b(reload|refresh|reopen|page\.reload)\b/i.test(evidenceText)) {
2915
4234
  errors.push(
2916
- `${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.`,
2917
4236
  );
2918
4237
  }
2919
4238
  }
@@ -2938,11 +4257,25 @@ export function validateToolcraftAcceptanceCoverage(
2938
4257
  const isCustomControl = isCustomToolcraftControl(control);
2939
4258
  const isSelectedLayerTarget = control.target.startsWith("selectedLayer.");
2940
4259
  const toggleLabelError = getToggleControlLabelError(control, sectionTitle);
4260
+ const singleActionsLabelError = getSingleActionsControlLabelError(control);
4261
+ const codeTextareaError = getCodeTextareaControlError({
4262
+ control,
4263
+ controlId,
4264
+ label,
4265
+ });
2941
4266
 
2942
4267
  if (toggleLabelError) {
2943
4268
  errors.push(`${label} ${toggleLabelError}`);
2944
4269
  }
2945
4270
 
4271
+ if (singleActionsLabelError) {
4272
+ errors.push(`${label} ${singleActionsLabelError}`);
4273
+ }
4274
+
4275
+ if (codeTextareaError) {
4276
+ errors.push(codeTextareaError);
4277
+ }
4278
+
2946
4279
  if (
2947
4280
  control.type === "rangeSlider" &&
2948
4281
  Array.isArray(control.defaultValue) &&
@@ -3014,6 +4347,18 @@ export function validateToolcraftAcceptanceCoverage(
3014
4347
  );
3015
4348
  }
3016
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
+
3017
4362
  if (!entry) {
3018
4363
  errors.push(`${label} is missing an acceptance entry.`);
3019
4364
  continue;
@@ -3046,7 +4391,18 @@ export function validateToolcraftAcceptanceCoverage(
3046
4391
  }
3047
4392
 
3048
4393
  if (control.type === "fileDrop") {
3049
- 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
+ );
3050
4406
  }
3051
4407
 
3052
4408
  if (
@@ -3065,17 +4421,6 @@ export function validateToolcraftAcceptanceCoverage(
3065
4421
  errors.push(...getBuiltInFitCheckErrors(label, entry, control));
3066
4422
  }
3067
4423
 
3068
- if (
3069
- control.disabledWhen &&
3070
- !/\b(disabled|unavailable|inactive|not editable|not meaningful|no effect|without effect)\b/i.test(
3071
- getAcceptanceEvidenceText(entry),
3072
- )
3073
- ) {
3074
- errors.push(
3075
- `${label} uses disabledWhen and acceptance must prove the control becomes disabled/unavailable when ${control.disabledWhen.target} reaches the disabling value.`,
3076
- );
3077
- }
3078
-
3079
4424
  if (
3080
4425
  control.visibleWhen &&
3081
4426
  !/\b(visible|shown|show|appears|hidden|hide|hides|not visible|disappear|unavailable)\b/i.test(
@@ -3143,6 +4488,12 @@ export function validateToolcraftAcceptanceCoverage(
3143
4488
  if (isSliderLikeControl(control)) {
3144
4489
  const expectedMarkerCount = getStepMarkerCount(control);
3145
4490
 
4491
+ if (control.unit === "x") {
4492
+ errors.push(
4493
+ `${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.`,
4494
+ );
4495
+ }
4496
+
3146
4497
  if (
3147
4498
  control.variant === "discrete" &&
3148
4499
  expectedMarkerCount &&
@@ -3204,7 +4555,12 @@ export function validateToolcraftAcceptanceCoverage(
3204
4555
  }
3205
4556
 
3206
4557
  for (const entry of acceptance) {
3207
- 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
+ ) {
3208
4564
  errors.push(`${entry.id} points to missing control target ${entry.target}.`);
3209
4565
  }
3210
4566