@pixel-point/toolcraft 0.0.9 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +42 -9
  2. package/package.json +2 -1
  3. package/src/cli.mjs +4 -2
  4. package/src/cli.test.mjs +5 -2
  5. package/src/generate.mjs +14 -6
  6. package/src/generate.test.mjs +33 -2
  7. package/templates/runtime/contracts/component-contracts.test.ts +175 -36
  8. package/templates/runtime/contracts/component-contracts.ts +90 -45
  9. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +478 -27
  17. package/templates/runtime/react/controls-panel.tsx +71 -26
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  27. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +41 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +657 -2
  41. package/templates/runtime/testing/performance.ts +789 -49
  42. package/templates/starter/AGENTS.md +22 -16
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +58 -37
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +30 -10
  50. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +57 -4
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/index.html +1 -0
  56. package/templates/starter/package.json +3 -2
  57. package/templates/starter/playwright.config.ts +1 -1
  58. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  59. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  60. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  61. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  62. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  63. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  64. package/templates/starter/src/app/starter-performance.test.ts +130 -8
  65. package/templates/starter/src/app/starter-performance.ts +5 -0
  66. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  67. package/templates/starter/src/app/starter-schema.ts +6 -2
  68. package/templates/starter/vite.config.ts +58 -2
  69. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  70. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  71. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  72. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  73. package/templates/ui/components/controls/index.ts +2 -0
  74. package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
  75. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  76. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  77. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -12,6 +12,12 @@ import {
12
12
  import {
13
13
  getToolcraftControlOrderTargets,
14
14
  type ToolcraftComponentAcceptance,
15
+ type ToolcraftReferenceCoverage,
16
+ type ToolcraftReferenceFeatureInventoryItem,
17
+ type ToolcraftReferenceStudyEvidence,
18
+ type ToolcraftReferenceTimelineCoverage,
19
+ type ToolcraftTransferMode,
20
+ type ToolcraftVideoReferenceStudyEvidence,
15
21
  starterAcceptance,
16
22
  starterControlSectionInventory,
17
23
  starterProductReadiness,
@@ -101,6 +107,7 @@ const requiredAgentWorklogDecisionTrailFields = [
101
107
  "Task type",
102
108
  "User-visible result",
103
109
  "Source/reference checked",
110
+ "Reference inputs",
104
111
  "Docs/contracts read",
105
112
  "Contract rules applied",
106
113
  "Decision",
@@ -111,6 +118,10 @@ const requiredAgentWorklogDecisionTrailFields = [
111
118
  "Skipped checks",
112
119
  "Risks",
113
120
  ] as const;
121
+ const worklogVideoReferencePattern =
122
+ /\b(?:source\/reference checked|source reviewed|reference inputs):[^\n]*(?:reference\s+(?:video|gif)|screen\s*recording|contact[-\s]*sheet|storyboard|frame[-\s]*by[-\s]*frame|extracted[-\s]*frames?|cleanshot|ffprobe|[^\s]+\.(?:mp4|mov|webm|gif))\b/i;
123
+ const worklogVideoReferenceStudyPattern =
124
+ /\b(video reference study|storyboard|frame[-\s]*by[-\s]*frame|transition analysis|frame[-\s]*to[-\s]*frame|contact[-\s]*sheet|extracted frames?)\b/i;
114
125
 
115
126
  const playbackTimelineAcceptance: ToolcraftComponentAcceptance = {
116
127
  automated: true,
@@ -134,9 +145,133 @@ const playbackTimelineAcceptance: ToolcraftComponentAcceptance = {
134
145
  "rendered-frame",
135
146
  ],
136
147
  userAction:
137
- "Edit timeline duration, scrub the playback range, then pause and resume playback from the timeline panel.",
148
+ "Edit timeline duration, verify the seamless forward-only loop still stitches first and last frames with no mirror, yoyo, ping-pong, or reverse motion, then scrub, pause, and resume playback from the timeline panel.",
138
149
  };
139
150
 
151
+ const keyframesTimelineAcceptance: ToolcraftComponentAcceptance = {
152
+ automated: true,
153
+ automatedTestName: "timeline keyframes evaluate rendered output",
154
+ browser: true,
155
+ browserTestName: "browser: timeline keyframes evaluate rendered output",
156
+ componentType: "timeline",
157
+ evidence: "timeline-output",
158
+ expectedObservable:
159
+ "Keyframed values create editable rows and change rendered output at different timeline times.",
160
+ fixture: "keyframes timeline fixture",
161
+ id: "timeline.keyframes",
162
+ kind: "runtime",
163
+ target: "timeline.keyframes",
164
+ timelineCoverage: "keyframes",
165
+ userAction: "Create a keyframe, edit its value, and scrub the timeline.",
166
+ };
167
+
168
+ const productDerivedEightSecondLoop = {
169
+ evidence:
170
+ "The product timing fixture derives one complete forward animation cycle over 8s from the authored playback baseline.",
171
+ seconds: 8,
172
+ source: "product-derived" as const,
173
+ };
174
+
175
+ const referenceStudyEvidence = {
176
+ behaviorEvidence:
177
+ "Ran the original app in a local browser and checked canvas sizing, controls, and renderer state changes.",
178
+ referenceLocation: "/fixtures/legacy-badge-wall",
179
+ reproductionSteps:
180
+ "Installed dependencies, started the reference app, opened it in the browser, and compared behavior against the port.",
181
+ sourceEvidence:
182
+ "Inspected reference routes, renderer, control state, export handlers, and media lifecycle source files.",
183
+ status: "ran-original",
184
+ } satisfies ToolcraftReferenceStudyEvidence;
185
+
186
+ const videoReferenceMotionAcceptance: ToolcraftComponentAcceptance = {
187
+ automated: true,
188
+ automatedTestName: "video reference motion matches frame delta study",
189
+ browser: true,
190
+ browserTestName: "browser: video reference motion matches frame delta study",
191
+ componentType: "custom-renderer",
192
+ evidence: "product-output",
193
+ expectedObservable:
194
+ "The renderer preserves the video reference behavior observed across the storyboard transitions.",
195
+ fixture: "video reference storyboard fixture",
196
+ id: "reference.video.motion",
197
+ kind: "runtime",
198
+ target: "reference.video.motion",
199
+ userAction:
200
+ "Compare the implemented renderer against the storyboard frames and frame-to-frame behavior deltas.",
201
+ };
202
+
203
+ const videoReferenceStudyEvidence = {
204
+ acceptanceMapping: [
205
+ {
206
+ acceptanceId: "reference.video.motion",
207
+ behavior: "Planted contact points stretch across adjacent frames before retargeting.",
208
+ frameIds: ["f000", "f012", "f024"],
209
+ },
210
+ ],
211
+ behaviorDecomposition:
212
+ "The video reference is decomposed into body movement, persistent contact points, delayed release, and retargeted anchors. The implementation copies those behaviors rather than a single static frame.",
213
+ extractionEvidence:
214
+ "Extracted frames with ffmpeg and reviewed contact sheets before implementation.",
215
+ referenceLocation: "/fixtures/reference-motion/ref.mp4",
216
+ storyboard: [
217
+ {
218
+ behaviorObservation:
219
+ "Body starts moving while several endpoints remain planted in canvas space.",
220
+ frameId: "f000",
221
+ frameSource: "frames/frame_000.png",
222
+ timeSeconds: 0,
223
+ visualObservation: "The body is near the left side with legs extended outward.",
224
+ },
225
+ {
226
+ behaviorObservation:
227
+ "Body advances and planted endpoints stretch instead of following immediately.",
228
+ frameId: "f012",
229
+ frameSource: "frames/frame_012.png",
230
+ timeSeconds: 0.2,
231
+ visualObservation: "Several endpoints stay near the earlier canvas positions.",
232
+ },
233
+ {
234
+ behaviorObservation:
235
+ "Over-extended legs begin to release and choose new anchors.",
236
+ frameId: "f024",
237
+ frameSource: "frames/frame_024.png",
238
+ timeSeconds: 0.4,
239
+ visualObservation: "One side shows a retargeted leg group closer to the body.",
240
+ },
241
+ {
242
+ behaviorObservation:
243
+ "The new anchor pattern stabilizes before the next release.",
244
+ frameId: "f036",
245
+ frameSource: "frames/frame_036.png",
246
+ timeSeconds: 0.6,
247
+ visualObservation: "Legs settle into a new spread with the body further right.",
248
+ },
249
+ ],
250
+ transitionAnalysis: [
251
+ {
252
+ behaviorDelta:
253
+ "Between f000 and f012, body position changes faster than several leg endpoints, proving contact memory.",
254
+ fromFrameId: "f000",
255
+ id: "f000-f012",
256
+ toFrameId: "f012",
257
+ },
258
+ {
259
+ behaviorDelta:
260
+ "Between f012 and f024, over-extension triggers release and retargeting instead of continuous sine motion.",
261
+ fromFrameId: "f012",
262
+ id: "f012-f024",
263
+ toFrameId: "f024",
264
+ },
265
+ {
266
+ behaviorDelta:
267
+ "Between f024 and f036, newly planted anchors remain stable while the body keeps moving.",
268
+ fromFrameId: "f024",
269
+ id: "f024-f036",
270
+ toFrameId: "f036",
271
+ },
272
+ ],
273
+ } satisfies ToolcraftVideoReferenceStudyEvidence;
274
+
140
275
  function makeControlAcceptance(
141
276
  target: string,
142
277
  componentType: string,
@@ -157,7 +292,91 @@ function makeControlAcceptance(
157
292
  };
158
293
  }
159
294
 
160
- function createSettingsTransferThresholdSchema(settingsTransfer: false | "auto" = false) {
295
+ function makeReferenceFeatureInventory(
296
+ extra: readonly ToolcraftReferenceFeatureInventoryItem[] = [],
297
+ ): readonly ToolcraftReferenceFeatureInventoryItem[] {
298
+ return [
299
+ {
300
+ acceptanceId: "reference.canvasSizing",
301
+ behaviorEvidence:
302
+ "Observed the reference output keep its authored dimensions in the browser.",
303
+ featureName: "Canvas sizing",
304
+ id: "canvas-sizing",
305
+ referenceBehavior: "The reference renderer owns the output dimensions.",
306
+ sourceEvidence: "Inspected reference renderer sizing and browser output.",
307
+ status: "ported",
308
+ toolcraftMapping:
309
+ "Toolcraft editable-output canvas starts from the reference dimensions and keeps runtime sizing visible.",
310
+ },
311
+ {
312
+ acceptanceId: "reference.controlMapping",
313
+ behaviorEvidence:
314
+ "Changed reference controls in the browser and observed renderer parameters update.",
315
+ featureName: "Control mapping",
316
+ id: "control-mapping",
317
+ referenceBehavior: "Reference controls update renderer parameters directly.",
318
+ sourceEvidence: "Inspected reference control state and parameter wiring.",
319
+ status: "ported",
320
+ toolcraftMapping:
321
+ "Toolcraft schema controls write runtime values consumed by the renderer.",
322
+ },
323
+ {
324
+ acceptanceId: "reference.rendererState",
325
+ behaviorEvidence:
326
+ "Observed reference renderer state persist across multiple animation frames.",
327
+ featureName: "Renderer state",
328
+ id: "renderer-state",
329
+ referenceBehavior: "Reference renderer mutable state persists across frames.",
330
+ sourceEvidence: "Inspected reference renderer lifecycle and frame updates.",
331
+ status: "ported",
332
+ toolcraftMapping:
333
+ "Toolcraft renderer keeps equivalent mutable state and invalidation lifecycle.",
334
+ },
335
+ ...extra,
336
+ ];
337
+ }
338
+
339
+ function makeReferenceCoverageAcceptance(
340
+ id: string,
341
+ referenceCoverage: ToolcraftReferenceCoverage,
342
+ ): ToolcraftComponentAcceptance {
343
+ return {
344
+ automated: true,
345
+ automatedTestName: `${id} preserves reference behavior`,
346
+ browser: true,
347
+ browserTestName: `browser: ${id} preserves reference behavior`,
348
+ componentType: "custom-renderer",
349
+ evidence: "product-output",
350
+ expectedObservable: `${id} preserves the reference behavior in Toolcraft output.`,
351
+ fixture: `${id} fixture`,
352
+ id,
353
+ kind: "runtime",
354
+ referenceCoverage,
355
+ userAction: `Exercise ${id}.`,
356
+ };
357
+ }
358
+
359
+ function makeReferenceTimelineCoverageAcceptance(
360
+ id: string,
361
+ referenceTimelineCoverage: ToolcraftReferenceTimelineCoverage,
362
+ ): ToolcraftComponentAcceptance {
363
+ return {
364
+ automated: true,
365
+ automatedTestName: `${id} preserves reference timeline behavior`,
366
+ browser: true,
367
+ browserTestName: `browser: ${id} preserves reference timeline behavior`,
368
+ componentType: "custom-timeline",
369
+ evidence: "timeline-output",
370
+ expectedObservable: `${id} preserves the reference timeline behavior.`,
371
+ fixture: `${id} fixture`,
372
+ id,
373
+ kind: "runtime",
374
+ referenceTimelineCoverage,
375
+ userAction: `Exercise ${id}.`,
376
+ };
377
+ }
378
+
379
+ function createMandatorySetupSchema(settingsTransfer: false | "auto" = false) {
161
380
  return defineToolcraft({
162
381
  canvas: { enabled: true },
163
382
  panels: {
@@ -186,13 +405,13 @@ function createSettingsTransferThresholdSchema(settingsTransfer: false | "auto"
186
405
  });
187
406
  }
188
407
 
189
- function createSettingsTransferThresholdAcceptance() {
408
+ function createMandatorySetupAcceptance() {
190
409
  return Array.from({ length: 12 }, (_, index) =>
191
410
  makeControlAcceptance(`settings.control${index}`, "slider"),
192
411
  );
193
412
  }
194
413
 
195
- function createSettingsTransferBelowThresholdWithCanvasSizeSchema() {
414
+ function createMandatorySetupWithCanvasSizeSchema() {
196
415
  return defineToolcraft({
197
416
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
198
417
  panels: {
@@ -214,14 +433,14 @@ function createSettingsTransferBelowThresholdWithCanvasSizeSchema() {
214
433
  title: "Transform",
215
434
  },
216
435
  ],
217
- title: "Below Threshold Settings",
436
+ title: "Runtime Setup Settings",
218
437
  },
219
438
  },
220
439
  settingsTransfer: false,
221
440
  });
222
441
  }
223
442
 
224
- function createSettingsTransferBelowThresholdWithCanvasSizeAcceptance() {
443
+ function createMandatorySetupWithCanvasSizeAcceptance() {
225
444
  return [
226
445
  makeControlAcceptance("canvas.size.width", "text"),
227
446
  makeControlAcceptance("canvas.size.height", "text"),
@@ -267,7 +486,7 @@ function stripJsComments(source: string): string {
267
486
  }
268
487
 
269
488
  function getMarkdownHeadingLevel(line: string): number | null {
270
- const match = /^(#{1,6})\s+\S/.exec(line);
489
+ const match = /^(#{1,6})\s+\S/.exec(line.trim());
271
490
 
272
491
  return match ? match[1].length : null;
273
492
  }
@@ -333,6 +552,104 @@ function escapeRegExp(value: string): string {
333
552
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
334
553
  }
335
554
 
555
+ const incompleteRequiredCheckPattern =
556
+ /\b(?:fail(?:ed|s|ing)?|not complete|not completed|incomplete|pending|blocked|planned|todo|to do|to run|will run|not run|not started|not yet|queued)\b/i;
557
+ const requiredCheckNamePattern =
558
+ /\b(?:(?:pnpm\s+)?(?:verify:final|verify:perf|test:browser:perf|test:browser|verify:quick|test|build)|browser performance checkpoint|agent-browser|playwright-fallback)\b/i;
559
+ const browserPerformanceCheckpointPattern =
560
+ /\b(?:agent-browser|browser performance checkpoint|performance checkpoint|verify:perf|test:browser:perf|playwright-fallback)\b/i;
561
+ const fallbackPerfReasonPattern =
562
+ /\b(?:agent[- ]browser|agent[- ]controlled browser).*\b(?:unavailable|not available|missing|absent)|\bagent-browser-unavailable\b|\b(?:no|without)\s+(?:agent[- ]browser|agent[- ]controlled browser)\b|\b(?:CI|non-agent automation|non-agent run)\b/i;
563
+
564
+ function getWorklogLines(source: string): string[] {
565
+ return source
566
+ .split(/\r?\n/)
567
+ .map((line) => line.trim())
568
+ .filter(Boolean);
569
+ }
570
+
571
+ function isAllowedPerfSkipLine(line: string): boolean {
572
+ return (
573
+ browserPerformanceCheckpointPattern.test(line) &&
574
+ /\bnot required\b/i.test(line) &&
575
+ /\b(?:post-first|after first|not first|feature loop|non-performance)\b/i.test(line)
576
+ );
577
+ }
578
+
579
+ function isIncompleteOrSkippedLine(line: string): boolean {
580
+ return (
581
+ /\b(?:skip|skipped|not required)\b/i.test(line) ||
582
+ incompleteRequiredCheckPattern.test(line)
583
+ );
584
+ }
585
+
586
+ function isVerificationEvidenceLine(line: string): boolean {
587
+ return /\b(?:Run|Verification|Browser):/i.test(line);
588
+ }
589
+
590
+ function isPassedAgentBrowserPerfRunLine(line: string): boolean {
591
+ return (
592
+ isVerificationEvidenceLine(line) &&
593
+ /\bagent-browser\b/i.test(line) &&
594
+ /\b(?:performance|perf|checkpoint)\b/i.test(line) &&
595
+ !isIncompleteOrSkippedLine(line)
596
+ );
597
+ }
598
+
599
+ function isPassedPlaywrightFallbackPerfRunLine(line: string): boolean {
600
+ return (
601
+ isVerificationEvidenceLine(line) &&
602
+ (/\bpnpm\s+verify:perf\b/i.test(line) ||
603
+ (/\bplaywright-fallback\b/i.test(line) &&
604
+ /\b(?:performance|perf|checkpoint)\b/i.test(line))) &&
605
+ !isIncompleteOrSkippedLine(line)
606
+ );
607
+ }
608
+
609
+ function getAgentWorklogVerificationGateErrors(source: string): string[] {
610
+ const lines = getWorklogLines(source);
611
+ const errors: string[] = [];
612
+ const hasPassedAgentBrowserPerfRun = lines.some(isPassedAgentBrowserPerfRunLine);
613
+ const hasPassedPlaywrightFallbackPerfRun = lines.some(
614
+ isPassedPlaywrightFallbackPerfRunLine,
615
+ );
616
+ const hasFallbackPerfReason = lines.some((line) => fallbackPerfReasonPattern.test(line));
617
+ const hasPassedPerfRun =
618
+ hasPassedAgentBrowserPerfRun ||
619
+ (hasPassedPlaywrightFallbackPerfRun && hasFallbackPerfReason);
620
+ const hasAllowedPerfSkip = lines.some(isAllowedPerfSkipLine);
621
+ const failedRequiredCheckLine = lines.find(
622
+ (line) => requiredCheckNamePattern.test(line) && incompleteRequiredCheckPattern.test(line),
623
+ );
624
+ const skippedRequiredCheckLine = lines.find(
625
+ (line) =>
626
+ /^-?\s*(?:Skipped checks|Skip):/i.test(line) &&
627
+ requiredCheckNamePattern.test(line) &&
628
+ !/\bnone\b/i.test(line) &&
629
+ !isAllowedPerfSkipLine(line),
630
+ );
631
+
632
+ if (failedRequiredCheckLine) {
633
+ errors.push(
634
+ "agent-worklog.md required checks must be passed before final delivery; do not report failed, incomplete, pending, or blocked verification.",
635
+ );
636
+ }
637
+
638
+ if (skippedRequiredCheckLine) {
639
+ errors.push(
640
+ "agent-worklog.md must not list required checks as skipped unless they are explicitly not required for a post-first-working non-performance edit.",
641
+ );
642
+ }
643
+
644
+ if (!hasPassedPerfRun && !hasAllowedPerfSkip) {
645
+ errors.push(
646
+ "agent-worklog.md Verification must record the browser performance checkpoint for first working product delivery: use agent-browser when available, or `pnpm verify:perf` with an explicit fallback reason when no agent browser or CI/non-agent automation is used.",
647
+ );
648
+ }
649
+
650
+ return errors;
651
+ }
652
+
336
653
  function getAgentWorklogValidationErrors(source: string): string[] {
337
654
  const errors: string[] = [];
338
655
 
@@ -395,10 +712,21 @@ function getAgentWorklogValidationErrors(source: string): string[] {
395
712
  errors.push("agent-worklog.md Evidence must name reviewed files, references, or contract rules.");
396
713
  }
397
714
 
715
+ if (
716
+ worklogVideoReferencePattern.test(source) &&
717
+ !worklogVideoReferenceStudyPattern.test(source)
718
+ ) {
719
+ errors.push(
720
+ "agent-worklog.md cites a video reference, screen recording, GIF, extracted frames, or contact sheet; record a Video Reference Study with storyboard frames and frame-to-frame transition analysis.",
721
+ );
722
+ }
723
+
398
724
  if (!/\bpnpm\s+(verify|test|build|typecheck)|browser|Playwright|perf/i.test(verificationBody)) {
399
725
  errors.push("agent-worklog.md Verification must list concrete test/build/browser/performance checks.");
400
726
  }
401
727
 
728
+ errors.push(...getAgentWorklogVerificationGateErrors(source));
729
+
402
730
  if (!/\b(Risk|None):\s*\S/i.test(risksBody)) {
403
731
  errors.push('agent-worklog.md Risks must include either "Risk:" entries or "None:" with a reason.');
404
732
  }
@@ -455,7 +783,7 @@ function sourceDefinesProductCanvasContent(): boolean {
455
783
  function schemaHasProductSurface(): boolean {
456
784
  return (
457
785
  (starterSchema.panels.controls?.sections ?? []).some(
458
- (section) => Object.keys(section.controls).length > 0,
786
+ (section) => section.title !== "Setup" && Object.keys(section.controls).length > 0,
459
787
  ) ||
460
788
  starterSchema.panels.layers === true ||
461
789
  starterSchema.panels.timeline?.enabled === true ||
@@ -677,12 +1005,93 @@ function sourceHasVideoDurationMetadataCoverage(source: string): boolean {
677
1005
  );
678
1006
  }
679
1007
 
1008
+ function sourceHasVideoDimensionMetadataCoverage(source: string): boolean {
1009
+ const hasVideoMetadataReader =
1010
+ /\bdocument\.createElement\s*\(\s*["']video["']\s*\)/.test(source) &&
1011
+ /\bURL\.createObjectURL\b/.test(source) &&
1012
+ /\bloadedmetadata\b|\bonloadedmetadata\b/.test(source) &&
1013
+ /\bvideo\.videoWidth\b/.test(source) &&
1014
+ /\bvideo\.videoHeight\b/.test(source);
1015
+ const hasResolutionCoverage =
1016
+ /\b(?:export\.video\.resolution|video resolution|resolution)\b/i.test(source) &&
1017
+ /\bcurrent\b/i.test(source) &&
1018
+ /\b4k\b/i.test(source);
1019
+ const has4kDimensionExpectation =
1020
+ /\b(?:3840|2160)\b/.test(source) || /\bgetToolcraftVideoExportSize\b/.test(source);
1021
+
1022
+ return hasVideoMetadataReader && hasResolutionCoverage && has4kDimensionExpectation;
1023
+ }
1024
+
680
1025
  function sourceHasCustomMovOrProResEncoder(source: string): boolean {
681
1026
  return /\bVideoEncoder\b|\bffmpeg\b|\bFFmpeg\b|\bProRes\b|\bprores\b|\btranscoder\b/i.test(
682
1027
  source,
683
1028
  );
684
1029
  }
685
1030
 
1031
+ function sourceUsesVideoExportSizeHelper(source: string): boolean {
1032
+ return /\bgetToolcraftVideoExportSize\b/.test(source);
1033
+ }
1034
+
1035
+ function sourceHasUnsafeVideoLongEdgeSizing(source: string): boolean {
1036
+ return (
1037
+ /\b(?:export\.video\.resolution|videoResolution|VideoResolution)\b[\s\S]{0,500}\b4096\b/i.test(
1038
+ source,
1039
+ ) ||
1040
+ /\b4096\b[\s\S]{0,500}\b(?:export\.video\.resolution|videoResolution|VideoResolution)\b/i.test(
1041
+ source,
1042
+ )
1043
+ );
1044
+ }
1045
+
1046
+ function sourceHasCaptureStreamBeforeCanvasSizing(source: string): boolean {
1047
+ const captureStreamMatches = Array.from(
1048
+ source.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\.captureStream\s*\(/g),
1049
+ );
1050
+
1051
+ if (captureStreamMatches.length === 0) {
1052
+ return false;
1053
+ }
1054
+
1055
+ return captureStreamMatches.some((match) => {
1056
+ const canvasName = match[1];
1057
+ const captureStreamIndex = match.index ?? 0;
1058
+ const beforeCaptureStream = source.slice(
1059
+ Math.max(0, captureStreamIndex - 2000),
1060
+ captureStreamIndex,
1061
+ );
1062
+ const escapedCanvasName = escapeRegExp(canvasName);
1063
+
1064
+ return (
1065
+ !new RegExp(`\\b${escapedCanvasName}\\.width\\s*=`).test(beforeCaptureStream) ||
1066
+ !new RegExp(`\\b${escapedCanvasName}\\.height\\s*=`).test(beforeCaptureStream)
1067
+ );
1068
+ });
1069
+ }
1070
+
1071
+ function sourceHandlesVideoRecorderOrEncoderErrors(source: string): boolean {
1072
+ const usesMediaRecorder = /\bnew\s+MediaRecorder\b/.test(source);
1073
+ const usesVideoEncoder = /\bnew\s+VideoEncoder\b/.test(source);
1074
+ const mediaRecorderRejectsErrors =
1075
+ /\.\s*onerror\s*=[\s\S]{0,700}\b(?:reject|throw|Promise\.reject)\b/.test(source) ||
1076
+ /\.addEventListener\s*\(\s*["']error["'][\s\S]{0,900}\b(?:reject|throw|Promise\.reject)\b/.test(
1077
+ source,
1078
+ );
1079
+ const videoEncoderRejectsErrors =
1080
+ /\bnew\s+VideoEncoder\s*\(\s*\{[\s\S]{0,1200}\berror\s*:[\s\S]{0,700}\b(?:reject|throw|Promise\.reject)\b/.test(
1081
+ source,
1082
+ );
1083
+
1084
+ if (usesMediaRecorder && !mediaRecorderRejectsErrors) {
1085
+ return false;
1086
+ }
1087
+
1088
+ if (usesVideoEncoder && !videoEncoderRejectsErrors) {
1089
+ return false;
1090
+ }
1091
+
1092
+ return true;
1093
+ }
1094
+
686
1095
  function sourceHasImageExportDimensionCoverage(source: string): boolean {
687
1096
  const hasImageDecoder =
688
1097
  /\bcreateImageBitmap\b/.test(source) ||
@@ -807,6 +1216,19 @@ describe("Toolcraft template app acceptance coverage", () => {
807
1216
  expect(validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance)).toEqual([]);
808
1217
  });
809
1218
 
1219
+ it("rejects generated apps without the mandatory runtime setup controls panel", () => {
1220
+ const schema = defineToolcraft({
1221
+ canvas: { enabled: true },
1222
+ panels: {},
1223
+ });
1224
+
1225
+ expect(validateToolcraftAcceptanceCoverage(schema, [])).toEqual(
1226
+ expect.arrayContaining([
1227
+ "Generated Toolcraft apps must define a controls panel so the mandatory runtime Setup section is visible.",
1228
+ ]),
1229
+ );
1230
+ });
1231
+
810
1232
  it("requires generated product apps to publish a control section inventory", () => {
811
1233
  if (!schemaHasProductSurface()) {
812
1234
  expect(starterControlSectionInventory).toEqual([]);
@@ -827,7 +1249,7 @@ describe("Toolcraft template app acceptance coverage", () => {
827
1249
  ).toEqual([]);
828
1250
  });
829
1251
 
830
- it("requires fileDrop acceptance to prove upload, clear, and reset lifecycle", () => {
1252
+ it("requires fileDrop acceptance to prove upload, clear, reset, and image transforms", () => {
831
1253
  const fileDropSchema = defineToolcraft({
832
1254
  canvas: { enabled: true },
833
1255
  panels: {
@@ -870,12 +1292,69 @@ describe("Toolcraft template app acceptance coverage", () => {
870
1292
  ]),
871
1293
  ).toEqual(
872
1294
  expect.arrayContaining([
873
- "Source / source (media.source) fileDrop acceptance must prove upload/import, clear/remove, and section or global reset remove source media.",
1295
+ "Source / source (media.source) 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.",
1296
+ "Source / source (media.source) image fileDrop acceptance must prove rotate and flip actions update runtime media transform metadata and that preview, renderer, or export consumes the transform.",
1297
+ ]),
1298
+ );
1299
+ });
1300
+
1301
+ it("requires predefined fileDrop media acceptance to prove attached default restore", () => {
1302
+ const fileDropSchema = defineToolcraft({
1303
+ canvas: { enabled: true, upload: true },
1304
+ media: {
1305
+ defaultAssets: [
1306
+ {
1307
+ dataUrl: "data:image/png;base64,AAAA",
1308
+ fileName: "default-source.png",
1309
+ sourceTarget: "media.source",
1310
+ },
1311
+ ],
1312
+ },
1313
+ panels: {
1314
+ controls: {
1315
+ sections: [
1316
+ {
1317
+ controls: {
1318
+ source: {
1319
+ defaultValue: null,
1320
+ label: "Source image",
1321
+ target: "media.source",
1322
+ type: "fileDrop",
1323
+ },
1324
+ },
1325
+ title: "Source",
1326
+ },
1327
+ ],
1328
+ title: "Controls",
1329
+ },
1330
+ },
1331
+ });
1332
+
1333
+ expect(
1334
+ validateToolcraftAcceptanceCoverage(fileDropSchema, [
1335
+ {
1336
+ automated: true,
1337
+ automatedTestName: "source image upload reset lifecycle",
1338
+ browser: true,
1339
+ browserTestName: "browser: source image upload reset lifecycle",
1340
+ componentType: "fileDrop",
1341
+ evidence: "media-lifecycle",
1342
+ expectedObservable:
1343
+ "Upload, clear, reset, rotate, and flip update the source preview and renderer.",
1344
+ fixture: "source image fixture",
1345
+ id: "media.source",
1346
+ kind: "control",
1347
+ target: "media.source",
1348
+ userAction:
1349
+ "Upload a source image, clear it, use Reset controls, rotate 90°, flip horizontal, and verify runtime media transform metadata is consumed by preview.",
1350
+ },
874
1351
  ]),
1352
+ ).toContain(
1353
+ "Source / source (media.source) 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.",
875
1354
  );
876
1355
  });
877
1356
 
878
- it("accepts fileDrop lifecycle coverage that includes reset", () => {
1357
+ it("accepts fileDrop lifecycle coverage that includes reset and image transforms", () => {
879
1358
  const fileDropSchema = defineToolcraft({
880
1359
  canvas: { enabled: true },
881
1360
  panels: {
@@ -907,13 +1386,13 @@ describe("Toolcraft template app acceptance coverage", () => {
907
1386
  componentType: "fileDrop",
908
1387
  evidence: "media-lifecycle",
909
1388
  expectedObservable:
910
- "Uploading a source image changes the media preview; Clear, section reset, and global Reset controls remove the source media and restore the empty upload state.",
1389
+ "Uploading a source image changes the media preview; rotate and flip actions update runtime media transform metadata and rendered output; Clear, section reset, and global Reset controls remove the source media and restore the empty upload state.",
911
1390
  fixture: "source image fixture",
912
1391
  id: "media.source",
913
1392
  kind: "control",
914
1393
  target: "media.source",
915
1394
  userAction:
916
- "Upload a source image, clear it, upload again, then use section reset and Reset controls.",
1395
+ "Upload a source image, rotate it, flip it, clear it, upload again, then use section reset and Reset controls.",
917
1396
  },
918
1397
  ]);
919
1398
 
@@ -922,6 +1401,77 @@ describe("Toolcraft template app acceptance coverage", () => {
922
1401
  ).toEqual([]);
923
1402
  });
924
1403
 
1404
+ it("requires multiple fileDrop acceptance to prove runtime media reorder", () => {
1405
+ const fileDropSchema = defineToolcraft({
1406
+ canvas: { enabled: true },
1407
+ panels: {
1408
+ controls: {
1409
+ sections: [
1410
+ {
1411
+ controls: {
1412
+ sources: {
1413
+ defaultValue: [],
1414
+ label: "Source images",
1415
+ multiple: true,
1416
+ target: "media.sources",
1417
+ type: "fileDrop",
1418
+ },
1419
+ },
1420
+ title: "Source",
1421
+ },
1422
+ ],
1423
+ title: "Controls",
1424
+ },
1425
+ },
1426
+ });
1427
+
1428
+ expect(
1429
+ validateToolcraftAcceptanceCoverage(fileDropSchema, [
1430
+ {
1431
+ automated: true,
1432
+ automatedTestName: "source images upload clear reset and transform",
1433
+ browser: true,
1434
+ browserTestName: "browser: source images upload clear reset and transform",
1435
+ componentType: "fileDrop",
1436
+ evidence: "media-lifecycle",
1437
+ expectedObservable:
1438
+ "Uploading source images changes the media preview; rotate and flip actions update runtime media transform metadata and rendered output; Clear and Reset controls remove media.",
1439
+ fixture: "source images fixture",
1440
+ id: "media.sources",
1441
+ kind: "control",
1442
+ target: "media.sources",
1443
+ userAction:
1444
+ "Upload two source images, rotate one, flip it, clear the images, and use Reset controls.",
1445
+ },
1446
+ ]),
1447
+ ).toEqual(
1448
+ expect.arrayContaining([
1449
+ "Source / sources (media.sources) multiple fileDrop acceptance must prove thumbnail/file reorder updates runtime media order and that preview, renderer, or export consumes that order.",
1450
+ ]),
1451
+ );
1452
+
1453
+ expect(
1454
+ validateToolcraftAcceptanceCoverage(fileDropSchema, [
1455
+ {
1456
+ automated: true,
1457
+ automatedTestName: "source images upload reorder transform and reset",
1458
+ browser: true,
1459
+ browserTestName: "browser: source images upload reorder transform and reset",
1460
+ componentType: "fileDrop",
1461
+ evidence: "media-lifecycle",
1462
+ expectedObservable:
1463
+ "Uploading source images changes the preview; drag reorder updates runtime media order used by rendered output; rotate and flip update runtime media transform metadata used by export; Clear, section reset, and global Reset controls remove media.",
1464
+ fixture: "source images fixture",
1465
+ id: "media.sources",
1466
+ kind: "control",
1467
+ target: "media.sources",
1468
+ userAction:
1469
+ "Upload two source images, drag to reorder thumbnails, rotate and flip the selected image, clear them, then use section reset and Reset controls.",
1470
+ },
1471
+ ]),
1472
+ ).toEqual([]);
1473
+ });
1474
+
925
1475
  it("rejects fake video duration browser coverage that falls back to expected duration", () => {
926
1476
  const fakeCoverage = `
927
1477
  async function getVideoDuration(page, buffer, mimeType) {
@@ -1142,7 +1692,7 @@ describe("Toolcraft template app acceptance coverage", () => {
1142
1692
  outputActions: {
1143
1693
  actions: [
1144
1694
  {
1145
- icon: "export",
1695
+ icon: "upload-simple",
1146
1696
  label: "Export PNG",
1147
1697
  value: "export.png",
1148
1698
  },
@@ -1184,20 +1734,79 @@ describe("Toolcraft template app acceptance coverage", () => {
1184
1734
  );
1185
1735
  });
1186
1736
 
1187
- it("requires browser reload acceptance when localStorage persistence is enabled", () => {
1188
- const persistentSchema = defineToolcraft({
1189
- canvas: { enabled: true },
1737
+ it("requires intrinsic media sizing acceptance for upload apps that let media own canvas size", () => {
1738
+ const intrinsicUploadSchema = defineToolcraft({
1739
+ canvas: {
1740
+ enabled: true,
1741
+ sizing: { mode: "intrinsic-media" },
1742
+ upload: true,
1743
+ },
1190
1744
  panels: {
1191
1745
  controls: {
1192
- sections: [
1193
- {
1194
- controls: {
1195
- opacity: {
1196
- defaultValue: 0.75,
1197
- label: "Opacity",
1198
- target: "appearance.opacity",
1199
- type: "slider",
1200
- },
1746
+ sections: [],
1747
+ title: "Controls",
1748
+ },
1749
+ },
1750
+ });
1751
+
1752
+ expect(validateToolcraftAcceptanceCoverage(intrinsicUploadSchema, starterAcceptance)).toEqual(
1753
+ expect.arrayContaining([
1754
+ '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.',
1755
+ ]),
1756
+ );
1757
+ });
1758
+
1759
+ it("accepts explicit intrinsic media sizing when browser coverage proves source-native canvas sizing", () => {
1760
+ const intrinsicUploadSchema = defineToolcraft({
1761
+ canvas: {
1762
+ enabled: true,
1763
+ sizing: { mode: "intrinsic-media" },
1764
+ upload: true,
1765
+ },
1766
+ panels: {
1767
+ controls: {
1768
+ sections: [],
1769
+ title: "Controls",
1770
+ },
1771
+ },
1772
+ });
1773
+
1774
+ expect(
1775
+ validateToolcraftAcceptanceCoverage(intrinsicUploadSchema, [
1776
+ ...starterAcceptance,
1777
+ {
1778
+ automated: true,
1779
+ automatedTestName: "media viewer uses uploaded natural dimensions",
1780
+ browser: true,
1781
+ browserTestName: "browser: upload source-native image updates canvas.size",
1782
+ canvasSizingCoverage: "intrinsic-media-size",
1783
+ componentType: "canvas",
1784
+ evidence: "media-lifecycle",
1785
+ expectedObservable:
1786
+ "Uploading a 1400x900 image intentionally changes canvas.size to the image natural dimensions.",
1787
+ fixture: "source-native media viewer fixture",
1788
+ id: "canvas.intrinsicSizing",
1789
+ kind: "runtime",
1790
+ userAction: "Upload an image with known natural dimensions.",
1791
+ },
1792
+ ]),
1793
+ ).toEqual([]);
1794
+ });
1795
+
1796
+ it("requires browser reload acceptance when localStorage persistence is enabled", () => {
1797
+ const persistentSchema = defineToolcraft({
1798
+ canvas: { enabled: true },
1799
+ panels: {
1800
+ controls: {
1801
+ sections: [
1802
+ {
1803
+ controls: {
1804
+ opacity: {
1805
+ defaultValue: 0.75,
1806
+ label: "Opacity",
1807
+ target: "appearance.opacity",
1808
+ type: "slider",
1809
+ },
1201
1810
  },
1202
1811
  title: "Appearance",
1203
1812
  },
@@ -1305,67 +1914,29 @@ describe("Toolcraft template app acceptance coverage", () => {
1305
1914
  );
1306
1915
  });
1307
1916
 
1308
- it("requires settings transfer when product schema crosses the complexity threshold", () => {
1309
- const complexSchema = createSettingsTransferThresholdSchema(false);
1917
+ it("does not require app-authored settings transfer because setup controls are runtime-mandatory", () => {
1918
+ const complexSchema = createMandatorySetupSchema(false);
1310
1919
 
1311
1920
  expect(
1312
1921
  validateToolcraftAcceptanceCoverage(
1313
1922
  complexSchema,
1314
- createSettingsTransferThresholdAcceptance(),
1923
+ createMandatorySetupAcceptance(),
1315
1924
  ),
1316
- ).toEqual(
1317
- expect.arrayContaining([
1318
- expect.stringContaining(
1319
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
1320
- ),
1321
- ]),
1322
- );
1925
+ ).toEqual([]);
1323
1926
  });
1324
1927
 
1325
- it("does not count runtime canvas size controls toward settings transfer eligibility", () => {
1326
- const belowThresholdSchema = createSettingsTransferBelowThresholdWithCanvasSizeSchema();
1928
+ it("accepts small schemas because settings transfer setup is runtime-mandatory", () => {
1929
+ const smallSchema = createMandatorySetupWithCanvasSizeSchema();
1327
1930
  const errors = validateToolcraftAcceptanceCoverage(
1328
- belowThresholdSchema,
1329
- createSettingsTransferBelowThresholdWithCanvasSizeAcceptance(),
1330
- );
1331
-
1332
- expect(errors).not.toEqual(
1333
- expect.arrayContaining([
1334
- expect.stringContaining(
1335
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
1336
- ),
1337
- ]),
1931
+ smallSchema,
1932
+ createMandatorySetupWithCanvasSizeAcceptance(),
1338
1933
  );
1339
- });
1340
-
1341
- it("allows explicit settings transfer opt-out only with runtime evidence", () => {
1342
- const complexSchema = createSettingsTransferThresholdSchema(false);
1343
- const acceptance: ToolcraftComponentAcceptance[] = [
1344
- ...createSettingsTransferThresholdAcceptance(),
1345
- {
1346
- automated: true,
1347
- automatedTestName: "settings transfer intentionally omitted for session-only app",
1348
- browser: true,
1349
- browserTestName: "browser: settings transfer intentionally omitted",
1350
- componentType: "settingsTransfer",
1351
- evidence: "persistence-state",
1352
- expectedObservable:
1353
- "The complex app is session-only and intentionally does not expose portable settings.",
1354
- fixture: "settings transfer opt-out fixture",
1355
- id: "settings.transfer.optOut",
1356
- kind: "runtime",
1357
- settingsTransferCoverage: "opt-out",
1358
- target: "runtime.settingsTransfer",
1359
- userAction:
1360
- "Open the session-only app, change temporary settings, and verify no portable settings workflow is required.",
1361
- },
1362
- ];
1363
1934
 
1364
- expect(validateToolcraftAcceptanceCoverage(complexSchema, acceptance)).toEqual([]);
1935
+ expect(errors).toEqual([]);
1365
1936
  });
1366
1937
 
1367
1938
  it("passes complex schemas with auto settings transfer enabled", () => {
1368
- const complexSchema = createSettingsTransferThresholdSchema("auto");
1939
+ const complexSchema = createMandatorySetupSchema("auto");
1369
1940
  const acceptance: ToolcraftComponentAcceptance[] = [
1370
1941
  {
1371
1942
  automated: true,
@@ -1383,12 +1954,63 @@ describe("Toolcraft template app acceptance coverage", () => {
1383
1954
  userAction:
1384
1955
  "Change one complex setting, export settings, change it again, import the JSON, and observe the restored value.",
1385
1956
  },
1386
- ...createSettingsTransferThresholdAcceptance(),
1957
+ ...createMandatorySetupAcceptance(),
1387
1958
  ];
1388
1959
 
1389
1960
  expect(validateToolcraftAcceptanceCoverage(complexSchema, acceptance)).toEqual([]);
1390
1961
  });
1391
1962
 
1963
+ it("rejects app-authored controls that try to own runtime setup targets", () => {
1964
+ const schema = defineToolcraft({
1965
+ canvas: {
1966
+ enabled: true,
1967
+ renderScale: true,
1968
+ size: { height: 1080, unit: "px", width: 1920 },
1969
+ },
1970
+ panels: {
1971
+ controls: {
1972
+ sections: [
1973
+ {
1974
+ controls: {
1975
+ manualWidth: {
1976
+ defaultValue: 1200,
1977
+ label: "Width",
1978
+ target: "canvas.size.width",
1979
+ type: "text",
1980
+ },
1981
+ manualRenderScale: {
1982
+ defaultValue: 1,
1983
+ label: "Scale",
1984
+ max: 2,
1985
+ min: 1,
1986
+ target: "canvas.renderScale",
1987
+ type: "slider",
1988
+ },
1989
+ manualTimeline: {
1990
+ defaultValue: true,
1991
+ label: "Timeline",
1992
+ target: "panels.timeline.extended",
1993
+ type: "switch",
1994
+ },
1995
+ },
1996
+ title: "Runtime Duplicates",
1997
+ },
1998
+ ],
1999
+ title: "Controls",
2000
+ },
2001
+ },
2002
+ });
2003
+
2004
+ expect(validateToolcraftAcceptanceCoverage(schema, [])).toEqual(
2005
+ expect.arrayContaining([
2006
+ 'Runtime Setup must not include the Timeline switch unless panels.timeline is enabled.',
2007
+ 'Runtime Duplicates / manualWidth uses runtime Setup target "canvas.size.width". 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.',
2008
+ 'Runtime Duplicates / manualRenderScale uses runtime Setup target "canvas.renderScale". 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.',
2009
+ 'Runtime Duplicates / manualTimeline uses runtime Setup target "panels.timeline.extended". 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.',
2010
+ ]),
2011
+ );
2012
+ });
2013
+
1392
2014
  it("requires compound controls to cover every semantic value part", () => {
1393
2015
  const compoundSchema = defineToolcraft({
1394
2016
  canvas: { enabled: true },
@@ -1502,7 +2124,6 @@ describe("Toolcraft template app acceptance coverage", () => {
1502
2124
  });
1503
2125
 
1504
2126
  const acceptance = [
1505
- "runtime.settingsTransfer",
1506
2127
  "mesh.anchor",
1507
2128
  "mesh.focus",
1508
2129
  "mesh.gradient",
@@ -1518,9 +2139,7 @@ describe("Toolcraft template app acceptance coverage", () => {
1518
2139
  browser: true,
1519
2140
  browserTestName: `browser: ${target} changes output`,
1520
2141
  componentType:
1521
- target === "runtime.settingsTransfer"
1522
- ? "settingsTransfer"
1523
- : target === "mesh.anchor"
2142
+ target === "mesh.anchor"
1524
2143
  ? "anchorGrid"
1525
2144
  : target === "mesh.focus"
1526
2145
  ? "vector"
@@ -2536,6 +3155,11 @@ describe("Toolcraft template app acceptance coverage", () => {
2536
3155
  schemaHasProductSurface(),
2537
3156
  "Product readiness requires product surface: controls, layers, timeline, canvasContent, or acceptance coverage.",
2538
3157
  ).toBe(true);
3158
+ expect(
3159
+ starterSchema.panels.controls,
3160
+ "Generated product apps must define a controls panel so runtime Setup, product controls, background, export settings, and sticky export actions are visible.",
3161
+ ).toBeTruthy();
3162
+ expect(starterSchema.panels.controls?.sections[0]?.title).toBe("Setup");
2539
3163
  return;
2540
3164
  }
2541
3165
 
@@ -2589,6 +3213,247 @@ describe("Toolcraft template app acceptance coverage", () => {
2589
3213
  expect(getAgentWorklogValidationErrors(worklogSource)).toEqual([]);
2590
3214
  });
2591
3215
 
3216
+ it("requires product worklogs to record storyboard evidence for video references", () => {
3217
+ const videoWorklogWithoutStudy = `
3218
+ # Implementation Worklog
3219
+
3220
+ ## Status
3221
+
3222
+ Mode: product
3223
+
3224
+ ## Decision Trail
3225
+
3226
+ ### Iteration 1 — Product build
3227
+
3228
+ - Request: Build a product from a motion reference.
3229
+ - Task type: Toolcraft product build.
3230
+ - User-visible result: The product renders the requested motion.
3231
+ - Source/reference checked: User prompt and /fixtures/reference-motion/ref.mp4 video.
3232
+ - Reference inputs: /fixtures/reference-motion/ref.mp4 video reference.
3233
+ - Docs/contracts read: workflow, assembly, acceptance, performance.
3234
+ - Contract rules applied: runtime-shell-required and acceptance-product-observable.
3235
+ - Decision: Use a Toolcraft renderer.
3236
+ - Alternatives rejected: Raw DOM because renderer output needs canvas behavior.
3237
+ - State/output mapping: Runtime values drive canvasContent.
3238
+ - Files changed: src/app/app-schema.ts and src/app/product-renderer.tsx.
3239
+ - Verification: pnpm verify:final passed; browser performance checkpoint used agent-browser.
3240
+ - Skipped checks: None.
3241
+ - Risks: None.
3242
+
3243
+ ## Decisions
3244
+
3245
+ ### Renderer
3246
+ - Decision: Use SVG.
3247
+ - Reason: Vector output.
3248
+ - Evidence: src/app/product-renderer.tsx.
3249
+
3250
+ ### Timeline
3251
+ - Decision: Use playback.
3252
+ - Reason: Motion output.
3253
+ - Evidence: src/app/app-schema.ts.
3254
+
3255
+ ### Layers
3256
+ - Decision: No layers.
3257
+ - Reason: Single output.
3258
+ - Evidence: src/app/app-schema.ts.
3259
+
3260
+ ### Controls
3261
+ - Decision: Use schema controls.
3262
+ - Reason: Runtime owns controls.
3263
+ - Evidence: src/app/app-schema.ts.
3264
+
3265
+ ### Export
3266
+ - Decision: Use panel actions.
3267
+ - Reason: Product output export.
3268
+ - Evidence: src/routes/index.tsx.
3269
+
3270
+ ### Performance
3271
+ - Decision: Measure motion.
3272
+ - Reason: Motion can lag.
3273
+ - Evidence: src/app/app-performance.ts.
3274
+
3275
+ ## Evidence
3276
+
3277
+ - Source reviewed: user prompt, ref.mp4, src/app/product-renderer.tsx.
3278
+ - Contract applied: Toolcraft workflow.
3279
+ - Evidence: pnpm verify:final passed.
3280
+
3281
+ ## Verification
3282
+
3283
+ - Run: pnpm verify:final passed.
3284
+ - Run: browser performance checkpoint passed with agent-browser.
3285
+
3286
+ ## Risks
3287
+
3288
+ - None: no known risk.
3289
+ `;
3290
+
3291
+ expect(getAgentWorklogValidationErrors(videoWorklogWithoutStudy)).toContain(
3292
+ "agent-worklog.md cites a video reference, screen recording, GIF, extracted frames, or contact sheet; record a Video Reference Study with storyboard frames and frame-to-frame transition analysis.",
3293
+ );
3294
+ });
3295
+
3296
+ it("accepts product worklogs that record video reference storyboard and transition evidence", () => {
3297
+ const videoWorklogWithStudy = `
3298
+ # Implementation Worklog
3299
+
3300
+ ## Status
3301
+
3302
+ Mode: product
3303
+
3304
+ ## Decision Trail
3305
+
3306
+ ### Iteration 1 — Product build
3307
+
3308
+ - Request: Build a product from a motion reference.
3309
+ - Task type: Toolcraft product build.
3310
+ - User-visible result: The product renders the requested motion.
3311
+ - Source/reference checked: User prompt, /fixtures/reference-motion/ref.mp4 video, extracted frames, and contact sheet.
3312
+ - Reference inputs: /fixtures/reference-motion/ref.mp4 video reference, extracted frames, contact sheet.
3313
+ - Docs/contracts read: workflow, assembly, acceptance, performance.
3314
+ - Contract rules applied: video-reference-analysis and acceptance-product-observable.
3315
+ - Decision: Use a Toolcraft renderer.
3316
+ - Alternatives rejected: Single screenshot implementation because the video behavior changes frame to frame.
3317
+ - State/output mapping: Runtime values drive canvasContent.
3318
+ - Files changed: src/app/app-schema.ts and src/app/product-renderer.tsx.
3319
+ - Verification: pnpm verify:final passed; browser performance checkpoint used agent-browser.
3320
+ - Skipped checks: None.
3321
+ - Risks: None.
3322
+
3323
+ ## Decisions
3324
+
3325
+ ### Renderer
3326
+ - Decision: Use SVG.
3327
+ - Reason: Vector output.
3328
+ - Evidence: src/app/product-renderer.tsx.
3329
+
3330
+ ### Timeline
3331
+ - Decision: Use playback.
3332
+ - Reason: Motion output.
3333
+ - Evidence: src/app/app-schema.ts.
3334
+
3335
+ ### Layers
3336
+ - Decision: No layers.
3337
+ - Reason: Single output.
3338
+ - Evidence: src/app/app-schema.ts.
3339
+
3340
+ ### Controls
3341
+ - Decision: Use schema controls.
3342
+ - Reason: Runtime owns controls.
3343
+ - Evidence: src/app/app-schema.ts.
3344
+
3345
+ ### Export
3346
+ - Decision: Use panel actions.
3347
+ - Reason: Product output export.
3348
+ - Evidence: src/routes/index.tsx.
3349
+
3350
+ ### Performance
3351
+ - Decision: Measure motion.
3352
+ - Reason: Motion can lag.
3353
+ - Evidence: src/app/app-performance.ts.
3354
+
3355
+ ### Video Reference Study
3356
+ - Decision: Implement from storyboard frames and frame-to-frame transition analysis.
3357
+ - Reason: The video reference defines behavior, not only a static visual state.
3358
+ - Evidence: extracted frames f000/f012/f024/f036 and transition analysis between adjacent frames.
3359
+
3360
+ ## Evidence
3361
+
3362
+ - Source reviewed: user prompt, ref.mp4, extracted frames, contact sheet, src/app/product-renderer.tsx.
3363
+ - Contract applied: Toolcraft workflow and video-reference-analysis.
3364
+ - Evidence: pnpm verify:final passed.
3365
+
3366
+ ## Verification
3367
+
3368
+ - Run: pnpm verify:final passed.
3369
+ - Run: browser performance checkpoint passed with agent-browser.
3370
+
3371
+ ## Risks
3372
+
3373
+ - None: no known risk.
3374
+ `;
3375
+
3376
+ expect(getAgentWorklogValidationErrors(videoWorklogWithStudy)).toEqual([]);
3377
+ });
3378
+
3379
+ it("does not treat ordinary video export worklog evidence as a video reference", () => {
3380
+ const videoExportWorklog = `
3381
+ # Implementation Worklog
3382
+
3383
+ ## Status
3384
+
3385
+ Mode: product
3386
+
3387
+ ## Decision Trail
3388
+
3389
+ ### Iteration 1 — Product build
3390
+
3391
+ - Request: Build an animated product with export.
3392
+ - Task type: Toolcraft product build.
3393
+ - User-visible result: The product renders and exports video.
3394
+ - Source/reference checked: User prompt, app schema, export handler, and browser export behavior.
3395
+ - Reference inputs: None.
3396
+ - Docs/contracts read: workflow, assembly, acceptance, performance.
3397
+ - Contract rules applied: output-export-required and acceptance-product-observable.
3398
+ - Decision: Use Toolcraft export helpers.
3399
+ - Alternatives rejected: Blob-only checks because metadata must prove exported video behavior.
3400
+ - State/output mapping: Runtime timeline state drives preview and export frames.
3401
+ - Files changed: src/app/app-schema.ts and src/app/export.ts.
3402
+ - Verification: pnpm verify:final passed; browser performance checkpoint used agent-browser.
3403
+ - Skipped checks: None.
3404
+ - Risks: None.
3405
+
3406
+ ## Decisions
3407
+
3408
+ ### Renderer
3409
+ - Decision: Use Canvas 2D.
3410
+ - Reason: Product output is simple animated geometry.
3411
+ - Evidence: src/app/product-renderer.tsx.
3412
+
3413
+ ### Timeline
3414
+ - Decision: Use playback.
3415
+ - Reason: Export Video requires runtime timeline time.
3416
+ - Evidence: src/app/app-schema.ts.
3417
+
3418
+ ### Layers
3419
+ - Decision: No layers.
3420
+ - Reason: Single output.
3421
+ - Evidence: src/app/app-schema.ts.
3422
+
3423
+ ### Controls
3424
+ - Decision: Use schema controls.
3425
+ - Reason: Runtime owns controls.
3426
+ - Evidence: src/app/app-schema.ts.
3427
+
3428
+ ### Export
3429
+ - Decision: Expose Export PNG and Export Video.
3430
+ - Reason: Animated products need still and video output.
3431
+ - Evidence: src/app/export.ts.
3432
+
3433
+ ### Performance
3434
+ - Decision: Measure export and animation.
3435
+ - Reason: Video export can be expensive.
3436
+ - Evidence: src/app/app-performance.ts.
3437
+
3438
+ ## Evidence
3439
+
3440
+ - Source reviewed: user prompt, app schema, export handler, browser export behavior.
3441
+ - Contract applied: Toolcraft workflow.
3442
+ - Evidence: pnpm verify:final passed.
3443
+
3444
+ ## Verification
3445
+
3446
+ - Run: pnpm verify:final passed.
3447
+ - Run: browser performance checkpoint passed with agent-browser.
3448
+
3449
+ ## Risks
3450
+
3451
+ - None: no known risk.
3452
+ `;
3453
+
3454
+ expect(getAgentWorklogValidationErrors(videoExportWorklog)).toEqual([]);
3455
+ });
3456
+
2592
3457
  it("rejects stale or incomplete product worklogs", () => {
2593
3458
  const staleWorklog = `
2594
3459
  # Implementation Worklog
@@ -2700,7 +3565,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2700
3565
 
2701
3566
  - Decision: Run viewport-stability and animation drag budgets.
2702
3567
  - Reason: Animation and canvas movement are performance-sensitive.
2703
- - Evidence: pnpm verify:perf browser run.
3568
+ - Evidence: agent-browser performance checkpoint.
2704
3569
 
2705
3570
  ## Decision Trail
2706
3571
 
@@ -2710,13 +3575,14 @@ describe("Toolcraft template app acceptance coverage", () => {
2710
3575
  - Task type: Renderer, schema, timeline, export, and performance.
2711
3576
  - User-visible result: The canvas renders the animated poster, the top timeline controls playback, and sticky footer actions export video and PNG.
2712
3577
  - Source/reference checked: User prompt plus the generated product renderer requirements.
3578
+ - Reference inputs: None.
2713
3579
  - Docs/contracts read: workflow.md, assembly-workflow.md, schema-reference.md, component-rules.md, renderer-technique.md, performance.md.
2714
3580
  - Contract rules applied: runtime-shell-required, controls-layout-heuristics, timeline-enabled-behavior, output-export-required, performance-coverage-levels.
2715
3581
  - Decision: Use SVG foreground with Canvas 2D background and Toolcraft playback timeline.
2716
3582
  - Alternatives rejected: DOM-only output because dense animation can jank; app-local timeline because playback UI is runtime-owned.
2717
3583
  - State/output mapping: Background and motion controls update runtime values consumed by product-renderer.tsx; timeline state drives rendered frame time; footer actions read the same renderer state for export.
2718
3584
  - Files changed: src/app/app-schema.ts, src/app/app-performance.ts, src/app/product-renderer.tsx, e2e/app-controls.spec.ts.
2719
- - Verification: pnpm verify:quick; pnpm verify:perf; browser timeline pause/resume and canvas drag stress.
3585
+ - Verification: pnpm verify:quick; agent-browser performance checkpoint; browser timeline pause/resume and canvas drag stress.
2720
3586
  - Skipped checks: None.
2721
3587
  - Risks: None; browser and performance gates cover the touched surfaces.
2722
3588
 
@@ -2728,7 +3594,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2728
3594
  ## Verification
2729
3595
 
2730
3596
  - Run: pnpm verify:quick
2731
- - Run: pnpm verify:perf
3597
+ - Run: agent-browser performance checkpoint
2732
3598
  - Browser: timeline pause/resume and canvas drag stress.
2733
3599
 
2734
3600
  ## Risks
@@ -2739,8 +3605,8 @@ describe("Toolcraft template app acceptance coverage", () => {
2739
3605
  expect(getAgentWorklogValidationErrors(productWorklog)).toEqual([]);
2740
3606
  });
2741
3607
 
2742
- it("rejects product worklogs without an actionable decision trail", () => {
2743
- const worklog = `
3608
+ it("rejects product worklogs that report incomplete required performance gates", () => {
3609
+ const incompletePerfWorklog = `
2744
3610
  # Implementation Worklog
2745
3611
 
2746
3612
  ## Status
@@ -2751,33 +3617,507 @@ describe("Toolcraft template app acceptance coverage", () => {
2751
3617
 
2752
3618
  ### Renderer
2753
3619
 
2754
- - Decision: Use WebGL.
2755
- - Reason: Dense animation needs batched rendering.
2756
- - Evidence: src/app/app-performance.ts.
3620
+ - Decision: Use Canvas 2D custom renderer.
3621
+ - Reason: The product output is a rasterized text effect.
3622
+ - Evidence: src/app/product-renderer.tsx and src/app/app-performance.ts.
2757
3623
 
2758
3624
  ### Timeline
2759
3625
 
2760
- - Decision: Use playback timeline.
2761
- - Reason: The product exposes play and scrub.
3626
+ - Decision: Use Toolcraft playback timeline.
3627
+ - Reason: The product is animated and exportable.
2762
3628
  - Evidence: appSchema.panels.timeline.mode.
2763
3629
 
2764
3630
  ### Layers
2765
3631
 
2766
3632
  - Decision: Do not enable layers.
2767
- - Reason: The product is single-output.
3633
+ - Reason: The product has one generated output.
2768
3634
  - Evidence: appSchema.panels.layers is omitted.
2769
3635
 
2770
3636
  ### Controls
2771
3637
 
2772
3638
  - Decision: Group controls by product entity.
2773
- - Reason: Sections map to product behavior.
2774
- - Evidence: src/app/app-schema.ts.
3639
+ - Reason: Controls map to text, motion, effects, and export.
3640
+ - Evidence: src/app/app-schema.ts control targets.
2775
3641
 
2776
3642
  ### Export
2777
3643
 
2778
3644
  - Decision: Provide PNG and video export.
2779
- - Reason: The app is animated.
2780
- - Evidence: panelActions.
3645
+ - Reason: The product is animated but still frames are useful.
3646
+ - Evidence: panelActions plus export handlers.
3647
+
3648
+ ### Performance
3649
+
3650
+ - Decision: Declare performance scenarios for renderer and controls.
3651
+ - Reason: Canvas effects are performance-sensitive.
3652
+ - Evidence: src/app/app-performance.ts.
3653
+
3654
+ ## Decision Trail
3655
+
3656
+ ### Iteration 1 — Initial product build
3657
+
3658
+ - Request: Build an animated raster text app.
3659
+ - Task type: Renderer, schema, timeline, export, and performance.
3660
+ - User-visible result: The app renders the animated text effect and exposes export actions.
3661
+ - Source/reference checked: User prompt and product renderer requirements.
3662
+ - Reference inputs: None.
3663
+ - Docs/contracts read: workflow.md, assembly-workflow.md, schema-reference.md, performance.md.
3664
+ - Contract rules applied: runtime-shell-required, output-export-required, performance-coverage-levels.
3665
+ - Decision: Use Canvas 2D renderer and Toolcraft timeline.
3666
+ - Alternatives rejected: DOM-only output because the final effect is pixel-composited.
3667
+ - State/output mapping: Schema values feed product-renderer.tsx and panel actions read the same state for export.
3668
+ - Files changed: src/app/app-schema.ts, src/app/product-renderer.tsx, src/app/app-performance.ts.
3669
+ - Verification: pnpm test failed; pnpm verify:perf not complete.
3670
+ - Skipped checks: pnpm verify:final and pnpm verify:perf are not complete yet.
3671
+ - Risks: Product contract tests still need updates before final delivery.
3672
+
3673
+ ## Evidence
3674
+
3675
+ - Source reviewed: src/app/app-schema.ts, src/app/app-performance.ts, e2e/app-controls.spec.ts.
3676
+ - Contract applied: performance-coverage-levels.
3677
+
3678
+ ## Verification
3679
+
3680
+ - Run: pnpm test failed.
3681
+ - Run: pnpm verify:perf not complete.
3682
+
3683
+ ## Risks
3684
+
3685
+ - Risk: Required checks are not complete.
3686
+ `;
3687
+
3688
+ expect(getAgentWorklogValidationErrors(incompletePerfWorklog)).toEqual(
3689
+ expect.arrayContaining([
3690
+ "agent-worklog.md required checks must be passed before final delivery; do not report failed, incomplete, pending, or blocked verification.",
3691
+ "agent-worklog.md must not list required checks as skipped unless they are explicitly not required for a post-first-working non-performance edit.",
3692
+ "agent-worklog.md Verification must record the browser performance checkpoint for first working product delivery: use agent-browser when available, or `pnpm verify:perf` with an explicit fallback reason when no agent browser or CI/non-agent automation is used.",
3693
+ ]),
3694
+ );
3695
+ });
3696
+
3697
+ it("requires first working product worklogs to record a browser performance checkpoint", () => {
3698
+ const missingPerfWorklog = `
3699
+ # Implementation Worklog
3700
+
3701
+ ## Status
3702
+
3703
+ Mode: product
3704
+
3705
+ ## Decisions
3706
+
3707
+ ### Renderer
3708
+
3709
+ - Decision: Use SVG renderer.
3710
+ - Reason: The product output is vector-native.
3711
+ - Evidence: src/app/product-renderer.tsx.
3712
+
3713
+ ### Timeline
3714
+
3715
+ - Decision: Do not enable timeline.
3716
+ - Reason: The product is still output.
3717
+ - Evidence: appSchema.panels.timeline is omitted.
3718
+
3719
+ ### Layers
3720
+
3721
+ - Decision: Do not enable layers.
3722
+ - Reason: The product edits one output.
3723
+ - Evidence: appSchema.panels.layers is omitted.
3724
+
3725
+ ### Controls
3726
+
3727
+ - Decision: Group controls by product entity.
3728
+ - Reason: Each section maps to visible output behavior.
3729
+ - Evidence: src/app/app-schema.ts.
3730
+
3731
+ ### Export
3732
+
3733
+ - Decision: Provide PNG export.
3734
+ - Reason: The product is a still image.
3735
+ - Evidence: panelActions.
3736
+
3737
+ ### Performance
3738
+
3739
+ - Decision: Cover visible control responsiveness.
3740
+ - Reason: First working product delivery must prove performance coverage.
3741
+ - Evidence: app-performance.ts.
3742
+
3743
+ ## Decision Trail
3744
+
3745
+ ### Iteration 1 — Initial product build
3746
+
3747
+ - Request: Build a still vector poster app.
3748
+ - Task type: Schema, renderer, export, acceptance, and performance.
3749
+ - User-visible result: The canvas renders the poster and exports PNG.
3750
+ - Source/reference checked: User prompt.
3751
+ - Reference inputs: None.
3752
+ - Docs/contracts read: workflow.md, assembly-workflow.md, performance.md.
3753
+ - Contract rules applied: runtime-shell-required, output-export-required, performance-coverage-levels.
3754
+ - Decision: Use SVG renderer with Toolcraft controls.
3755
+ - Alternatives rejected: Canvas output because vector output must stay crisp.
3756
+ - State/output mapping: Schema values feed product-renderer.tsx and export uses runtime state.
3757
+ - Files changed: src/app/app-schema.ts, src/app/product-renderer.tsx, src/app/app-performance.ts.
3758
+ - Verification: pnpm verify:quick; pnpm verify:final.
3759
+ - Skipped checks: None.
3760
+ - Risks: None; final functional gate passed.
3761
+
3762
+ ## Evidence
3763
+
3764
+ - Source reviewed: src/app/app-schema.ts, src/app/product-renderer.tsx.
3765
+ - Contract applied: runtime-shell-required, performance-coverage-levels.
3766
+
3767
+ ## Verification
3768
+
3769
+ - Run: pnpm verify:quick
3770
+ - Run: pnpm verify:final
3771
+
3772
+ ## Risks
3773
+
3774
+ - None: functional checks passed.
3775
+ `;
3776
+
3777
+ expect(getAgentWorklogValidationErrors(missingPerfWorklog)).toContain(
3778
+ "agent-worklog.md Verification must record the browser performance checkpoint for first working product delivery: use agent-browser when available, or `pnpm verify:perf` with an explicit fallback reason when no agent browser or CI/non-agent automation is used.",
3779
+ );
3780
+ });
3781
+
3782
+ it("recognizes supported browser performance checkpoint evidence line formats", () => {
3783
+ expect(
3784
+ isPassedAgentBrowserPerfRunLine(
3785
+ "- Browser: performance checkpoint runner agent-browser",
3786
+ ),
3787
+ ).toBe(true);
3788
+ expect(
3789
+ isPassedAgentBrowserPerfRunLine(
3790
+ "- Verification: agent-browser perf checkpoint",
3791
+ ),
3792
+ ).toBe(true);
3793
+ expect(
3794
+ isPassedPlaywrightFallbackPerfRunLine(
3795
+ "- Verification: playwright-fallback browser performance checkpoint",
3796
+ ),
3797
+ ).toBe(true);
3798
+ expect(isPassedPlaywrightFallbackPerfRunLine("- Run: pnpm verify:perf")).toBe(true);
3799
+ expect(
3800
+ isPassedPlaywrightFallbackPerfRunLine(
3801
+ "- Run: pnpm verify:perf not complete",
3802
+ ),
3803
+ ).toBe(false);
3804
+ expect(
3805
+ isPassedAgentBrowserPerfRunLine(
3806
+ "- Verification: agent-browser perf checkpoint planned after build",
3807
+ ),
3808
+ ).toBe(false);
3809
+ expect(
3810
+ isPassedAgentBrowserPerfRunLine(
3811
+ "- Browser: will run agent-browser performance checkpoint",
3812
+ ),
3813
+ ).toBe(false);
3814
+ expect(
3815
+ isPassedPlaywrightFallbackPerfRunLine("- Run: pnpm verify:perf not run"),
3816
+ ).toBe(false);
3817
+ });
3818
+
3819
+ it("recognizes explicit agent-browser fallback reason aliases", () => {
3820
+ expect(
3821
+ fallbackPerfReasonPattern.test("- Fallback reason: agent-browser-unavailable."),
3822
+ ).toBe(true);
3823
+ expect(
3824
+ fallbackPerfReasonPattern.test("- Fallback reason: agent-browser unavailable."),
3825
+ ).toBe(true);
3826
+ });
3827
+
3828
+ it("allows Playwright fallback perf only with an explicit fallback reason", () => {
3829
+ const playwrightFallbackWorklog = `
3830
+ # Implementation Worklog
3831
+
3832
+ ## Status
3833
+
3834
+ Mode: product
3835
+
3836
+ ## Decisions
3837
+
3838
+ ### Renderer
3839
+
3840
+ - Decision: Use SVG renderer.
3841
+ - Reason: The product output is vector-native.
3842
+ - Evidence: src/app/product-renderer.tsx.
3843
+
3844
+ ### Timeline
3845
+
3846
+ - Decision: Do not enable timeline.
3847
+ - Reason: The product is still output.
3848
+ - Evidence: appSchema.panels.timeline is omitted.
3849
+
3850
+ ### Layers
3851
+
3852
+ - Decision: Do not enable layers.
3853
+ - Reason: The product edits one output.
3854
+ - Evidence: appSchema.panels.layers is omitted.
3855
+
3856
+ ### Controls
3857
+
3858
+ - Decision: Group controls by product entity.
3859
+ - Reason: Each section maps to visible output behavior.
3860
+ - Evidence: src/app/app-schema.ts.
3861
+
3862
+ ### Export
3863
+
3864
+ - Decision: Provide PNG export.
3865
+ - Reason: The product is a still image.
3866
+ - Evidence: panelActions.
3867
+
3868
+ ### Performance
3869
+
3870
+ - Decision: Use Playwright fallback for the browser performance checkpoint.
3871
+ - Reason: The run is CI/non-agent automation with no agent browser available.
3872
+ - Evidence: pnpm verify:perf.
3873
+
3874
+ ## Decision Trail
3875
+
3876
+ ### Iteration 1 — Initial product build
3877
+
3878
+ - Request: Build a still vector poster app.
3879
+ - Task type: Schema, renderer, export, acceptance, and performance.
3880
+ - User-visible result: The canvas renders the poster and exports PNG.
3881
+ - Source/reference checked: User prompt.
3882
+ - Reference inputs: None.
3883
+ - Docs/contracts read: workflow.md, assembly-workflow.md, performance.md.
3884
+ - Contract rules applied: runtime-shell-required, output-export-required, performance-coverage-levels.
3885
+ - Decision: Use SVG renderer with Toolcraft controls.
3886
+ - Alternatives rejected: Canvas output because vector output must stay crisp.
3887
+ - State/output mapping: Schema values feed product-renderer.tsx and export uses runtime state.
3888
+ - Files changed: src/app/app-schema.ts, src/app/product-renderer.tsx, src/app/app-performance.ts.
3889
+ - Verification: pnpm verify:quick; pnpm verify:final; pnpm verify:perf.
3890
+ - Skipped checks: None.
3891
+ - Risks: None; final functional and fallback performance gates passed.
3892
+
3893
+ ## Evidence
3894
+
3895
+ - Source reviewed: src/app/app-schema.ts, src/app/product-renderer.tsx.
3896
+ - Contract applied: runtime-shell-required, performance-coverage-levels.
3897
+
3898
+ ## Verification
3899
+
3900
+ - Run: pnpm verify:quick
3901
+ - Run: pnpm verify:final
3902
+ - Run: pnpm verify:perf
3903
+ - Fallback reason: no agent browser available in CI/non-agent automation.
3904
+
3905
+ ## Risks
3906
+
3907
+ - None: fallback performance evidence is recorded with reason.
3908
+ `;
3909
+
3910
+ expect(getAgentWorklogValidationErrors(playwrightFallbackWorklog)).toEqual([]);
3911
+ });
3912
+
3913
+ it("rejects Playwright fallback perf without an explicit fallback reason", () => {
3914
+ const missingFallbackReasonWorklog = `
3915
+ # Implementation Worklog
3916
+
3917
+ ## Status
3918
+
3919
+ Mode: product
3920
+
3921
+ ## Decisions
3922
+
3923
+ ### Renderer
3924
+
3925
+ - Decision: Use SVG renderer.
3926
+ - Reason: The product output is vector-native.
3927
+ - Evidence: src/app/product-renderer.tsx.
3928
+
3929
+ ### Timeline
3930
+
3931
+ - Decision: Do not enable timeline.
3932
+ - Reason: The product is still output.
3933
+ - Evidence: appSchema.panels.timeline is omitted.
3934
+
3935
+ ### Layers
3936
+
3937
+ - Decision: Do not enable layers.
3938
+ - Reason: The product edits one output.
3939
+ - Evidence: appSchema.panels.layers is omitted.
3940
+
3941
+ ### Controls
3942
+
3943
+ - Decision: Group controls by product entity.
3944
+ - Reason: Each section maps to visible output behavior.
3945
+ - Evidence: src/app/app-schema.ts.
3946
+
3947
+ ### Export
3948
+
3949
+ - Decision: Provide PNG export.
3950
+ - Reason: The product is a still image.
3951
+ - Evidence: panelActions.
3952
+
3953
+ ### Performance
3954
+
3955
+ - Decision: Run performance coverage.
3956
+ - Reason: First working product delivery must prove performance.
3957
+ - Evidence: pnpm verify:perf.
3958
+
3959
+ ## Decision Trail
3960
+
3961
+ ### Iteration 1 — Initial product build
3962
+
3963
+ - Request: Build a still vector poster app.
3964
+ - Task type: Schema, renderer, export, acceptance, and performance.
3965
+ - User-visible result: The canvas renders the poster and exports PNG.
3966
+ - Source/reference checked: User prompt.
3967
+ - Reference inputs: None.
3968
+ - Docs/contracts read: workflow.md, assembly-workflow.md, performance.md.
3969
+ - Contract rules applied: runtime-shell-required, output-export-required, performance-coverage-levels.
3970
+ - Decision: Use SVG renderer with Toolcraft controls.
3971
+ - Alternatives rejected: Canvas output because vector output must stay crisp.
3972
+ - State/output mapping: Schema values feed product-renderer.tsx and export uses runtime state.
3973
+ - Files changed: src/app/app-schema.ts, src/app/product-renderer.tsx, src/app/app-performance.ts.
3974
+ - Verification: pnpm verify:quick; pnpm verify:final; pnpm verify:perf.
3975
+ - Skipped checks: None.
3976
+ - Risks: None; final functional and performance gates passed.
3977
+
3978
+ ## Evidence
3979
+
3980
+ - Source reviewed: src/app/app-schema.ts, src/app/product-renderer.tsx.
3981
+ - Contract applied: runtime-shell-required, performance-coverage-levels.
3982
+
3983
+ ## Verification
3984
+
3985
+ - Run: pnpm verify:quick
3986
+ - Run: pnpm verify:final
3987
+ - Run: pnpm verify:perf
3988
+
3989
+ ## Risks
3990
+
3991
+ - None: checks passed.
3992
+ `;
3993
+
3994
+ expect(getAgentWorklogValidationErrors(missingFallbackReasonWorklog)).toContain(
3995
+ "agent-worklog.md Verification must record the browser performance checkpoint for first working product delivery: use agent-browser when available, or `pnpm verify:perf` with an explicit fallback reason when no agent browser or CI/non-agent automation is used.",
3996
+ );
3997
+ });
3998
+
3999
+ it("allows explicit post-first-working non-performance skips of the full performance suite", () => {
4000
+ const postFirstWorklog = `
4001
+ # Implementation Worklog
4002
+
4003
+ ## Status
4004
+
4005
+ Mode: product
4006
+
4007
+ ## Decisions
4008
+
4009
+ ### Renderer
4010
+
4011
+ - Decision: Keep the existing SVG renderer.
4012
+ - Reason: This pass changes copy only.
4013
+ - Evidence: No renderer files changed.
4014
+
4015
+ ### Timeline
4016
+
4017
+ - Decision: Keep timeline disabled.
4018
+ - Reason: The product remains still output.
4019
+ - Evidence: appSchema.panels.timeline is omitted.
4020
+
4021
+ ### Layers
4022
+
4023
+ - Decision: Keep layers disabled.
4024
+ - Reason: No layer workflow changed.
4025
+ - Evidence: appSchema.panels.layers is omitted.
4026
+
4027
+ ### Controls
4028
+
4029
+ - Decision: Keep existing controls.
4030
+ - Reason: This pass only updates labels.
4031
+ - Evidence: src/app/app-schema.ts labels.
4032
+
4033
+ ### Export
4034
+
4035
+ - Decision: Keep PNG export.
4036
+ - Reason: Export behavior did not change.
4037
+ - Evidence: panelActions unchanged.
4038
+
4039
+ ### Performance
4040
+
4041
+ - Decision: Skip full perf for this post-first-working copy pass.
4042
+ - Reason: No renderer, workload, viewport, animation, export, or performance-sensitive control changed.
4043
+ - Evidence: Prior iteration recorded agent-browser performance checkpoint.
4044
+
4045
+ ## Decision Trail
4046
+
4047
+ ### Iteration 2 — Copy refinement
4048
+
4049
+ - Request: Rename two labels in an already delivered app.
4050
+ - Task type: Tier 0 copy update after the first working version.
4051
+ - User-visible result: The labels are clearer and product output is unchanged.
4052
+ - Source/reference checked: Existing app schema.
4053
+ - Reference inputs: None.
4054
+ - Docs/contracts read: workflow.md and component-rules.md.
4055
+ - Contract rules applied: controls-layout-heuristics.
4056
+ - Decision: Keep renderer, export, and performance matrix unchanged.
4057
+ - Alternatives rejected: Running full perf because this is a post-first-working non-performance edit.
4058
+ - State/output mapping: Label text changes do not alter runtime values or renderer state.
4059
+ - Files changed: src/app/app-schema.ts.
4060
+ - Verification: pnpm verify:quick.
4061
+ - Skipped checks: full performance checkpoint not required for this post-first-working non-performance feature loop.
4062
+ - Risks: None; output behavior is unchanged.
4063
+
4064
+ ## Evidence
4065
+
4066
+ - Source reviewed: src/app/app-schema.ts.
4067
+ - Contract applied: controls-layout-heuristics.
4068
+
4069
+ ## Verification
4070
+
4071
+ - Run: pnpm verify:quick
4072
+ - Skipped checks: full performance checkpoint not required for this post-first-working non-performance feature loop.
4073
+
4074
+ ## Risks
4075
+
4076
+ - None: no performance-sensitive behavior changed.
4077
+ `;
4078
+
4079
+ expect(getAgentWorklogValidationErrors(postFirstWorklog)).toEqual([]);
4080
+ });
4081
+
4082
+ it("rejects product worklogs without an actionable decision trail", () => {
4083
+ const worklog = `
4084
+ # Implementation Worklog
4085
+
4086
+ ## Status
4087
+
4088
+ Mode: product
4089
+
4090
+ ## Decisions
4091
+
4092
+ ### Renderer
4093
+
4094
+ - Decision: Use WebGL.
4095
+ - Reason: Dense animation needs batched rendering.
4096
+ - Evidence: src/app/app-performance.ts.
4097
+
4098
+ ### Timeline
4099
+
4100
+ - Decision: Use playback timeline.
4101
+ - Reason: The product exposes play and scrub.
4102
+ - Evidence: appSchema.panels.timeline.mode.
4103
+
4104
+ ### Layers
4105
+
4106
+ - Decision: Do not enable layers.
4107
+ - Reason: The product is single-output.
4108
+ - Evidence: appSchema.panels.layers is omitted.
4109
+
4110
+ ### Controls
4111
+
4112
+ - Decision: Group controls by product entity.
4113
+ - Reason: Sections map to product behavior.
4114
+ - Evidence: src/app/app-schema.ts.
4115
+
4116
+ ### Export
4117
+
4118
+ - Decision: Provide PNG and video export.
4119
+ - Reason: The app is animated.
4120
+ - Evidence: panelActions.
2781
4121
 
2782
4122
  ### Performance
2783
4123
 
@@ -2815,6 +4155,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2815
4155
  expect.arrayContaining([
2816
4156
  'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "User-visible result:".',
2817
4157
  'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Source/reference checked:".',
4158
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Reference inputs:".',
2818
4159
  'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Contract rules applied:".',
2819
4160
  'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Alternatives rejected:".',
2820
4161
  'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "State/output mapping:".',
@@ -2994,8 +4335,24 @@ describe("Toolcraft template app acceptance coverage", () => {
2994
4335
  ).toBeGreaterThanOrEqual(2);
2995
4336
  expect(
2996
4337
  productImplementationSource,
2997
- "Video export must use getToolcraftRetinaExportSize so video dimensions follow the same retina export contract.",
2998
- ).toMatch(/\bgetToolcraftRetinaExportSize\b/);
4338
+ "Video export must use getToolcraftVideoExportSize so current and 4K dimensions follow the standard encoder-safe export contract.",
4339
+ ).toMatch(/\bgetToolcraftVideoExportSize\b/);
4340
+ expect(
4341
+ sourceUsesVideoExportSizeHelper(productImplementationSource),
4342
+ "Video export must use getToolcraftVideoExportSize instead of custom video dimension math.",
4343
+ ).toBe(true);
4344
+ expect(
4345
+ sourceHasUnsafeVideoLongEdgeSizing(productImplementationSource),
4346
+ "Video export must not use PNG-style 4096px long-edge sizing for 4K video; getToolcraftVideoExportSize fits inside 3840x2160.",
4347
+ ).toBe(false);
4348
+ expect(
4349
+ sourceHasCaptureStreamBeforeCanvasSizing(productImplementationSource),
4350
+ "When using captureStream, video export must set canvas width/height before captureStream/MediaRecorder setup.",
4351
+ ).toBe(false);
4352
+ expect(
4353
+ sourceHandlesVideoRecorderOrEncoderErrors(productImplementationSource),
4354
+ "Video export must reject MediaRecorder/VideoEncoder errors instead of returning corrupt video blobs.",
4355
+ ).toBe(true);
2999
4356
  expect(
3000
4357
  productImplementationSource,
3001
4358
  "Video export must use shouldIncludeToolcraftExportBackground so PNG transparency does not remove the video background.",
@@ -3074,6 +4431,10 @@ describe("Toolcraft template app acceptance coverage", () => {
3074
4431
  sourceHasVideoDurationMetadataCoverage(browserTestSources),
3075
4432
  "Video export browser coverage must load the exported blob as a <video>, wait for loadedmetadata, and compare video.duration with the edited timeline duration. blobSize/blobType checks alone do not prove timeline-length export.",
3076
4433
  ).toBe(true);
4434
+ expect(
4435
+ sourceHasVideoDimensionMetadataCoverage(browserTestSources),
4436
+ "Video export browser coverage must load exported video metadata and assert video.videoWidth/video.videoHeight for both Current and 4K resolution paths. Blob size or selected control values alone do not prove conversion dimensions.",
4437
+ ).toBe(true);
3077
4438
  if (hasMovOrProResFormat) {
3078
4439
  expect(
3079
4440
  sourceHasCustomMovOrProResEncoder(productImplementationSource),
@@ -3102,7 +4463,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3102
4463
  value: "reset",
3103
4464
  },
3104
4465
  {
3105
- icon: "export",
4466
+ icon: "upload-simple",
3106
4467
  label: "Export PNG",
3107
4468
  value: "export.png",
3108
4469
  },
@@ -3157,7 +4518,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3157
4518
  outputActions: {
3158
4519
  actions: [
3159
4520
  {
3160
- icon: "export",
4521
+ icon: "upload-simple",
3161
4522
  label: "Export PNG",
3162
4523
  value: "export.png",
3163
4524
  },
@@ -3226,7 +4587,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3226
4587
  outputActions: {
3227
4588
  actions: [
3228
4589
  {
3229
- icon: "export",
4590
+ icon: "upload-simple",
3230
4591
  label: "Export PNG",
3231
4592
  value: "export.png",
3232
4593
  },
@@ -3292,7 +4653,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3292
4653
  outputActions: {
3293
4654
  actions: [
3294
4655
  {
3295
- icon: "export",
4656
+ icon: "upload-simple",
3296
4657
  label: "Export PNG",
3297
4658
  value: "export.png",
3298
4659
  },
@@ -3359,7 +4720,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3359
4720
  outputActions: {
3360
4721
  actions: [
3361
4722
  {
3362
- icon: "export",
4723
+ icon: "upload-simple",
3363
4724
  label: "Export PNG",
3364
4725
  value: "export.png",
3365
4726
  },
@@ -3422,7 +4783,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3422
4783
  outputActions: {
3423
4784
  actions: [
3424
4785
  {
3425
- icon: "export",
4786
+ icon: "upload-simple",
3426
4787
  label: "Export PNG",
3427
4788
  value: "export.png",
3428
4789
  },
@@ -3487,7 +4848,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3487
4848
  outputActions: {
3488
4849
  actions: [
3489
4850
  {
3490
- icon: "export",
4851
+ icon: "upload-simple",
3491
4852
  label: "Export PNG",
3492
4853
  value: "export.png",
3493
4854
  },
@@ -3569,12 +4930,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3569
4930
  outputActions: {
3570
4931
  actions: [
3571
4932
  {
3572
- icon: "download",
4933
+ icon: "upload-simple",
3573
4934
  label: "Export Video",
3574
4935
  value: "export.video",
3575
4936
  },
3576
4937
  {
3577
- icon: "export",
4938
+ icon: "upload-simple",
3578
4939
  label: "Export PNG",
3579
4940
  value: "export.png",
3580
4941
  },
@@ -3588,7 +4949,6 @@ describe("Toolcraft template app acceptance coverage", () => {
3588
4949
  ],
3589
4950
  title: "Controls",
3590
4951
  },
3591
- timeline: { enabled: true, mode: "playback" },
3592
4952
  },
3593
4953
  });
3594
4954
 
@@ -3643,12 +5003,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3643
5003
  outputActions: {
3644
5004
  actions: [
3645
5005
  {
3646
- icon: "download",
5006
+ icon: "upload-simple",
3647
5007
  label: "Export Video",
3648
5008
  value: "export.video",
3649
5009
  },
3650
5010
  {
3651
- icon: "export",
5011
+ icon: "upload-simple",
3652
5012
  label: "Export PNG",
3653
5013
  value: "export.png",
3654
5014
  },
@@ -3662,7 +5022,6 @@ describe("Toolcraft template app acceptance coverage", () => {
3662
5022
  ],
3663
5023
  title: "Controls",
3664
5024
  },
3665
- timeline: { enabled: true, mode: "playback" },
3666
5025
  },
3667
5026
  });
3668
5027
  const imageFormatAcceptance = makeControlAcceptance("export.image.format", "select");
@@ -3714,7 +5073,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3714
5073
  );
3715
5074
  });
3716
5075
 
3717
- it("requires disabledWhen controls to reference a real target and prove disabled behavior", () => {
5076
+ it("rejects disabled product controls and requires visibleWhen for unavailable controls", () => {
3718
5077
  const schemaWithDisabledDependency = defineToolcraft({
3719
5078
  canvas: { enabled: true },
3720
5079
  panels: {
@@ -3764,12 +5123,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3764
5123
  "disabledWhen target distribution.mode does not match another schema control target",
3765
5124
  ),
3766
5125
  expect.stringContaining(
3767
- "uses disabledWhen and acceptance must prove the control becomes disabled/unavailable",
5126
+ "uses disabledWhen. Generated product panels should show only controls usable in the current state",
3768
5127
  ),
3769
5128
  ]),
3770
5129
  );
3771
5130
 
3772
- const schemaWithValidDependency = defineToolcraft({
5131
+ const schemaWithBranchDisabledDependency = defineToolcraft({
3773
5132
  canvas: { enabled: true },
3774
5133
  panels: {
3775
5134
  controls: {
@@ -3806,7 +5165,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3806
5165
  },
3807
5166
  },
3808
5167
  });
3809
- const errors = validateToolcraftAcceptanceCoverage(schemaWithValidDependency, [
5168
+ const branchErrors = validateToolcraftAcceptanceCoverage(schemaWithBranchDisabledDependency, [
3810
5169
  makeControlAcceptance("distribution.fillMode", "segmented"),
3811
5170
  {
3812
5171
  ...makeControlAcceptance("distribution.fillAmount", "slider"),
@@ -3816,10 +5175,48 @@ describe("Toolcraft template app acceptance coverage", () => {
3816
5175
  },
3817
5176
  ]);
3818
5177
 
3819
- expect(errors).not.toEqual(
5178
+ expect(branchErrors).toEqual(
5179
+ expect.arrayContaining([
5180
+ expect.stringContaining(
5181
+ "uses disabledWhen. Generated product panels should show only controls usable in the current state",
5182
+ ),
5183
+ ]),
5184
+ );
5185
+
5186
+ const schemaWithDisabledControl = defineToolcraft({
5187
+ canvas: { enabled: true },
5188
+ panels: {
5189
+ controls: {
5190
+ sections: [
5191
+ {
5192
+ controls: {
5193
+ fillAmount: {
5194
+ defaultValue: 50,
5195
+ disabled: true,
5196
+ label: "Fill level",
5197
+ max: 100,
5198
+ min: 0,
5199
+ target: "distribution.fillAmount",
5200
+ type: "slider",
5201
+ },
5202
+ },
5203
+ title: "Distribution",
5204
+ },
5205
+ ],
5206
+ title: "Controls",
5207
+ },
5208
+ },
5209
+ });
5210
+
5211
+ expect(
5212
+ validateToolcraftAcceptanceCoverage(schemaWithDisabledControl, [
5213
+ makeControlAcceptance("distribution.fillAmount", "slider"),
5214
+ ]),
5215
+ ).toEqual(
3820
5216
  expect.arrayContaining([
3821
- expect.stringContaining("disabledWhen target"),
3822
- expect.stringContaining("uses disabledWhen and acceptance must prove"),
5217
+ expect.stringContaining(
5218
+ "sets disabled: true. Generated product panels should show only controls usable in the current state",
5219
+ ),
3823
5220
  ]),
3824
5221
  );
3825
5222
  });
@@ -4654,6 +6051,189 @@ describe("Toolcraft template app acceptance coverage", () => {
4654
6051
  );
4655
6052
  });
4656
6053
 
6054
+ it("requires a reference feature inventory before accepting a reference-runtime-clone", () => {
6055
+ const referenceAcceptance = [
6056
+ ...starterAcceptance,
6057
+ makeReferenceCoverageAcceptance("reference.canvasSizing", "canvas-sizing"),
6058
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6059
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6060
+ ];
6061
+
6062
+ expect(
6063
+ validateToolcraftAcceptanceCoverage(starterSchema, referenceAcceptance, {
6064
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6065
+ mode: "reference-runtime-clone",
6066
+ referenceName: "legacy badge wall",
6067
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6068
+ sourceOfTruth: "reference-runtime",
6069
+ }),
6070
+ ).toEqual(
6071
+ expect.arrayContaining([
6072
+ "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.",
6073
+ 'reference-runtime-clone behaviorCoverage "canvas-sizing" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceCoverage.',
6074
+ 'reference-runtime-clone behaviorCoverage "control-mapping" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceCoverage.',
6075
+ 'reference-runtime-clone behaviorCoverage "renderer-state" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceCoverage.',
6076
+ "reference.canvasSizing declares reference coverage but is not mapped from referenceFeatureInventory. Every reference acceptance row must correspond to an inventoried reference feature.",
6077
+ ]),
6078
+ );
6079
+ });
6080
+
6081
+ it("requires reference study evidence before accepting a reference-runtime-clone", () => {
6082
+ expect(
6083
+ validateToolcraftAcceptanceCoverage(
6084
+ starterSchema,
6085
+ [
6086
+ ...starterAcceptance,
6087
+ makeReferenceCoverageAcceptance("reference.canvasSizing", "canvas-sizing"),
6088
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6089
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6090
+ ],
6091
+ {
6092
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6093
+ mode: "reference-runtime-clone",
6094
+ referenceFeatureInventory: makeReferenceFeatureInventory(),
6095
+ referenceName: "legacy badge wall",
6096
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6097
+ sourceOfTruth: "reference-runtime",
6098
+ },
6099
+ ),
6100
+ ).toContain(
6101
+ "reference-runtime-clone transferMode must declare referenceStudy proving the reference was inspected and, when runnable or reconstructable, run or restored locally before implementation.",
6102
+ );
6103
+ });
6104
+
6105
+ it("requires a concrete blocker for source-only reference study", () => {
6106
+ expect(
6107
+ validateToolcraftAcceptanceCoverage(
6108
+ starterSchema,
6109
+ [
6110
+ ...starterAcceptance,
6111
+ makeReferenceCoverageAcceptance("reference.canvasSizing", "canvas-sizing"),
6112
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6113
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6114
+ ],
6115
+ {
6116
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6117
+ mode: "reference-runtime-clone",
6118
+ referenceFeatureInventory: makeReferenceFeatureInventory(),
6119
+ referenceName: "legacy badge wall",
6120
+ referenceStudy: {
6121
+ ...referenceStudyEvidence,
6122
+ sourceOnlyReason: "Source review was enough.",
6123
+ status: "source-inspection-only",
6124
+ },
6125
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6126
+ sourceOfTruth: "reference-runtime",
6127
+ },
6128
+ ),
6129
+ ).toContain(
6130
+ "referenceStudy.sourceOnlyReason must state the concrete blocker that made running or restoring the reference unavailable.",
6131
+ );
6132
+ });
6133
+
6134
+ it("rejects reference feature inventory items that are not backed by reference acceptance coverage", () => {
6135
+ const ordinaryControlAcceptance = makeControlAcceptance("appearance.opacity", "slider");
6136
+
6137
+ expect(
6138
+ validateToolcraftAcceptanceCoverage(
6139
+ starterSchema,
6140
+ [
6141
+ ...starterAcceptance,
6142
+ ordinaryControlAcceptance,
6143
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6144
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6145
+ ],
6146
+ {
6147
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6148
+ mode: "reference-runtime-clone",
6149
+ referenceFeatureInventory: [
6150
+ {
6151
+ acceptanceId: "appearance.opacity",
6152
+ behaviorEvidence:
6153
+ "Observed the reference browser output preserve its canvas dimensions.",
6154
+ featureName: "Canvas sizing",
6155
+ id: "canvas-sizing",
6156
+ referenceBehavior:
6157
+ "The reference renderer owns output sizing and canvas dimensions.",
6158
+ sourceEvidence: "Inspected reference renderer sizing source.",
6159
+ status: "ported",
6160
+ toolcraftMapping:
6161
+ "Toolcraft editable-output sizing preserves the reference dimensions.",
6162
+ },
6163
+ ...makeReferenceFeatureInventory().slice(1),
6164
+ ],
6165
+ referenceName: "legacy badge wall",
6166
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6167
+ sourceOfTruth: "reference-runtime",
6168
+ },
6169
+ ),
6170
+ ).toEqual(
6171
+ expect.arrayContaining([
6172
+ 'referenceFeatureInventory "canvas-sizing" acceptanceId "appearance.opacity" must point to an acceptance entry with referenceCoverage or referenceTimelineCoverage.',
6173
+ 'reference-runtime-clone behaviorCoverage "canvas-sizing" must be represented in referenceFeatureInventory by an item whose acceptanceId points to that referenceCoverage.',
6174
+ ]),
6175
+ );
6176
+ });
6177
+
6178
+ it("requires feature-level behavior evidence for reference inventory items", () => {
6179
+ expect(
6180
+ validateToolcraftAcceptanceCoverage(
6181
+ starterSchema,
6182
+ [
6183
+ ...starterAcceptance,
6184
+ makeReferenceCoverageAcceptance("reference.canvasSizing", "canvas-sizing"),
6185
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6186
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6187
+ ],
6188
+ {
6189
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6190
+ mode: "reference-runtime-clone",
6191
+ referenceFeatureInventory: [
6192
+ { ...makeReferenceFeatureInventory()[0], behaviorEvidence: "" },
6193
+ ...makeReferenceFeatureInventory().slice(1),
6194
+ ],
6195
+ referenceName: "legacy badge wall",
6196
+ referenceStudy: referenceStudyEvidence,
6197
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6198
+ sourceOfTruth: "reference-runtime",
6199
+ },
6200
+ ),
6201
+ ).toContain(
6202
+ 'referenceFeatureInventory "canvas-sizing" must include behaviorEvidence from the original, restored, or source-only reference study proving this feature was observed before Toolcraft mapping.',
6203
+ );
6204
+ });
6205
+
6206
+ it("requires explicit user evidence before marking reference behavior intentionally changed", () => {
6207
+ expect(
6208
+ validateToolcraftAcceptanceCoverage(
6209
+ starterSchema,
6210
+ [
6211
+ ...starterAcceptance,
6212
+ makeReferenceCoverageAcceptance("reference.canvasSizing", "canvas-sizing"),
6213
+ makeReferenceCoverageAcceptance("reference.controlMapping", "control-mapping"),
6214
+ makeReferenceCoverageAcceptance("reference.rendererState", "renderer-state"),
6215
+ ],
6216
+ {
6217
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6218
+ mode: "reference-runtime-clone",
6219
+ referenceFeatureInventory: [
6220
+ {
6221
+ ...makeReferenceFeatureInventory()[0],
6222
+ status: "intentionally-changed",
6223
+ userApprovedChangeReason: "Cleaner implementation.",
6224
+ },
6225
+ ...makeReferenceFeatureInventory().slice(1),
6226
+ ],
6227
+ referenceName: "legacy badge wall",
6228
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6229
+ sourceOfTruth: "reference-runtime",
6230
+ },
6231
+ ),
6232
+ ).toContain(
6233
+ 'referenceFeatureInventory "canvas-sizing" userApprovedChangeReason must cite explicit user approval or redesign/change-request evidence.',
6234
+ );
6235
+ });
6236
+
4657
6237
  it("rejects reference-runtime-clone apps that disable the Toolcraft canvas shell", () => {
4658
6238
  const schemaWithoutCanvas = defineToolcraft({
4659
6239
  canvas: {
@@ -4788,7 +6368,9 @@ describe("Toolcraft template app acceptance coverage", () => {
4788
6368
  {
4789
6369
  behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
4790
6370
  mode: "reference-runtime-clone",
6371
+ referenceFeatureInventory: makeReferenceFeatureInventory(),
4791
6372
  referenceName: "legacy badge wall",
6373
+ referenceStudy: referenceStudyEvidence,
4792
6374
  referenceTimeline: { behaviorCoverage: [], mode: "none" },
4793
6375
  sourceOfTruth: "reference-runtime",
4794
6376
  },
@@ -4811,41 +6393,165 @@ describe("Toolcraft template app acceptance coverage", () => {
4811
6393
  sourceOfTruth: "reference-runtime",
4812
6394
  }),
4813
6395
  ).toContain(
4814
- 'reference-runtime-clone transport behaviorCoverage "pause-resume" requires referenceTimeline mode "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline"; mode "none" is only for references with no user-facing transport behavior.',
6396
+ 'reference-runtime-clone transport behaviorCoverage "pause-resume" requires referenceTimeline mode "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline"; mode "none" is only for references with no user-facing transport behavior.',
6397
+ );
6398
+ });
6399
+
6400
+ it("rejects reference clones that hide restart transport behind timeline none", () => {
6401
+ expect(
6402
+ validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
6403
+ behaviorCoverage: [
6404
+ "canvas-sizing",
6405
+ "control-mapping",
6406
+ "renderer-state",
6407
+ "restart",
6408
+ "time-progress",
6409
+ ],
6410
+ mode: "reference-runtime-clone",
6411
+ referenceName: "legacy restart animation",
6412
+ referenceTimeline: { behaviorCoverage: [], mode: "none" },
6413
+ sourceOfTruth: "reference-runtime",
6414
+ }),
6415
+ ).toContain(
6416
+ 'reference-runtime-clone transport behaviorCoverage "restart", "time-progress" requires referenceTimeline mode "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline"; mode "none" is only for references with no user-facing transport behavior.',
6417
+ );
6418
+ });
6419
+
6420
+ it("requires playback reference timelines to declare concrete behavior coverage", () => {
6421
+ expect(
6422
+ validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
6423
+ behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
6424
+ mode: "reference-runtime-clone",
6425
+ referenceName: "legacy playback animation",
6426
+ referenceTimeline: { behaviorCoverage: [], mode: "toolcraft-playback" },
6427
+ sourceOfTruth: "reference-runtime",
6428
+ }),
6429
+ ).toContain(
6430
+ 'referenceTimeline mode "toolcraft-playback" must list the concrete timeline transport behaviors in behaviorCoverage.',
6431
+ );
6432
+ });
6433
+
6434
+ it("requires Toolcraft reference playback timelines to declare loop duration provenance", () => {
6435
+ const schemaWithPlaybackTimeline = {
6436
+ ...starterSchema,
6437
+ panels: {
6438
+ ...starterSchema.panels,
6439
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
6440
+ },
6441
+ };
6442
+
6443
+ expect(
6444
+ validateToolcraftAcceptanceCoverage(
6445
+ schemaWithPlaybackTimeline,
6446
+ [
6447
+ ...starterAcceptance,
6448
+ {
6449
+ ...playbackTimelineAcceptance,
6450
+ id: "reference.timeline.playback",
6451
+ referenceTimelineCoverage: "playback",
6452
+ },
6453
+ ],
6454
+ {
6455
+ behaviorCoverage: [
6456
+ "canvas-sizing",
6457
+ "control-mapping",
6458
+ "renderer-state",
6459
+ "pause-resume",
6460
+ ],
6461
+ mode: "reference-runtime-clone",
6462
+ referenceName: "legacy playback animation",
6463
+ referenceTimeline: { behaviorCoverage: ["playback"], mode: "toolcraft-playback" },
6464
+ sourceOfTruth: "reference-runtime",
6465
+ },
6466
+ ),
6467
+ ).toContain(
6468
+ 'referenceTimeline mode "toolcraft-playback" must declare loopDuration with source, seconds, and evidence. Do not let runtime/template fallback duration such as 8s stand in for reference loop intent.',
4815
6469
  );
4816
6470
  });
4817
6471
 
4818
- it("rejects reference clones that hide restart transport behind timeline none", () => {
6472
+ it("requires Toolcraft reference timeline loop duration to match the timeline default", () => {
6473
+ const schemaWithPlaybackTimeline = {
6474
+ ...starterSchema,
6475
+ panels: {
6476
+ ...starterSchema.panels,
6477
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
6478
+ },
6479
+ };
6480
+
4819
6481
  expect(
4820
- validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
4821
- behaviorCoverage: [
4822
- "canvas-sizing",
4823
- "control-mapping",
4824
- "renderer-state",
4825
- "restart",
4826
- "time-progress",
6482
+ validateToolcraftAcceptanceCoverage(
6483
+ schemaWithPlaybackTimeline,
6484
+ [
6485
+ ...starterAcceptance,
6486
+ {
6487
+ ...playbackTimelineAcceptance,
6488
+ id: "reference.timeline.playback",
6489
+ referenceTimelineCoverage: "playback",
6490
+ },
4827
6491
  ],
4828
- mode: "reference-runtime-clone",
4829
- referenceName: "legacy restart animation",
4830
- referenceTimeline: { behaviorCoverage: [], mode: "none" },
4831
- sourceOfTruth: "reference-runtime",
4832
- }),
6492
+ {
6493
+ behaviorCoverage: [
6494
+ "canvas-sizing",
6495
+ "control-mapping",
6496
+ "renderer-state",
6497
+ "pause-resume",
6498
+ ],
6499
+ mode: "reference-runtime-clone",
6500
+ referenceName: "legacy playback animation",
6501
+ referenceTimeline: {
6502
+ behaviorCoverage: ["playback"],
6503
+ loopDuration: {
6504
+ evidence:
6505
+ "The reference app completes one seamless forward playback cycle over 6s.",
6506
+ seconds: 6,
6507
+ source: "reference",
6508
+ },
6509
+ mode: "toolcraft-playback",
6510
+ },
6511
+ sourceOfTruth: "reference-runtime",
6512
+ },
6513
+ ),
4833
6514
  ).toContain(
4834
- 'reference-runtime-clone transport behaviorCoverage "restart", "time-progress" requires referenceTimeline mode "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline"; mode "none" is only for references with no user-facing transport behavior.',
6515
+ "panels.timeline.defaultDurationSeconds (8) must match referenceTimeline.loopDuration.seconds (6).",
4835
6516
  );
4836
6517
  });
4837
6518
 
4838
- it("requires playback reference timelines to declare concrete behavior coverage", () => {
6519
+ it("requires Toolcraft reference keyframe timelines to declare loop duration provenance", () => {
6520
+ const schemaWithKeyframesTimeline = {
6521
+ ...starterSchema,
6522
+ panels: {
6523
+ ...starterSchema.panels,
6524
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
6525
+ },
6526
+ };
6527
+
4839
6528
  expect(
4840
- validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
4841
- behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
4842
- mode: "reference-runtime-clone",
4843
- referenceName: "legacy playback animation",
4844
- referenceTimeline: { behaviorCoverage: [], mode: "toolcraft-playback" },
4845
- sourceOfTruth: "reference-runtime",
4846
- }),
6529
+ validateToolcraftAcceptanceCoverage(
6530
+ schemaWithKeyframesTimeline,
6531
+ [
6532
+ ...starterAcceptance,
6533
+ playbackTimelineAcceptance,
6534
+ {
6535
+ ...keyframesTimelineAcceptance,
6536
+ id: "reference.timeline.keyframes",
6537
+ referenceTimelineCoverage: "keyframes",
6538
+ },
6539
+ ],
6540
+ {
6541
+ behaviorCoverage: [
6542
+ "canvas-sizing",
6543
+ "control-mapping",
6544
+ "renderer-state",
6545
+ "time-progress",
6546
+ ],
6547
+ mode: "reference-runtime-clone",
6548
+ referenceName: "legacy keyframe animation",
6549
+ referenceTimeline: { behaviorCoverage: ["keyframes"], mode: "toolcraft-keyframes" },
6550
+ sourceOfTruth: "reference-runtime",
6551
+ },
6552
+ ),
4847
6553
  ).toContain(
4848
- 'referenceTimeline mode "toolcraft-playback" must list the concrete timeline transport behaviors in behaviorCoverage.',
6554
+ 'referenceTimeline mode "toolcraft-keyframes" must declare loopDuration with source, seconds, and evidence. Do not let runtime/template fallback duration such as 8s stand in for reference loop intent.',
4849
6555
  );
4850
6556
  });
4851
6557
 
@@ -4885,7 +6591,7 @@ describe("Toolcraft template app acceptance coverage", () => {
4885
6591
  ],
4886
6592
  title: "Controls",
4887
6593
  },
4888
- timeline: { enabled: true, mode: "playback" as const },
6594
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
4889
6595
  },
4890
6596
  } as const;
4891
6597
 
@@ -5017,10 +6723,13 @@ describe("Toolcraft template app acceptance coverage", () => {
5017
6723
  });
5018
6724
 
5019
6725
  it("accepts explicit autonomous animation intent for decorative self-running output", () => {
5020
- const schemaWithAutonomousAnimation = {
5021
- ...starterSchema,
6726
+ const schemaWithAutonomousAnimation = defineToolcraft({
6727
+ canvas: {
6728
+ enabled: true,
6729
+ sizing: { mode: "editable-output" },
6730
+ upload: true,
6731
+ },
5022
6732
  panels: {
5023
- ...starterSchema.panels,
5024
6733
  controls: {
5025
6734
  sections: [
5026
6735
  {
@@ -5043,7 +6752,12 @@ describe("Toolcraft template app acceptance coverage", () => {
5043
6752
  },
5044
6753
  timeline: undefined,
5045
6754
  },
5046
- } as const;
6755
+ toolbar: {
6756
+ history: true,
6757
+ radar: true,
6758
+ zoom: true,
6759
+ },
6760
+ });
5047
6761
 
5048
6762
  expect(
5049
6763
  validateToolcraftAcceptanceCoverage(
@@ -5069,12 +6783,109 @@ describe("Toolcraft template app acceptance coverage", () => {
5069
6783
  ).toEqual([]);
5070
6784
  });
5071
6785
 
6786
+ it("requires a top timeline when video export exists", () => {
6787
+ const schemaWithVideoExportWithoutTimeline = defineToolcraft({
6788
+ canvas: {
6789
+ enabled: true,
6790
+ sizing: { mode: "editable-output" },
6791
+ },
6792
+ panels: {
6793
+ controls: {
6794
+ sections: [
6795
+ makeVideoExportSection(),
6796
+ {
6797
+ actionGroup: "secondary",
6798
+ controls: {
6799
+ outputActions: {
6800
+ actions: [
6801
+ {
6802
+ icon: "upload-simple",
6803
+ label: "Export Video",
6804
+ value: "export.video",
6805
+ },
6806
+ ],
6807
+ target: "actions.output",
6808
+ type: "panelActions",
6809
+ },
6810
+ },
6811
+ title: "Export",
6812
+ },
6813
+ ],
6814
+ title: "Controls",
6815
+ },
6816
+ },
6817
+ });
6818
+
6819
+ expect(validateToolcraftAcceptanceCoverage(schemaWithVideoExportWithoutTimeline)).toContain(
6820
+ '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.',
6821
+ );
6822
+ });
6823
+
6824
+ it("rejects autonomous animation intent when video export exists", () => {
6825
+ const schemaWithAutonomousVideoExport = defineToolcraft({
6826
+ canvas: {
6827
+ enabled: true,
6828
+ sizing: { mode: "editable-output" },
6829
+ },
6830
+ panels: {
6831
+ controls: {
6832
+ sections: [
6833
+ makeVideoExportSection(),
6834
+ {
6835
+ actionGroup: "secondary",
6836
+ controls: {
6837
+ outputActions: {
6838
+ actions: [
6839
+ {
6840
+ icon: "upload-simple",
6841
+ label: "Export Video",
6842
+ value: "export.video",
6843
+ },
6844
+ ],
6845
+ target: "actions.output",
6846
+ type: "panelActions",
6847
+ },
6848
+ },
6849
+ title: "Export",
6850
+ },
6851
+ ],
6852
+ title: "Controls",
6853
+ },
6854
+ },
6855
+ });
6856
+
6857
+ expect(
6858
+ validateToolcraftAcceptanceCoverage(
6859
+ schemaWithAutonomousVideoExport,
6860
+ [],
6861
+ {
6862
+ animationIntent: {
6863
+ behaviorCoverage: [
6864
+ "no-user-facing-transport",
6865
+ "no-play-pause",
6866
+ "no-scrub",
6867
+ "no-duration-control",
6868
+ "no-loop-control",
6869
+ "no-export-at-time",
6870
+ ],
6871
+ mode: "autonomous",
6872
+ reason:
6873
+ "The shader is decorative self-running output with no transport controls.",
6874
+ },
6875
+ mode: "new-toolcraft-app",
6876
+ },
6877
+ ),
6878
+ ).toContain(
6879
+ '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.',
6880
+ );
6881
+ });
6882
+
5072
6883
  it("rejects timeline animation intent when the timeline mode does not match", () => {
5073
6884
  const schemaWithPlaybackTimeline = {
5074
6885
  ...starterSchema,
5075
6886
  panels: {
5076
6887
  ...starterSchema.panels,
5077
- timeline: { enabled: true, mode: "playback" as const },
6888
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
5078
6889
  },
5079
6890
  };
5080
6891
 
@@ -5083,7 +6894,10 @@ describe("Toolcraft template app acceptance coverage", () => {
5083
6894
  schemaWithPlaybackTimeline,
5084
6895
  [playbackTimelineAcceptance],
5085
6896
  {
5086
- animationIntent: { mode: "timeline-keyframes" },
6897
+ animationIntent: {
6898
+ loopDuration: productDerivedEightSecondLoop,
6899
+ mode: "timeline-keyframes",
6900
+ },
5087
6901
  mode: "new-toolcraft-app",
5088
6902
  },
5089
6903
  ),
@@ -5199,7 +7013,36 @@ describe("Toolcraft template app acceptance coverage", () => {
5199
7013
  {
5200
7014
  behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
5201
7015
  mode: "reference-runtime-clone",
7016
+ referenceFeatureInventory: makeReferenceFeatureInventory([
7017
+ {
7018
+ acceptanceId: "reference.timeline.stateJump",
7019
+ behaviorEvidence:
7020
+ "Clicked the reference timeline state buttons and observed discrete renderer state jumps.",
7021
+ featureName: "Timeline state jump",
7022
+ id: "timeline-state-jump",
7023
+ referenceBehavior:
7024
+ "The reference timeline state buttons jump to discrete renderer states.",
7025
+ sourceEvidence: "Inspected reference timeline state button handlers.",
7026
+ status: "ported",
7027
+ toolcraftMapping:
7028
+ "The custom reference timeline control keeps the same state-jump behavior.",
7029
+ },
7030
+ {
7031
+ acceptanceId: "reference.timeline.trimRange",
7032
+ behaviorEvidence:
7033
+ "Dragged the reference trim handles and observed the active playback/render range change.",
7034
+ featureName: "Timeline trim range",
7035
+ id: "timeline-trim-range",
7036
+ referenceBehavior:
7037
+ "The reference trim handles edit the active playback/render range.",
7038
+ sourceEvidence: "Inspected reference trim handle drag behavior.",
7039
+ status: "ported",
7040
+ toolcraftMapping:
7041
+ "The custom reference timeline control keeps the same trim range behavior.",
7042
+ },
7043
+ ]),
5202
7044
  referenceName: "legacy state timeline",
7045
+ referenceStudy: referenceStudyEvidence,
5203
7046
  referenceTimeline: {
5204
7047
  behaviorCoverage: ["state-jump", "trim-range"],
5205
7048
  mode: "custom-reference-timeline",
@@ -5451,7 +7294,7 @@ describe("Toolcraft template app acceptance coverage", () => {
5451
7294
  }
5452
7295
  });
5453
7296
 
5454
- it("requires each browser acceptance entry to point at a Playwright test", () => {
7297
+ it("requires each browser acceptance entry to point at a fallback Playwright test", () => {
5455
7298
  const browserTestSources = readBrowserTestSources();
5456
7299
 
5457
7300
  for (const entry of starterAcceptance) {
@@ -5461,7 +7304,7 @@ describe("Toolcraft template app acceptance coverage", () => {
5461
7304
 
5462
7305
  expect(
5463
7306
  browserTestSources,
5464
- `${entry.id} must be backed by a Playwright test named "${entry.browserTestName}".`,
7307
+ `${entry.id} must be backed by a fallback Playwright test named "${entry.browserTestName}".`,
5465
7308
  ).toContain(entry.browserTestName);
5466
7309
 
5467
7310
  if (entry.timelineCoverage === "playback" && acceptanceCoversTimelineDurationEdit(entry)) {
@@ -5538,10 +7381,13 @@ describe("Toolcraft template app acceptance coverage", () => {
5538
7381
  });
5539
7382
 
5540
7383
  it("accepts stepped continuous sliders without forcing the discrete visual variant", () => {
5541
- const schemaWithAmbiguousSlider = {
5542
- ...starterSchema,
7384
+ const schemaWithAmbiguousSlider = defineToolcraft({
7385
+ canvas: {
7386
+ enabled: true,
7387
+ sizing: { mode: "editable-output" },
7388
+ upload: true,
7389
+ },
5543
7390
  panels: {
5544
- ...starterSchema.panels,
5545
7391
  controls: {
5546
7392
  sections: [
5547
7393
  {
@@ -5564,7 +7410,12 @@ describe("Toolcraft template app acceptance coverage", () => {
5564
7410
  },
5565
7411
  timeline: undefined,
5566
7412
  },
5567
- };
7413
+ toolbar: {
7414
+ history: true,
7415
+ radar: true,
7416
+ zoom: true,
7417
+ },
7418
+ });
5568
7419
 
5569
7420
  expect(
5570
7421
  validateToolcraftAcceptanceCoverage(schemaWithAmbiguousSlider, [
@@ -6053,8 +7904,8 @@ describe("Toolcraft template app acceptance coverage", () => {
6053
7904
  controls: schema.panels.controls
6054
7905
  ? {
6055
7906
  ...schema.panels.controls,
6056
- sections: schema.panels.controls.sections.map((section, index) =>
6057
- index === 0 ? { ...section, title: undefined } : section,
7907
+ sections: schema.panels.controls.sections.map((section) =>
7908
+ section.title === "Shader" ? { ...section, title: undefined } : section,
6058
7909
  ),
6059
7910
  }
6060
7911
  : schema.panels.controls,
@@ -6067,7 +7918,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6067
7918
  ]),
6068
7919
  ).toEqual(
6069
7920
  expect.arrayContaining([
6070
- "untitled section 1 is missing a controls section title. Every visible controls-panel section must name the product entity, workflow stage, or behavior it edits.",
7921
+ "untitled section 2 is missing a controls section title. Every visible controls-panel section must name the product entity, workflow stage, or behavior it edits.",
6071
7922
  ]),
6072
7923
  );
6073
7924
  });
@@ -6816,7 +8667,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6816
8667
  ]),
6817
8668
  ).toEqual(
6818
8669
  expect.arrayContaining([
6819
- 'Image / sourceUpload is gated by visibleWhen target "source.mode" in Source, but it belongs to the same dependency group. Keep selectors and their dependent controls in one semantic section when they describe one product entity or branch; use visibleWhen/disabledWhen inside that section instead of splitting branch controls into their own section.',
8670
+ 'Image / sourceUpload is gated by visibleWhen target "source.mode" in Source, 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.',
6820
8671
  ]),
6821
8672
  );
6822
8673
  });
@@ -6869,7 +8720,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6869
8720
  ]),
6870
8721
  ).toEqual(
6871
8722
  expect.arrayContaining([
6872
- 'Library / libraryAsset is gated by visibleWhen target "shape.kind" in Shape, but it belongs to the same dependency group. Keep selectors and their dependent controls in one semantic section when they describe one product entity or branch; use visibleWhen/disabledWhen inside that section instead of splitting branch controls into their own section.',
8723
+ 'Library / libraryAsset is gated by visibleWhen target "shape.kind" in Shape, 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.',
6873
8724
  ]),
6874
8725
  );
6875
8726
  });
@@ -7221,49 +9072,131 @@ describe("Toolcraft template app acceptance coverage", () => {
7221
9072
  ],
7222
9073
  title: "Controls",
7223
9074
  },
7224
- },
7225
- });
7226
-
7227
- expect(
7228
- validateToolcraftAcceptanceCoverage(schemaWithUsefulColorLabels, [
7229
- makeControlAcceptance("palette.accent1", "color"),
7230
- makeControlAcceptance("palette.accent2", "color"),
7231
- makeControlAcceptance("palette.spread", "slider"),
7232
- makeControlAcceptance("object.fill", "color"),
7233
- makeControlAcceptance("object.stroke", "color"),
9075
+ },
9076
+ });
9077
+
9078
+ expect(
9079
+ validateToolcraftAcceptanceCoverage(schemaWithUsefulColorLabels, [
9080
+ makeControlAcceptance("palette.accent1", "color"),
9081
+ makeControlAcceptance("palette.accent2", "color"),
9082
+ makeControlAcceptance("palette.spread", "slider"),
9083
+ makeControlAcceptance("object.fill", "color"),
9084
+ makeControlAcceptance("object.stroke", "color"),
9085
+ ]),
9086
+ ).toEqual([]);
9087
+ });
9088
+
9089
+ it("requires mode selectors to appear before dependent controls", () => {
9090
+ const schemaWithLateModeSelector = {
9091
+ ...starterSchema,
9092
+ panels: {
9093
+ ...starterSchema.panels,
9094
+ controls: {
9095
+ sections: [
9096
+ {
9097
+ controls: {
9098
+ depth: {
9099
+ defaultValue: 0.64,
9100
+ label: "Depth",
9101
+ max: 1,
9102
+ min: 0,
9103
+ target: "shader.depth",
9104
+ type: "slider",
9105
+ variant: "continuous",
9106
+ },
9107
+ mode: {
9108
+ defaultValue: "liquid",
9109
+ label: "Mode",
9110
+ options: [
9111
+ { label: "Silk", value: "silk" },
9112
+ { label: "Liquid", value: "liquid" },
9113
+ { label: "Crystal", value: "crystal" },
9114
+ ],
9115
+ target: "shader.mode",
9116
+ type: "segmented",
9117
+ },
9118
+ },
9119
+ title: "Volume",
9120
+ },
9121
+ ],
9122
+ title: "Shader",
9123
+ },
9124
+ },
9125
+ };
9126
+
9127
+ expect(
9128
+ validateToolcraftAcceptanceCoverage(schemaWithLateModeSelector, [
9129
+ {
9130
+ automated: true,
9131
+ automatedTestName: "depth changes rendered output",
9132
+ browser: true,
9133
+ browserTestName: "browser: depth slider changes rendered output",
9134
+ componentType: "slider",
9135
+ evidence: "rendered-pixels",
9136
+ expectedObservable: "Changing Depth changes shader contrast.",
9137
+ fixture: "depth fixture",
9138
+ id: "shader.depth",
9139
+ kind: "control",
9140
+ target: "shader.depth",
9141
+ userAction: "Drag the Depth slider.",
9142
+ },
9143
+ {
9144
+ automated: true,
9145
+ automatedTestName: "mode changes rendered output",
9146
+ browser: true,
9147
+ browserTestName: "browser: mode selector changes rendered output",
9148
+ componentType: "segmented",
9149
+ evidence: "rendered-pixels",
9150
+ expectedObservable: "Changing Mode switches shader pattern.",
9151
+ fixture: "mode fixture",
9152
+ id: "shader.mode",
9153
+ kind: "control",
9154
+ optionCoverage: "each-visible-item",
9155
+ target: "shader.mode",
9156
+ userAction: "Select each Mode option.",
9157
+ },
7234
9158
  ]),
7235
- ).toEqual([]);
9159
+ ).toEqual(
9160
+ expect.arrayContaining([
9161
+ 'Volume / mode (shader.mode) has orderRole "mode" after depth (shader.depth) with orderRole "strength". Move mode/input/primary controls before dependent strength/detail/advanced controls or split them into an earlier section.',
9162
+ ]),
9163
+ );
7236
9164
  });
7237
9165
 
7238
- it("requires mode selectors to appear before dependent controls", () => {
7239
- const schemaWithLateModeSelector = {
7240
- ...starterSchema,
9166
+ it("accepts explicit order roles when selectors lead dependent controls", () => {
9167
+ const schemaWithOrderedControls = defineToolcraft({
9168
+ canvas: {
9169
+ enabled: true,
9170
+ sizing: { mode: "editable-output" },
9171
+ upload: true,
9172
+ },
7241
9173
  panels: {
7242
- ...starterSchema.panels,
7243
9174
  controls: {
7244
9175
  sections: [
7245
9176
  {
7246
9177
  controls: {
9178
+ blend: {
9179
+ defaultValue: "liquid",
9180
+ label: "Blend",
9181
+ options: [
9182
+ { label: "Silk", value: "silk" },
9183
+ { label: "Liquid", value: "liquid" },
9184
+ { label: "Crystal", value: "crystal" },
9185
+ ],
9186
+ orderRole: "mode" as const,
9187
+ target: "shader.blend",
9188
+ type: "segmented",
9189
+ },
7247
9190
  depth: {
7248
9191
  defaultValue: 0.64,
7249
9192
  label: "Depth",
7250
9193
  max: 1,
7251
9194
  min: 0,
9195
+ orderRole: "strength" as const,
7252
9196
  target: "shader.depth",
7253
9197
  type: "slider",
7254
9198
  variant: "continuous",
7255
9199
  },
7256
- mode: {
7257
- defaultValue: "liquid",
7258
- label: "Mode",
7259
- options: [
7260
- { label: "Silk", value: "silk" },
7261
- { label: "Liquid", value: "liquid" },
7262
- { label: "Crystal", value: "crystal" },
7263
- ],
7264
- target: "shader.mode",
7265
- type: "segmented",
7266
- },
7267
9200
  },
7268
9201
  title: "Volume",
7269
9202
  },
@@ -7271,10 +9204,34 @@ describe("Toolcraft template app acceptance coverage", () => {
7271
9204
  title: "Shader",
7272
9205
  },
7273
9206
  },
7274
- };
9207
+ toolbar: {
9208
+ history: true,
9209
+ radar: true,
9210
+ zoom: true,
9211
+ },
9212
+ });
7275
9213
 
9214
+ expect(getToolcraftControlOrderTargets(schemaWithOrderedControls)).toEqual([
9215
+ "shader.blend",
9216
+ "shader.depth",
9217
+ ]);
7276
9218
  expect(
7277
- validateToolcraftAcceptanceCoverage(schemaWithLateModeSelector, [
9219
+ validateToolcraftAcceptanceCoverage(schemaWithOrderedControls, [
9220
+ {
9221
+ automated: true,
9222
+ automatedTestName: "blend changes rendered output",
9223
+ browser: true,
9224
+ browserTestName: "browser: blend selector changes rendered output",
9225
+ componentType: "segmented",
9226
+ evidence: "rendered-pixels",
9227
+ expectedObservable: "Changing Blend switches shader pattern.",
9228
+ fixture: "blend fixture",
9229
+ id: "shader.blend",
9230
+ kind: "control",
9231
+ optionCoverage: "each-visible-item",
9232
+ target: "shader.blend",
9233
+ userAction: "Select each Blend option.",
9234
+ },
7278
9235
  {
7279
9236
  automated: true,
7280
9237
  automatedTestName: "depth changes rendered output",
@@ -7289,188 +9246,484 @@ describe("Toolcraft template app acceptance coverage", () => {
7289
9246
  target: "shader.depth",
7290
9247
  userAction: "Drag the Depth slider.",
7291
9248
  },
9249
+ ]),
9250
+ ).toEqual([]);
9251
+ });
9252
+
9253
+ it("requires overwide segmented controls to shorten labels or use select", () => {
9254
+ const schemaWithOverwideSegmentedControl = {
9255
+ ...starterSchema,
9256
+ panels: {
9257
+ ...starterSchema.panels,
9258
+ controls: {
9259
+ sections: [
9260
+ {
9261
+ controls: {
9262
+ preset: {
9263
+ defaultValue: "full-stack",
9264
+ label: "FX Preset",
9265
+ options: [
9266
+ { label: "Full Stack", value: "full-stack" },
9267
+ { label: "RGB Split", value: "rgb-split" },
9268
+ { label: "Shade", value: "shade" },
9269
+ { label: "Lines", value: "lines" },
9270
+ { label: "Off", value: "off" },
9271
+ ],
9272
+ orderRole: "mode" as const,
9273
+ target: "shader.fxPreset",
9274
+ type: "segmented",
9275
+ },
9276
+ },
9277
+ title: "Style",
9278
+ },
9279
+ ],
9280
+ title: "Shader",
9281
+ },
9282
+ },
9283
+ };
9284
+
9285
+ expect(
9286
+ validateToolcraftAcceptanceCoverage(schemaWithOverwideSegmentedControl, [
7292
9287
  {
7293
9288
  automated: true,
7294
- automatedTestName: "mode changes rendered output",
9289
+ automatedTestName: "fx preset changes rendered output",
7295
9290
  browser: true,
7296
- browserTestName: "browser: mode selector changes rendered output",
9291
+ browserTestName: "browser: fx preset selector changes rendered output",
7297
9292
  componentType: "segmented",
7298
9293
  evidence: "rendered-pixels",
7299
- expectedObservable: "Changing Mode switches shader pattern.",
7300
- fixture: "mode fixture",
7301
- id: "shader.mode",
9294
+ expectedObservable: "Changing FX Preset switches the shader preset.",
9295
+ fixture: "fx preset fixture",
9296
+ id: "shader.fxPreset",
7302
9297
  kind: "control",
7303
9298
  optionCoverage: "each-visible-item",
7304
- target: "shader.mode",
7305
- userAction: "Select each Mode option.",
9299
+ target: "shader.fxPreset",
9300
+ userAction: "Select each FX Preset option.",
7306
9301
  },
7307
9302
  ]),
7308
9303
  ).toEqual(
7309
9304
  expect.arrayContaining([
7310
- 'Volume / mode (shader.mode) has orderRole "mode" after depth (shader.depth) with orderRole "strength". Move mode/input/primary controls before dependent strength/detail/advanced controls or split them into an earlier section.',
9305
+ "Style / preset (shader.fxPreset) segmented controls must preserve cell padding: use at most 4 short options (max 9 characters per label and 24 total) or shorten labels first; if the compact names still exceed the budget, use a select dropdown instead.",
9306
+ ]),
9307
+ );
9308
+ });
9309
+
9310
+ it("requires timeline playback coverage when a playback timeline is enabled", () => {
9311
+ const schemaWithPlaybackTimeline = {
9312
+ ...starterSchema,
9313
+ panels: {
9314
+ ...starterSchema.panels,
9315
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9316
+ },
9317
+ };
9318
+
9319
+ expect(validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [])).toEqual(
9320
+ expect.arrayContaining([
9321
+ 'panels.timeline mode "playback" requires a runtime acceptance entry with timelineCoverage "playback" proving pause, scrub, duration/loop, and rendered-frame behavior.',
9322
+ ]),
9323
+ );
9324
+ });
9325
+
9326
+ it("requires playback timeline coverage to prove duration drives renderer progress", () => {
9327
+ const schemaWithPlaybackTimeline = {
9328
+ ...starterSchema,
9329
+ panels: {
9330
+ ...starterSchema.panels,
9331
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9332
+ },
9333
+ };
9334
+
9335
+ expect(
9336
+ validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
9337
+ {
9338
+ automated: true,
9339
+ automatedTestName: "timeline playback controls drive rendered output",
9340
+ browser: true,
9341
+ browserTestName: "browser: timeline playback controls drive rendered output",
9342
+ componentType: "timeline",
9343
+ evidence: "timeline-output",
9344
+ expectedObservable:
9345
+ "Pause, scrub, and playback update visible renderer output.",
9346
+ fixture: "timeline playback fixture",
9347
+ id: "timeline.playback",
9348
+ kind: "runtime",
9349
+ target: "timeline.playback",
9350
+ timelineCoverage: "playback",
9351
+ timelinePlaybackCoverage: ["pause-resume", "scrub", "rendered-frame"],
9352
+ userAction: "Pause, scrub, and resume timeline playback.",
9353
+ },
7311
9354
  ]),
9355
+ ).toContain(
9356
+ 'timeline.playback 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.',
9357
+ );
9358
+ });
9359
+
9360
+ it("requires timeline animation intent to declare loop duration provenance", () => {
9361
+ const schemaWithPlaybackTimeline = {
9362
+ ...starterSchema,
9363
+ panels: {
9364
+ ...starterSchema.panels,
9365
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9366
+ },
9367
+ };
9368
+
9369
+ expect(
9370
+ validateToolcraftAcceptanceCoverage(
9371
+ schemaWithPlaybackTimeline,
9372
+ [playbackTimelineAcceptance],
9373
+ {
9374
+ animationIntent: { mode: "timeline-playback" },
9375
+ mode: "new-toolcraft-app",
9376
+ } as unknown as ToolcraftTransferMode,
9377
+ ),
9378
+ ).toContain(
9379
+ 'starterTransferMode.animationIntent mode "timeline-playback" must declare loopDuration with source, seconds, and evidence. Do not let runtime/template fallback duration such as 8s stand in for product loop intent.',
9380
+ );
9381
+ });
9382
+
9383
+ it("requires playback timeline apps to declare matching playback animation intent", () => {
9384
+ const schemaWithPlaybackTimeline = {
9385
+ ...starterSchema,
9386
+ panels: {
9387
+ ...starterSchema.panels,
9388
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9389
+ },
9390
+ };
9391
+
9392
+ expect(
9393
+ validateToolcraftAcceptanceCoverage(
9394
+ schemaWithPlaybackTimeline,
9395
+ [playbackTimelineAcceptance],
9396
+ {
9397
+ animationIntent: { mode: "none" },
9398
+ mode: "new-toolcraft-app",
9399
+ },
9400
+ ),
9401
+ ).toContain(
9402
+ 'panels.timeline mode "playback" requires starterTransferMode.animationIntent mode "timeline-playback" with loopDuration provenance.',
9403
+ );
9404
+ });
9405
+
9406
+ it("requires keyframe timeline apps to declare matching keyframe animation intent", () => {
9407
+ const schemaWithKeyframesTimeline = {
9408
+ ...starterSchema,
9409
+ panels: {
9410
+ ...starterSchema.panels,
9411
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
9412
+ },
9413
+ };
9414
+
9415
+ expect(
9416
+ validateToolcraftAcceptanceCoverage(
9417
+ schemaWithKeyframesTimeline,
9418
+ [playbackTimelineAcceptance],
9419
+ {
9420
+ animationIntent: { mode: "none" },
9421
+ mode: "new-toolcraft-app",
9422
+ },
9423
+ ),
9424
+ ).toContain(
9425
+ 'panels.timeline mode "keyframes" requires starterTransferMode.animationIntent mode "timeline-keyframes" with loopDuration provenance.',
9426
+ );
9427
+ });
9428
+
9429
+ it("requires declared loop duration to match timeline default duration", () => {
9430
+ const schemaWithPlaybackTimeline = {
9431
+ ...starterSchema,
9432
+ panels: {
9433
+ ...starterSchema.panels,
9434
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9435
+ },
9436
+ };
9437
+
9438
+ expect(
9439
+ validateToolcraftAcceptanceCoverage(
9440
+ schemaWithPlaybackTimeline,
9441
+ [playbackTimelineAcceptance],
9442
+ {
9443
+ animationIntent: {
9444
+ loopDuration: {
9445
+ evidence:
9446
+ "The product timing model derives a six second forward animation cycle from the authored baseline.",
9447
+ seconds: 6,
9448
+ source: "product-derived",
9449
+ },
9450
+ mode: "timeline-playback",
9451
+ },
9452
+ mode: "new-toolcraft-app",
9453
+ },
9454
+ ),
9455
+ ).toContain(
9456
+ "panels.timeline.defaultDurationSeconds (8) must match starterTransferMode.animationIntent.loopDuration.seconds (6).",
7312
9457
  );
7313
9458
  });
7314
9459
 
7315
- it("accepts explicit order roles when selectors lead dependent controls", () => {
7316
- const schemaWithOrderedControls = {
9460
+ it("rejects runtime fallback evidence as a loop duration source", () => {
9461
+ const schemaWithPlaybackTimeline = {
7317
9462
  ...starterSchema,
7318
9463
  panels: {
7319
9464
  ...starterSchema.panels,
7320
- controls: {
7321
- sections: [
7322
- {
7323
- controls: {
7324
- blend: {
7325
- defaultValue: "liquid",
7326
- label: "Blend",
7327
- options: [
7328
- { label: "Silk", value: "silk" },
7329
- { label: "Liquid", value: "liquid" },
7330
- { label: "Crystal", value: "crystal" },
7331
- ],
7332
- orderRole: "mode" as const,
7333
- target: "shader.blend",
7334
- type: "segmented",
7335
- },
7336
- depth: {
7337
- defaultValue: 0.64,
7338
- label: "Depth",
7339
- max: 1,
7340
- min: 0,
7341
- orderRole: "strength" as const,
7342
- target: "shader.depth",
7343
- type: "slider",
7344
- variant: "continuous",
7345
- },
7346
- },
7347
- title: "Volume",
9465
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
9466
+ },
9467
+ };
9468
+
9469
+ expect(
9470
+ validateToolcraftAcceptanceCoverage(
9471
+ schemaWithPlaybackTimeline,
9472
+ [playbackTimelineAcceptance],
9473
+ {
9474
+ animationIntent: {
9475
+ loopDuration: {
9476
+ evidence: "Use the runtime default 8s fallback because Toolcraft starts there.",
9477
+ seconds: 8,
9478
+ source: "product-derived",
7348
9479
  },
7349
- ],
7350
- title: "Shader",
9480
+ mode: "timeline-playback",
9481
+ },
9482
+ mode: "new-toolcraft-app",
7351
9483
  },
9484
+ ),
9485
+ ).toContain(
9486
+ "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.",
9487
+ );
9488
+ });
9489
+
9490
+ it("requires video reference study when a new app cites video reference evidence", () => {
9491
+ const schemaWithPlaybackTimeline = {
9492
+ ...starterSchema,
9493
+ panels: {
9494
+ ...starterSchema.panels,
9495
+ timeline: { defaultDurationSeconds: 5.8, enabled: true, mode: "playback" as const },
7352
9496
  },
7353
9497
  };
7354
9498
 
7355
- expect(getToolcraftControlOrderTargets(schemaWithOrderedControls)).toEqual([
7356
- "shader.blend",
7357
- "shader.depth",
7358
- ]);
7359
9499
  expect(
7360
- validateToolcraftAcceptanceCoverage(schemaWithOrderedControls, [
7361
- playbackTimelineAcceptance,
9500
+ validateToolcraftAcceptanceCoverage(
9501
+ schemaWithPlaybackTimeline,
9502
+ [playbackTimelineAcceptance],
7362
9503
  {
7363
- automated: true,
7364
- automatedTestName: "blend changes rendered output",
7365
- browser: true,
7366
- browserTestName: "browser: blend selector changes rendered output",
7367
- componentType: "segmented",
7368
- evidence: "rendered-pixels",
7369
- expectedObservable: "Changing Blend switches shader pattern.",
7370
- fixture: "blend fixture",
7371
- id: "shader.blend",
7372
- kind: "control",
7373
- optionCoverage: "each-visible-item",
7374
- target: "shader.blend",
7375
- userAction: "Select each Blend option.",
9504
+ animationIntent: {
9505
+ loopDuration: {
9506
+ evidence:
9507
+ "The source reference video /fixtures/reference-motion/ref.mp4 was inspected for motion behavior.",
9508
+ seconds: 5.8,
9509
+ source: "reference",
9510
+ },
9511
+ mode: "timeline-playback",
9512
+ },
9513
+ mode: "new-toolcraft-app",
7376
9514
  },
9515
+ ),
9516
+ ).toContain(
9517
+ "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.",
9518
+ );
9519
+ });
9520
+
9521
+ it("does not require video reference study for ordinary video export intent", () => {
9522
+ const schemaWithPlaybackTimeline = defineToolcraft({
9523
+ canvas: {
9524
+ enabled: true,
9525
+ upload: true,
9526
+ },
9527
+ panels: {
9528
+ controls: {
9529
+ sections: [],
9530
+ title: "Controls",
9531
+ },
9532
+ timeline: { defaultDurationSeconds: 6, enabled: true, mode: "playback" as const },
9533
+ },
9534
+ toolbar: {
9535
+ history: true,
9536
+ radar: true,
9537
+ zoom: true,
9538
+ },
9539
+ });
9540
+
9541
+ expect(
9542
+ validateToolcraftAcceptanceCoverage(
9543
+ schemaWithPlaybackTimeline,
9544
+ [playbackTimelineAcceptance],
7377
9545
  {
7378
- automated: true,
7379
- automatedTestName: "depth changes rendered output",
7380
- browser: true,
7381
- browserTestName: "browser: depth slider changes rendered output",
7382
- componentType: "slider",
7383
- evidence: "rendered-pixels",
7384
- expectedObservable: "Changing Depth changes shader contrast.",
7385
- fixture: "depth fixture",
7386
- id: "shader.depth",
7387
- kind: "control",
7388
- target: "shader.depth",
7389
- userAction: "Drag the Depth slider.",
9546
+ animationIntent: {
9547
+ loopDuration: {
9548
+ evidence:
9549
+ "The product timing model derives a six second cycle for exported video output.",
9550
+ seconds: 6,
9551
+ source: "product-derived",
9552
+ },
9553
+ mode: "timeline-playback",
9554
+ },
9555
+ mode: "new-toolcraft-app",
7390
9556
  },
7391
- ]),
9557
+ ),
7392
9558
  ).toEqual([]);
7393
9559
  });
7394
9560
 
7395
- it("requires overwide segmented controls to shorten labels or use select", () => {
7396
- const schemaWithOverwideSegmentedControl = {
7397
- ...starterSchema,
9561
+ it("accepts a new app video reference only with storyboard and transition acceptance mapping", () => {
9562
+ const schemaWithPlaybackTimeline = defineToolcraft({
9563
+ canvas: {
9564
+ enabled: true,
9565
+ upload: true,
9566
+ },
7398
9567
  panels: {
7399
- ...starterSchema.panels,
7400
9568
  controls: {
7401
- sections: [
9569
+ sections: [],
9570
+ title: "Controls",
9571
+ },
9572
+ timeline: { defaultDurationSeconds: 5.8, enabled: true, mode: "playback" as const },
9573
+ },
9574
+ toolbar: {
9575
+ history: true,
9576
+ radar: true,
9577
+ zoom: true,
9578
+ },
9579
+ });
9580
+
9581
+ expect(
9582
+ validateToolcraftAcceptanceCoverage(
9583
+ schemaWithPlaybackTimeline,
9584
+ [playbackTimelineAcceptance, videoReferenceMotionAcceptance],
9585
+ {
9586
+ animationIntent: {
9587
+ loopDuration: {
9588
+ evidence:
9589
+ "The source reference timing comes from the inspected motion reference asset.",
9590
+ seconds: 5.8,
9591
+ source: "reference",
9592
+ },
9593
+ mode: "timeline-playback",
9594
+ },
9595
+ mode: "new-toolcraft-app",
9596
+ videoReferenceStudy: videoReferenceStudyEvidence,
9597
+ },
9598
+ ),
9599
+ ).toEqual([]);
9600
+ });
9601
+
9602
+ it("rejects incomplete video reference studies", () => {
9603
+ expect(
9604
+ validateToolcraftAcceptanceCoverage(starterSchema, [], {
9605
+ mode: "new-toolcraft-app",
9606
+ videoReferenceStudy: {
9607
+ acceptanceMapping: [
7402
9608
  {
7403
- controls: {
7404
- preset: {
7405
- defaultValue: "full-stack",
7406
- label: "FX Preset",
7407
- options: [
7408
- { label: "Full Stack", value: "full-stack" },
7409
- { label: "RGB Split", value: "rgb-split" },
7410
- { label: "Shade", value: "shade" },
7411
- { label: "Lines", value: "lines" },
7412
- { label: "Off", value: "off" },
7413
- ],
7414
- orderRole: "mode" as const,
7415
- target: "shader.fxPreset",
7416
- type: "segmented",
7417
- },
7418
- },
7419
- title: "Style",
9609
+ acceptanceId: "missing.acceptance",
9610
+ behavior: "Motion behavior",
9611
+ frameIds: ["f001"],
7420
9612
  },
7421
9613
  ],
7422
- title: "Shader",
9614
+ behaviorDecomposition: "",
9615
+ extractionEvidence: "",
9616
+ referenceLocation: "/fixtures/reference-motion/ref.mp4",
9617
+ storyboard: [
9618
+ {
9619
+ behaviorObservation: "Only one frame was inspected.",
9620
+ frameId: "f001",
9621
+ frameSource: "frames/frame_001.png",
9622
+ timeSeconds: 0,
9623
+ visualObservation: "A single static state.",
9624
+ },
9625
+ ],
9626
+ transitionAnalysis: [],
7423
9627
  },
9628
+ }),
9629
+ ).toEqual(
9630
+ expect.arrayContaining([
9631
+ "videoReferenceStudy.extractionEvidence must explain how frames were inspected or extracted before implementation.",
9632
+ "videoReferenceStudy.behaviorDecomposition must decompose the observed frame-to-frame changes into product behavior to preserve.",
9633
+ "videoReferenceStudy.storyboard must include at least four timecoded frames so the reference is studied as motion, not a single screenshot.",
9634
+ "videoReferenceStudy.transitionAnalysis must include at least three frame-to-frame deltas proving how behavior changes between sampled frames.",
9635
+ 'videoReferenceStudy.acceptanceMapping "Motion behavior" points to missing acceptanceId "missing.acceptance".',
9636
+ ]),
9637
+ );
9638
+ });
9639
+
9640
+ it("requires playback timeline coverage to prove seamless forward-only loop behavior follows edited duration", () => {
9641
+ const schemaWithPlaybackTimeline = {
9642
+ ...starterSchema,
9643
+ panels: {
9644
+ ...starterSchema.panels,
9645
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
7424
9646
  },
7425
9647
  };
7426
9648
 
7427
9649
  expect(
7428
- validateToolcraftAcceptanceCoverage(schemaWithOverwideSegmentedControl, [
9650
+ validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
7429
9651
  {
7430
9652
  automated: true,
7431
- automatedTestName: "fx preset changes rendered output",
9653
+ automatedTestName: "timeline duration edit drives renderer output",
7432
9654
  browser: true,
7433
- browserTestName: "browser: fx preset selector changes rendered output",
7434
- componentType: "segmented",
7435
- evidence: "rendered-pixels",
7436
- expectedObservable: "Changing FX Preset switches the shader preset.",
7437
- fixture: "fx preset fixture",
7438
- id: "shader.fxPreset",
7439
- kind: "control",
7440
- optionCoverage: "each-visible-item",
7441
- target: "shader.fxPreset",
7442
- userAction: "Select each FX Preset option.",
9655
+ browserTestName: "browser: timeline duration edit drives renderer output",
9656
+ componentType: "timeline",
9657
+ evidence: "timeline-output",
9658
+ expectedObservable:
9659
+ "Editing timeline duration changes the playback range and renderer follows state.timeline.durationSeconds.",
9660
+ fixture: "timeline playback fixture",
9661
+ id: "timeline.playback",
9662
+ kind: "runtime",
9663
+ target: "timeline.playback",
9664
+ timelineCoverage: "playback",
9665
+ timelinePlaybackCoverage: [
9666
+ "pause-resume",
9667
+ "scrub",
9668
+ "duration",
9669
+ "loop",
9670
+ "rendered-frame",
9671
+ ],
9672
+ userAction: "Edit timeline duration, scrub the range, pause, and resume playback.",
7443
9673
  },
7444
9674
  ]),
7445
- ).toEqual(
7446
- expect.arrayContaining([
7447
- "Style / preset (shader.fxPreset) segmented controls must preserve cell padding: use at most 4 short options (max 9 characters per label and 24 total) or shorten labels first; if the compact names still exceed the budget, use a select dropdown instead.",
7448
- ]),
9675
+ ).toContain(
9676
+ 'timeline.playback 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.',
7449
9677
  );
7450
9678
  });
7451
9679
 
7452
- it("requires timeline playback coverage when a playback timeline is enabled", () => {
9680
+ it("rejects incomplete seamless loop evidence that omits fallback direction checks", () => {
7453
9681
  const schemaWithPlaybackTimeline = {
7454
9682
  ...starterSchema,
7455
9683
  panels: {
7456
9684
  ...starterSchema.panels,
7457
- timeline: { enabled: true, mode: "playback" as const },
9685
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
7458
9686
  },
7459
9687
  };
7460
9688
 
7461
- expect(validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [])).toEqual(
7462
- expect.arrayContaining([
7463
- 'panels.timeline mode "playback" requires a runtime acceptance entry with timelineCoverage "playback" proving pause, scrub, duration/loop, and rendered-frame behavior.',
9689
+ expect(
9690
+ validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
9691
+ {
9692
+ automated: true,
9693
+ automatedTestName: "timeline duration edit verifies a seamless forward-only loop",
9694
+ browser: true,
9695
+ browserTestName: "browser: timeline duration edit verifies loop seam",
9696
+ componentType: "timeline",
9697
+ evidence: "timeline-output",
9698
+ expectedObservable:
9699
+ "Editing timeline duration keeps a seamless forward-only loop and stitches first and last frames.",
9700
+ fixture: "timeline playback fixture",
9701
+ id: "timeline.playback",
9702
+ kind: "runtime",
9703
+ target: "timeline.playback",
9704
+ timelineCoverage: "playback",
9705
+ timelinePlaybackCoverage: [
9706
+ "pause-resume",
9707
+ "scrub",
9708
+ "duration",
9709
+ "loop",
9710
+ "rendered-frame",
9711
+ ],
9712
+ userAction:
9713
+ "Edit timeline duration and verify first and last frames stitch with no mirror fallback.",
9714
+ },
7464
9715
  ]),
9716
+ ).toContain(
9717
+ 'timeline.playback 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.',
7465
9718
  );
7466
9719
  });
7467
9720
 
7468
- it("requires playback timeline coverage to prove duration drives renderer progress", () => {
9721
+ it("rejects generic seamless loop evidence that omits first-last frame stitching", () => {
7469
9722
  const schemaWithPlaybackTimeline = {
7470
9723
  ...starterSchema,
7471
9724
  panels: {
7472
9725
  ...starterSchema.panels,
7473
- timeline: { enabled: true, mode: "playback" as const },
9726
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
7474
9727
  },
7475
9728
  };
7476
9729
 
@@ -7478,24 +9731,31 @@ describe("Toolcraft template app acceptance coverage", () => {
7478
9731
  validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
7479
9732
  {
7480
9733
  automated: true,
7481
- automatedTestName: "timeline playback controls drive rendered output",
9734
+ automatedTestName: "timeline duration edit verifies a generic loop",
7482
9735
  browser: true,
7483
- browserTestName: "browser: timeline playback controls drive rendered output",
9736
+ browserTestName: "browser: timeline duration edit verifies a generic loop",
7484
9737
  componentType: "timeline",
7485
9738
  evidence: "timeline-output",
7486
9739
  expectedObservable:
7487
- "Pause, scrub, and playback update visible renderer output.",
9740
+ "Editing timeline duration keeps a seamless forward-only loop with no mirror, yoyo, ping-pong, or reverse fallback.",
7488
9741
  fixture: "timeline playback fixture",
7489
9742
  id: "timeline.playback",
7490
9743
  kind: "runtime",
7491
9744
  target: "timeline.playback",
7492
9745
  timelineCoverage: "playback",
7493
- timelinePlaybackCoverage: ["pause-resume", "scrub", "rendered-frame"],
7494
- userAction: "Pause, scrub, and resume timeline playback.",
9746
+ timelinePlaybackCoverage: [
9747
+ "pause-resume",
9748
+ "scrub",
9749
+ "duration",
9750
+ "loop",
9751
+ "rendered-frame",
9752
+ ],
9753
+ userAction:
9754
+ "Edit timeline duration and verify the loop remains forward-only with no mirror, yoyo, ping-pong, or reverse fallback.",
7495
9755
  },
7496
9756
  ]),
7497
9757
  ).toContain(
7498
- 'timeline.playback 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.',
9758
+ 'timeline.playback 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.',
7499
9759
  );
7500
9760
  });
7501
9761
 
@@ -7533,7 +9793,7 @@ describe("Toolcraft template app acceptance coverage", () => {
7533
9793
  ],
7534
9794
  title: "Controls",
7535
9795
  },
7536
- timeline: { enabled: true, mode: "keyframes" as const },
9796
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
7537
9797
  },
7538
9798
  };
7539
9799
 
@@ -7638,7 +9898,7 @@ describe("Toolcraft template app acceptance coverage", () => {
7638
9898
  ],
7639
9899
  title: "Controls",
7640
9900
  },
7641
- timeline: { enabled: true, mode: "keyframes" as const },
9901
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
7642
9902
  },
7643
9903
  };
7644
9904
 
@@ -7730,7 +9990,7 @@ describe("Toolcraft template app acceptance coverage", () => {
7730
9990
  ],
7731
9991
  title: "Controls",
7732
9992
  },
7733
- timeline: { enabled: true, mode: "keyframes" as const },
9993
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
7734
9994
  },
7735
9995
  };
7736
9996