@pixel-point/toolcraft 0.0.8 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -12,7 +12,14 @@ 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,
22
+ starterControlSectionInventory,
16
23
  starterProductReadiness,
17
24
  starterTransferMode,
18
25
  validateToolcraftAcceptanceCoverage,
@@ -100,6 +107,7 @@ const requiredAgentWorklogDecisionTrailFields = [
100
107
  "Task type",
101
108
  "User-visible result",
102
109
  "Source/reference checked",
110
+ "Reference inputs",
103
111
  "Docs/contracts read",
104
112
  "Contract rules applied",
105
113
  "Decision",
@@ -110,6 +118,10 @@ const requiredAgentWorklogDecisionTrailFields = [
110
118
  "Skipped checks",
111
119
  "Risks",
112
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;
113
125
 
114
126
  const playbackTimelineAcceptance: ToolcraftComponentAcceptance = {
115
127
  automated: true,
@@ -133,9 +145,133 @@ const playbackTimelineAcceptance: ToolcraftComponentAcceptance = {
133
145
  "rendered-frame",
134
146
  ],
135
147
  userAction:
136
- "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.",
137
149
  };
138
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
+
139
275
  function makeControlAcceptance(
140
276
  target: string,
141
277
  componentType: string,
@@ -156,7 +292,91 @@ function makeControlAcceptance(
156
292
  };
157
293
  }
158
294
 
159
- 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) {
160
380
  return defineToolcraft({
161
381
  canvas: { enabled: true },
162
382
  panels: {
@@ -185,13 +405,13 @@ function createSettingsTransferThresholdSchema(settingsTransfer: false | "auto"
185
405
  });
186
406
  }
187
407
 
188
- function createSettingsTransferThresholdAcceptance() {
408
+ function createMandatorySetupAcceptance() {
189
409
  return Array.from({ length: 12 }, (_, index) =>
190
410
  makeControlAcceptance(`settings.control${index}`, "slider"),
191
411
  );
192
412
  }
193
413
 
194
- function createSettingsTransferBelowThresholdWithCanvasSizeSchema() {
414
+ function createMandatorySetupWithCanvasSizeSchema() {
195
415
  return defineToolcraft({
196
416
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
197
417
  panels: {
@@ -213,14 +433,14 @@ function createSettingsTransferBelowThresholdWithCanvasSizeSchema() {
213
433
  title: "Transform",
214
434
  },
215
435
  ],
216
- title: "Below Threshold Settings",
436
+ title: "Runtime Setup Settings",
217
437
  },
218
438
  },
219
439
  settingsTransfer: false,
220
440
  });
221
441
  }
222
442
 
223
- function createSettingsTransferBelowThresholdWithCanvasSizeAcceptance() {
443
+ function createMandatorySetupWithCanvasSizeAcceptance() {
224
444
  return [
225
445
  makeControlAcceptance("canvas.size.width", "text"),
226
446
  makeControlAcceptance("canvas.size.height", "text"),
@@ -266,7 +486,7 @@ function stripJsComments(source: string): string {
266
486
  }
267
487
 
268
488
  function getMarkdownHeadingLevel(line: string): number | null {
269
- const match = /^(#{1,6})\s+\S/.exec(line);
489
+ const match = /^(#{1,6})\s+\S/.exec(line.trim());
270
490
 
271
491
  return match ? match[1].length : null;
272
492
  }
@@ -332,6 +552,104 @@ function escapeRegExp(value: string): string {
332
552
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333
553
  }
334
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
+
335
653
  function getAgentWorklogValidationErrors(source: string): string[] {
336
654
  const errors: string[] = [];
337
655
 
@@ -394,10 +712,21 @@ function getAgentWorklogValidationErrors(source: string): string[] {
394
712
  errors.push("agent-worklog.md Evidence must name reviewed files, references, or contract rules.");
395
713
  }
396
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
+
397
724
  if (!/\bpnpm\s+(verify|test|build|typecheck)|browser|Playwright|perf/i.test(verificationBody)) {
398
725
  errors.push("agent-worklog.md Verification must list concrete test/build/browser/performance checks.");
399
726
  }
400
727
 
728
+ errors.push(...getAgentWorklogVerificationGateErrors(source));
729
+
401
730
  if (!/\b(Risk|None):\s*\S/i.test(risksBody)) {
402
731
  errors.push('agent-worklog.md Risks must include either "Risk:" entries or "None:" with a reason.');
403
732
  }
@@ -454,7 +783,7 @@ function sourceDefinesProductCanvasContent(): boolean {
454
783
  function schemaHasProductSurface(): boolean {
455
784
  return (
456
785
  (starterSchema.panels.controls?.sections ?? []).some(
457
- (section) => Object.keys(section.controls).length > 0,
786
+ (section) => section.title !== "Setup" && Object.keys(section.controls).length > 0,
458
787
  ) ||
459
788
  starterSchema.panels.layers === true ||
460
789
  starterSchema.panels.timeline?.enabled === true ||
@@ -676,12 +1005,93 @@ function sourceHasVideoDurationMetadataCoverage(source: string): boolean {
676
1005
  );
677
1006
  }
678
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
+
679
1025
  function sourceHasCustomMovOrProResEncoder(source: string): boolean {
680
1026
  return /\bVideoEncoder\b|\bffmpeg\b|\bFFmpeg\b|\bProRes\b|\bprores\b|\btranscoder\b/i.test(
681
1027
  source,
682
1028
  );
683
1029
  }
684
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
+
685
1095
  function sourceHasImageExportDimensionCoverage(source: string): boolean {
686
1096
  const hasImageDecoder =
687
1097
  /\bcreateImageBitmap\b/.test(source) ||
@@ -806,7 +1216,40 @@ describe("Toolcraft template app acceptance coverage", () => {
806
1216
  expect(validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance)).toEqual([]);
807
1217
  });
808
1218
 
809
- it("requires fileDrop acceptance to prove upload, clear, and reset lifecycle", () => {
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
+
1232
+ it("requires generated product apps to publish a control section inventory", () => {
1233
+ if (!schemaHasProductSurface()) {
1234
+ expect(starterControlSectionInventory).toEqual([]);
1235
+ return;
1236
+ }
1237
+
1238
+ expect(
1239
+ starterControlSectionInventory.length,
1240
+ "Product apps must export starterControlSectionInventory so section grouping decisions are machine-checkable.",
1241
+ ).toBeGreaterThan(0);
1242
+ expect(
1243
+ validateToolcraftAcceptanceCoverage(
1244
+ starterSchema,
1245
+ starterAcceptance,
1246
+ starterTransferMode,
1247
+ starterControlSectionInventory,
1248
+ ),
1249
+ ).toEqual([]);
1250
+ });
1251
+
1252
+ it("requires fileDrop acceptance to prove upload, clear, reset, and image transforms", () => {
810
1253
  const fileDropSchema = defineToolcraft({
811
1254
  canvas: { enabled: true },
812
1255
  panels: {
@@ -849,12 +1292,69 @@ describe("Toolcraft template app acceptance coverage", () => {
849
1292
  ]),
850
1293
  ).toEqual(
851
1294
  expect.arrayContaining([
852
- "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
+ },
853
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.",
854
1354
  );
855
1355
  });
856
1356
 
857
- it("accepts fileDrop lifecycle coverage that includes reset", () => {
1357
+ it("accepts fileDrop lifecycle coverage that includes reset and image transforms", () => {
858
1358
  const fileDropSchema = defineToolcraft({
859
1359
  canvas: { enabled: true },
860
1360
  panels: {
@@ -886,13 +1386,13 @@ describe("Toolcraft template app acceptance coverage", () => {
886
1386
  componentType: "fileDrop",
887
1387
  evidence: "media-lifecycle",
888
1388
  expectedObservable:
889
- "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.",
890
1390
  fixture: "source image fixture",
891
1391
  id: "media.source",
892
1392
  kind: "control",
893
1393
  target: "media.source",
894
1394
  userAction:
895
- "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.",
896
1396
  },
897
1397
  ]);
898
1398
 
@@ -901,6 +1401,77 @@ describe("Toolcraft template app acceptance coverage", () => {
901
1401
  ).toEqual([]);
902
1402
  });
903
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
+
904
1475
  it("rejects fake video duration browser coverage that falls back to expected duration", () => {
905
1476
  const fakeCoverage = `
906
1477
  async function getVideoDuration(page, buffer, mimeType) {
@@ -994,7 +1565,7 @@ describe("Toolcraft template app acceptance coverage", () => {
994
1565
 
995
1566
  expect(validateToolcraftAcceptanceCoverage(fixedOutputSchema, starterAcceptance)).toEqual(
996
1567
  expect.arrayContaining([
997
- 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. A user-provided base/default size should normally use "editable-output".',
1568
+ 'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. Product/output apps must use "editable-output"; user-provided, reference, fixed-format, or base/default sizes belong in canvas.size as editable initial values.',
998
1569
  ]),
999
1570
  );
1000
1571
  });
@@ -1047,14 +1618,184 @@ describe("Toolcraft template app acceptance coverage", () => {
1047
1618
  ]),
1048
1619
  ).toEqual(
1049
1620
  expect.arrayContaining([
1050
- 'canvas.sizing canvasSizingCoverage "fixed-output-size" must explain why the product output dimensions are intentionally fixed, not merely initialized from a default size.',
1621
+ 'canvas.sizing canvasSizingCoverage "fixed-output-size" must explain why the output dimensions are intentionally fixed for a non-product/internal fixture, not merely initialized from a default size.',
1051
1622
  ]),
1052
1623
  );
1053
1624
  });
1054
1625
 
1055
- it("requires browser reload acceptance when localStorage persistence is enabled", () => {
1056
- const persistentSchema = defineToolcraft({
1057
- canvas: { enabled: true },
1626
+ it("rejects fixed canvas sizing justified only by a missing reference size editor", () => {
1627
+ const fixedOutputSchema = defineToolcraft({
1628
+ canvas: {
1629
+ enabled: true,
1630
+ size: { height: 1080, unit: "px", width: 1920 },
1631
+ sizing: { mode: "fixed-output" },
1632
+ },
1633
+ panels: {
1634
+ controls: {
1635
+ sections: [
1636
+ {
1637
+ controls: {
1638
+ prompt: {
1639
+ defaultValue: "Describe the effect",
1640
+ label: "Prompt",
1641
+ orderRole: "input",
1642
+ target: "generation.prompt",
1643
+ type: "text",
1644
+ },
1645
+ },
1646
+ title: "Generation",
1647
+ },
1648
+ ],
1649
+ title: "Controls",
1650
+ },
1651
+ },
1652
+ });
1653
+
1654
+ expect(
1655
+ validateToolcraftAcceptanceCoverage(fixedOutputSchema, [
1656
+ ...starterAcceptance,
1657
+ {
1658
+ automated: true,
1659
+ automatedTestName: "canvas output remains fixed",
1660
+ browser: true,
1661
+ browserTestName: "browser: canvas output remains fixed",
1662
+ canvasSizingCoverage: "fixed-output-size",
1663
+ componentType: "fixed-output canvas",
1664
+ evidence: "product-output",
1665
+ expectedObservable:
1666
+ "The WebGL output remains fixed at 1920x1080 because the reference app has no user-facing output size editor.",
1667
+ fixture: "default canvas fixture",
1668
+ id: "canvas.sizing",
1669
+ kind: "runtime",
1670
+ userAction: "Open the app.",
1671
+ },
1672
+ ]),
1673
+ ).toEqual(
1674
+ expect.arrayContaining([
1675
+ expect.stringContaining("cannot be justified by the reference or previous app lacking a size editor"),
1676
+ ]),
1677
+ );
1678
+ });
1679
+
1680
+ it("rejects fixed canvas sizing for exportable product apps even when reference dimensions are fixed", () => {
1681
+ const fixedExportSchema = defineToolcraft({
1682
+ canvas: {
1683
+ enabled: true,
1684
+ size: { height: 900, unit: "px", width: 1440 },
1685
+ sizing: { mode: "fixed-output" },
1686
+ },
1687
+ panels: {
1688
+ controls: {
1689
+ sections: [
1690
+ {
1691
+ controls: {
1692
+ outputActions: {
1693
+ actions: [
1694
+ {
1695
+ icon: "upload-simple",
1696
+ label: "Export PNG",
1697
+ value: "export.png",
1698
+ },
1699
+ ],
1700
+ target: "actions.output",
1701
+ type: "panelActions",
1702
+ },
1703
+ },
1704
+ },
1705
+ ],
1706
+ title: "Controls",
1707
+ },
1708
+ },
1709
+ });
1710
+
1711
+ expect(
1712
+ validateToolcraftAcceptanceCoverage(fixedExportSchema, [
1713
+ ...starterAcceptance,
1714
+ {
1715
+ automated: true,
1716
+ automatedTestName: "fixed reference size is preserved",
1717
+ browser: true,
1718
+ browserTestName: "browser: fixed reference size is preserved",
1719
+ canvasSizingCoverage: "fixed-output-size",
1720
+ componentType: "fixed-output canvas",
1721
+ evidence: "product-output",
1722
+ expectedObservable:
1723
+ "The exported shader canvas remains reference-defined and fixed at 1440x900.",
1724
+ fixture: "shader reference fixture",
1725
+ id: "canvas.sizing",
1726
+ kind: "runtime",
1727
+ userAction: "Open the app.",
1728
+ },
1729
+ ]),
1730
+ ).toEqual(
1731
+ expect.arrayContaining([
1732
+ 'Product/output apps with export actions must use canvas.sizing mode "editable-output" so Aspect ratio, Canvas width, and Canvas height are always available. Put reference, fixed-format, or user-requested dimensions in canvas.size as the initial value instead of hiding size controls with "fixed-output".',
1733
+ ]),
1734
+ );
1735
+ });
1736
+
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
+ },
1744
+ panels: {
1745
+ controls: {
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 },
1058
1799
  panels: {
1059
1800
  controls: {
1060
1801
  sections: [
@@ -1173,67 +1914,29 @@ describe("Toolcraft template app acceptance coverage", () => {
1173
1914
  );
1174
1915
  });
1175
1916
 
1176
- it("requires settings transfer when product schema crosses the complexity threshold", () => {
1177
- const complexSchema = createSettingsTransferThresholdSchema(false);
1917
+ it("does not require app-authored settings transfer because setup controls are runtime-mandatory", () => {
1918
+ const complexSchema = createMandatorySetupSchema(false);
1178
1919
 
1179
1920
  expect(
1180
1921
  validateToolcraftAcceptanceCoverage(
1181
1922
  complexSchema,
1182
- createSettingsTransferThresholdAcceptance(),
1923
+ createMandatorySetupAcceptance(),
1183
1924
  ),
1184
- ).toEqual(
1185
- expect.arrayContaining([
1186
- expect.stringContaining(
1187
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
1188
- ),
1189
- ]),
1190
- );
1925
+ ).toEqual([]);
1191
1926
  });
1192
1927
 
1193
- it("does not count runtime canvas size controls toward settings transfer eligibility", () => {
1194
- const belowThresholdSchema = createSettingsTransferBelowThresholdWithCanvasSizeSchema();
1928
+ it("accepts small schemas because settings transfer setup is runtime-mandatory", () => {
1929
+ const smallSchema = createMandatorySetupWithCanvasSizeSchema();
1195
1930
  const errors = validateToolcraftAcceptanceCoverage(
1196
- belowThresholdSchema,
1197
- createSettingsTransferBelowThresholdWithCanvasSizeAcceptance(),
1198
- );
1199
-
1200
- expect(errors).not.toEqual(
1201
- expect.arrayContaining([
1202
- expect.stringContaining(
1203
- "settingsTransfer is required for this complex product app because settings-transfer eligibility was reached.",
1204
- ),
1205
- ]),
1931
+ smallSchema,
1932
+ createMandatorySetupWithCanvasSizeAcceptance(),
1206
1933
  );
1207
- });
1208
-
1209
- it("allows explicit settings transfer opt-out only with runtime evidence", () => {
1210
- const complexSchema = createSettingsTransferThresholdSchema(false);
1211
- const acceptance: ToolcraftComponentAcceptance[] = [
1212
- ...createSettingsTransferThresholdAcceptance(),
1213
- {
1214
- automated: true,
1215
- automatedTestName: "settings transfer intentionally omitted for session-only app",
1216
- browser: true,
1217
- browserTestName: "browser: settings transfer intentionally omitted",
1218
- componentType: "settingsTransfer",
1219
- evidence: "persistence-state",
1220
- expectedObservable:
1221
- "The complex app is session-only and intentionally does not expose portable settings.",
1222
- fixture: "settings transfer opt-out fixture",
1223
- id: "settings.transfer.optOut",
1224
- kind: "runtime",
1225
- settingsTransferCoverage: "opt-out",
1226
- target: "runtime.settingsTransfer",
1227
- userAction:
1228
- "Open the session-only app, change temporary settings, and verify no portable settings workflow is required.",
1229
- },
1230
- ];
1231
1934
 
1232
- expect(validateToolcraftAcceptanceCoverage(complexSchema, acceptance)).toEqual([]);
1935
+ expect(errors).toEqual([]);
1233
1936
  });
1234
1937
 
1235
1938
  it("passes complex schemas with auto settings transfer enabled", () => {
1236
- const complexSchema = createSettingsTransferThresholdSchema("auto");
1939
+ const complexSchema = createMandatorySetupSchema("auto");
1237
1940
  const acceptance: ToolcraftComponentAcceptance[] = [
1238
1941
  {
1239
1942
  automated: true,
@@ -1251,12 +1954,63 @@ describe("Toolcraft template app acceptance coverage", () => {
1251
1954
  userAction:
1252
1955
  "Change one complex setting, export settings, change it again, import the JSON, and observe the restored value.",
1253
1956
  },
1254
- ...createSettingsTransferThresholdAcceptance(),
1957
+ ...createMandatorySetupAcceptance(),
1255
1958
  ];
1256
1959
 
1257
1960
  expect(validateToolcraftAcceptanceCoverage(complexSchema, acceptance)).toEqual([]);
1258
1961
  });
1259
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
+
1260
2014
  it("requires compound controls to cover every semantic value part", () => {
1261
2015
  const compoundSchema = defineToolcraft({
1262
2016
  canvas: { enabled: true },
@@ -1370,7 +2124,6 @@ describe("Toolcraft template app acceptance coverage", () => {
1370
2124
  });
1371
2125
 
1372
2126
  const acceptance = [
1373
- "runtime.settingsTransfer",
1374
2127
  "mesh.anchor",
1375
2128
  "mesh.focus",
1376
2129
  "mesh.gradient",
@@ -1386,9 +2139,7 @@ describe("Toolcraft template app acceptance coverage", () => {
1386
2139
  browser: true,
1387
2140
  browserTestName: `browser: ${target} changes output`,
1388
2141
  componentType:
1389
- target === "runtime.settingsTransfer"
1390
- ? "settingsTransfer"
1391
- : target === "mesh.anchor"
2142
+ target === "mesh.anchor"
1392
2143
  ? "anchorGrid"
1393
2144
  : target === "mesh.focus"
1394
2145
  ? "vector"
@@ -2109,7 +2860,6 @@ describe("Toolcraft template app acceptance coverage", () => {
2109
2860
  step: 0.1,
2110
2861
  target: "field.speed",
2111
2862
  type: "slider",
2112
- unit: "x",
2113
2863
  },
2114
2864
  },
2115
2865
  layoutGroups: [
@@ -2167,6 +2917,57 @@ describe("Toolcraft template app acceptance coverage", () => {
2167
2917
  );
2168
2918
  });
2169
2919
 
2920
+ it("rejects x units on slider value labels", () => {
2921
+ const schema = defineToolcraft({
2922
+ canvas: { enabled: true },
2923
+ panels: {
2924
+ controls: {
2925
+ sections: [
2926
+ {
2927
+ controls: {
2928
+ strength: {
2929
+ defaultValue: 0.4,
2930
+ label: "Strength",
2931
+ max: 1,
2932
+ min: 0,
2933
+ step: 0.01,
2934
+ target: "glass.strength",
2935
+ type: "slider",
2936
+ unit: "x",
2937
+ },
2938
+ },
2939
+ title: "Glass",
2940
+ },
2941
+ ],
2942
+ title: "Controls",
2943
+ },
2944
+ },
2945
+ });
2946
+
2947
+ expect(
2948
+ validateToolcraftAcceptanceCoverage(schema, [
2949
+ {
2950
+ automated: true,
2951
+ automatedTestName: "strength changes glass output",
2952
+ browser: true,
2953
+ browserTestName: "browser: strength slider changes glass output",
2954
+ componentType: "slider",
2955
+ evidence: "rendered-pixels",
2956
+ expectedObservable: "Changing Strength changes the rendered glass strength.",
2957
+ fixture: "glass strength fixture",
2958
+ id: "glass.strength",
2959
+ kind: "control",
2960
+ target: "glass.strength",
2961
+ userAction: "Drag the Strength slider.",
2962
+ },
2963
+ ]),
2964
+ ).toEqual(
2965
+ expect.arrayContaining([
2966
+ 'Glass / strength (glass.strength) uses unit "x", but Toolcraft slider values do not use x suffixes. Omit unit for scale, multiplier, intensity, opacity, strength, depth, and shader amount values unless a real measurement unit applies.',
2967
+ ]),
2968
+ );
2969
+ });
2970
+
2170
2971
  it("accepts compound controls only when every semantic value part is declared", () => {
2171
2972
  const gradientSchema = defineToolcraft({
2172
2973
  canvas: { enabled: true },
@@ -2354,6 +3155,11 @@ describe("Toolcraft template app acceptance coverage", () => {
2354
3155
  schemaHasProductSurface(),
2355
3156
  "Product readiness requires product surface: controls, layers, timeline, canvasContent, or acceptance coverage.",
2356
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");
2357
3163
  return;
2358
3164
  }
2359
3165
 
@@ -2407,26 +3213,267 @@ describe("Toolcraft template app acceptance coverage", () => {
2407
3213
  expect(getAgentWorklogValidationErrors(worklogSource)).toEqual([]);
2408
3214
  });
2409
3215
 
2410
- it("rejects stale or incomplete product worklogs", () => {
2411
- const staleWorklog = `
3216
+ it("requires product worklogs to record storyboard evidence for video references", () => {
3217
+ const videoWorklogWithoutStudy = `
2412
3218
  # Implementation Worklog
2413
3219
 
2414
3220
  ## Status
2415
3221
 
2416
- Mode: starter
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.
2417
3242
 
2418
3243
  ## Decisions
2419
3244
 
2420
3245
  ### Renderer
2421
-
2422
- - Decision: No product renderer yet.
2423
- - Reason: The starter is neutral.
2424
- - Evidence: No canvasContent.
3246
+ - Decision: Use SVG.
3247
+ - Reason: Vector output.
3248
+ - Evidence: src/app/product-renderer.tsx.
2425
3249
 
2426
3250
  ### Timeline
3251
+ - Decision: Use playback.
3252
+ - Reason: Motion output.
3253
+ - Evidence: src/app/app-schema.ts.
2427
3254
 
2428
- - Decision: No timeline yet.
2429
- - Reason: No animation.
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
+
3457
+ it("rejects stale or incomplete product worklogs", () => {
3458
+ const staleWorklog = `
3459
+ # Implementation Worklog
3460
+
3461
+ ## Status
3462
+
3463
+ Mode: starter
3464
+
3465
+ ## Decisions
3466
+
3467
+ ### Renderer
3468
+
3469
+ - Decision: No product renderer yet.
3470
+ - Reason: The starter is neutral.
3471
+ - Evidence: No canvasContent.
3472
+
3473
+ ### Timeline
3474
+
3475
+ - Decision: No timeline yet.
3476
+ - Reason: No animation.
2430
3477
  - Evidence: panels.timeline is omitted.
2431
3478
 
2432
3479
  ### Layers
@@ -2518,7 +3565,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2518
3565
 
2519
3566
  - Decision: Run viewport-stability and animation drag budgets.
2520
3567
  - Reason: Animation and canvas movement are performance-sensitive.
2521
- - Evidence: pnpm verify:perf browser run.
3568
+ - Evidence: agent-browser performance checkpoint.
2522
3569
 
2523
3570
  ## Decision Trail
2524
3571
 
@@ -2528,13 +3575,14 @@ describe("Toolcraft template app acceptance coverage", () => {
2528
3575
  - Task type: Renderer, schema, timeline, export, and performance.
2529
3576
  - User-visible result: The canvas renders the animated poster, the top timeline controls playback, and sticky footer actions export video and PNG.
2530
3577
  - Source/reference checked: User prompt plus the generated product renderer requirements.
3578
+ - Reference inputs: None.
2531
3579
  - Docs/contracts read: workflow.md, assembly-workflow.md, schema-reference.md, component-rules.md, renderer-technique.md, performance.md.
2532
3580
  - Contract rules applied: runtime-shell-required, controls-layout-heuristics, timeline-enabled-behavior, output-export-required, performance-coverage-levels.
2533
3581
  - Decision: Use SVG foreground with Canvas 2D background and Toolcraft playback timeline.
2534
3582
  - Alternatives rejected: DOM-only output because dense animation can jank; app-local timeline because playback UI is runtime-owned.
2535
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.
2536
3584
  - Files changed: src/app/app-schema.ts, src/app/app-performance.ts, src/app/product-renderer.tsx, e2e/app-controls.spec.ts.
2537
- - 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.
2538
3586
  - Skipped checks: None.
2539
3587
  - Risks: None; browser and performance gates cover the touched surfaces.
2540
3588
 
@@ -2546,7 +3594,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2546
3594
  ## Verification
2547
3595
 
2548
3596
  - Run: pnpm verify:quick
2549
- - Run: pnpm verify:perf
3597
+ - Run: agent-browser performance checkpoint
2550
3598
  - Browser: timeline pause/resume and canvas drag stress.
2551
3599
 
2552
3600
  ## Risks
@@ -2557,8 +3605,8 @@ describe("Toolcraft template app acceptance coverage", () => {
2557
3605
  expect(getAgentWorklogValidationErrors(productWorklog)).toEqual([]);
2558
3606
  });
2559
3607
 
2560
- it("rejects product worklogs without an actionable decision trail", () => {
2561
- const worklog = `
3608
+ it("rejects product worklogs that report incomplete required performance gates", () => {
3609
+ const incompletePerfWorklog = `
2562
3610
  # Implementation Worklog
2563
3611
 
2564
3612
  ## Status
@@ -2569,116 +3617,591 @@ describe("Toolcraft template app acceptance coverage", () => {
2569
3617
 
2570
3618
  ### Renderer
2571
3619
 
2572
- - Decision: Use WebGL.
2573
- - Reason: Dense animation needs batched rendering.
2574
- - 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.
2575
3623
 
2576
3624
  ### Timeline
2577
3625
 
2578
- - Decision: Use playback timeline.
2579
- - Reason: The product exposes play and scrub.
3626
+ - Decision: Use Toolcraft playback timeline.
3627
+ - Reason: The product is animated and exportable.
2580
3628
  - Evidence: appSchema.panels.timeline.mode.
2581
3629
 
2582
3630
  ### Layers
2583
3631
 
2584
3632
  - Decision: Do not enable layers.
2585
- - Reason: The product is single-output.
3633
+ - Reason: The product has one generated output.
2586
3634
  - Evidence: appSchema.panels.layers is omitted.
2587
3635
 
2588
3636
  ### Controls
2589
3637
 
2590
3638
  - Decision: Group controls by product entity.
2591
- - Reason: Sections map to product behavior.
2592
- - 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.
2593
3641
 
2594
3642
  ### Export
2595
3643
 
2596
3644
  - Decision: Provide PNG and video export.
2597
- - Reason: The app is animated.
2598
- - Evidence: panelActions.
3645
+ - Reason: The product is animated but still frames are useful.
3646
+ - Evidence: panelActions plus export handlers.
2599
3647
 
2600
3648
  ### Performance
2601
3649
 
2602
- - Decision: Run perf coverage.
2603
- - Reason: Renderer is animated.
2604
- - Evidence: pnpm verify:perf.
3650
+ - Decision: Declare performance scenarios for renderer and controls.
3651
+ - Reason: Canvas effects are performance-sensitive.
3652
+ - Evidence: src/app/app-performance.ts.
2605
3653
 
2606
3654
  ## Decision Trail
2607
3655
 
2608
- ### Iteration 1 — Product build
3656
+ ### Iteration 1 — Initial product build
2609
3657
 
2610
- - Request: Build an animated product app.
2611
- - Task type: Renderer, controls, and export.
2612
- - Docs/contracts read: workflow.md and renderer-technique.md.
2613
- - Decision: Use WebGL and playback timeline.
2614
- - Files changed: src/app/app-schema.ts and src/app/product-renderer.tsx.
2615
- - Verification: pnpm verify:quick.
2616
- - Risks: Performance still needs browser proof.
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.
2617
3672
 
2618
3673
  ## Evidence
2619
3674
 
2620
- - Source reviewed: src/app/app-schema.ts.
2621
- - Contract applied: runtime-shell-required.
3675
+ - Source reviewed: src/app/app-schema.ts, src/app/app-performance.ts, e2e/app-controls.spec.ts.
3676
+ - Contract applied: performance-coverage-levels.
2622
3677
 
2623
3678
  ## Verification
2624
3679
 
2625
- - Run: pnpm verify:quick
3680
+ - Run: pnpm test failed.
3681
+ - Run: pnpm verify:perf not complete.
2626
3682
 
2627
3683
  ## Risks
2628
3684
 
2629
- - Risk: performance needs a follow-up run.
3685
+ - Risk: Required checks are not complete.
2630
3686
  `;
2631
3687
 
2632
- expect(getAgentWorklogValidationErrors(worklog)).toEqual(
3688
+ expect(getAgentWorklogValidationErrors(incompletePerfWorklog)).toEqual(
2633
3689
  expect.arrayContaining([
2634
- 'agent-worklog.md Decision Trail iteration "Iteration 1 Product build" must include "User-visible result:".',
2635
- 'agent-worklog.md Decision Trail iteration "Iteration 1 Product build" must include "Source/reference checked:".',
2636
- 'agent-worklog.md Decision Trail iteration "Iteration 1 Product build" must include "Contract rules applied:".',
2637
- 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Alternatives rejected:".',
2638
- 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "State/output mapping:".',
2639
- 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Skipped checks:".',
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.",
2640
3693
  ]),
2641
3694
  );
2642
3695
  });
2643
3696
 
2644
- it("requires product output apps to expose export actions in the sticky footer", () => {
2645
- if (starterProductReadiness.mode !== "product" && !schemaHasProductSurface()) {
2646
- return;
2647
- }
3697
+ it("requires first working product worklogs to record a browser performance checkpoint", () => {
3698
+ const missingPerfWorklog = `
3699
+ # Implementation Worklog
2648
3700
 
2649
- const panelActionTexts = getSchemaPanelActionSearchTexts();
2650
- const browserTestSources = readBrowserTestSources();
2651
- const productImplementationSource = getProductImplementationSource();
2652
- const productRuntimeImplementationSource = getProductRuntimeImplementationSource();
2653
- const backgroundColorTargets = getSchemaBackgroundControlTargets(new Set(["color"]));
2654
- const backgroundToggleTargets = getSchemaBackgroundControlTargets(
2655
- new Set(["checkbox", "select", "segmented", "switch"]),
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.",
2656
3779
  );
3780
+ });
2657
3781
 
3782
+ it("recognizes supported browser performance checkpoint evidence line formats", () => {
2658
3783
  expect(
2659
- panelActionTexts.length,
2660
- "Product apps must define panelActions in the controls panel sticky footer.",
2661
- ).toBeGreaterThan(0);
3784
+ isPassedAgentBrowserPerfRunLine(
3785
+ "- Browser: performance checkpoint runner agent-browser",
3786
+ ),
3787
+ ).toBe(true);
2662
3788
  expect(
2663
- panelActionTexts.some(textLooksLikePngExport),
2664
- "Every product app must expose Export PNG or Download PNG through panelActions.",
3789
+ isPassedAgentBrowserPerfRunLine(
3790
+ "- Verification: agent-browser perf checkpoint",
3791
+ ),
2665
3792
  ).toBe(true);
2666
3793
  expect(
2667
- productImplementationSource,
2668
- "PNG export must use createToolcraftPngExportCanvas so background transparency and retina sizing follow the standard runtime contract.",
2669
- ).toMatch(/\bcreateToolcraftPngExportCanvas\b/);
3794
+ isPassedPlaywrightFallbackPerfRunLine(
3795
+ "- Verification: playwright-fallback browser performance checkpoint",
3796
+ ),
3797
+ ).toBe(true);
3798
+ expect(isPassedPlaywrightFallbackPerfRunLine("- Run: pnpm verify:perf")).toBe(true);
2670
3799
  expect(
2671
- productImplementationSource,
2672
- "PNG export must pass includeBackground from runtime state to createToolcraftPngExportCanvas; do not hardcode PNG transparency or background inclusion in schema only.",
2673
- ).toMatch(/\bcreateToolcraftPngExportCanvas\s*\(\s*\{[\s\S]*\bincludeBackground\s*:/);
3800
+ isPassedPlaywrightFallbackPerfRunLine(
3801
+ "- Run: pnpm verify:perf not complete",
3802
+ ),
3803
+ ).toBe(false);
2674
3804
  expect(
2675
- productRuntimeImplementationSource,
2676
- "Live preview must read include-background state through shouldIncludeToolcraftPreviewBackground(state) so turning Include off hides the product preview background without affecting video export.",
2677
- ).toMatch(/\bshouldIncludeToolcraftPreviewBackground\b/);
3805
+ isPassedAgentBrowserPerfRunLine(
3806
+ "- Verification: agent-browser perf checkpoint planned after build",
3807
+ ),
3808
+ ).toBe(false);
2678
3809
  expect(
2679
- backgroundColorTargets.length,
2680
- "Every product app with Export PNG must expose a user-facing background color control.",
2681
- ).toBeGreaterThan(0);
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.
4121
+
4122
+ ### Performance
4123
+
4124
+ - Decision: Run perf coverage.
4125
+ - Reason: Renderer is animated.
4126
+ - Evidence: pnpm verify:perf.
4127
+
4128
+ ## Decision Trail
4129
+
4130
+ ### Iteration 1 — Product build
4131
+
4132
+ - Request: Build an animated product app.
4133
+ - Task type: Renderer, controls, and export.
4134
+ - Docs/contracts read: workflow.md and renderer-technique.md.
4135
+ - Decision: Use WebGL and playback timeline.
4136
+ - Files changed: src/app/app-schema.ts and src/app/product-renderer.tsx.
4137
+ - Verification: pnpm verify:quick.
4138
+ - Risks: Performance still needs browser proof.
4139
+
4140
+ ## Evidence
4141
+
4142
+ - Source reviewed: src/app/app-schema.ts.
4143
+ - Contract applied: runtime-shell-required.
4144
+
4145
+ ## Verification
4146
+
4147
+ - Run: pnpm verify:quick
4148
+
4149
+ ## Risks
4150
+
4151
+ - Risk: performance needs a follow-up run.
4152
+ `;
4153
+
4154
+ expect(getAgentWorklogValidationErrors(worklog)).toEqual(
4155
+ expect.arrayContaining([
4156
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "User-visible result:".',
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:".',
4159
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Contract rules applied:".',
4160
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Alternatives rejected:".',
4161
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "State/output mapping:".',
4162
+ 'agent-worklog.md Decision Trail iteration "Iteration 1 — Product build" must include "Skipped checks:".',
4163
+ ]),
4164
+ );
4165
+ });
4166
+
4167
+ it("requires product output apps to expose export actions in the sticky footer", () => {
4168
+ if (starterProductReadiness.mode !== "product" && !schemaHasProductSurface()) {
4169
+ return;
4170
+ }
4171
+
4172
+ const panelActionTexts = getSchemaPanelActionSearchTexts();
4173
+ const browserTestSources = readBrowserTestSources();
4174
+ const productImplementationSource = getProductImplementationSource();
4175
+ const productRuntimeImplementationSource = getProductRuntimeImplementationSource();
4176
+ const backgroundColorTargets = getSchemaBackgroundControlTargets(new Set(["color"]));
4177
+ const backgroundToggleTargets = getSchemaBackgroundControlTargets(
4178
+ new Set(["checkbox", "select", "segmented", "switch"]),
4179
+ );
4180
+
4181
+ expect(
4182
+ panelActionTexts.length,
4183
+ "Product apps must define panelActions in the controls panel sticky footer.",
4184
+ ).toBeGreaterThan(0);
4185
+ expect(
4186
+ panelActionTexts.some(textLooksLikePngExport),
4187
+ "Every product app must expose Export PNG or Download PNG through panelActions.",
4188
+ ).toBe(true);
4189
+ expect(
4190
+ productImplementationSource,
4191
+ "PNG export must use createToolcraftPngExportCanvas so background transparency and retina sizing follow the standard runtime contract.",
4192
+ ).toMatch(/\bcreateToolcraftPngExportCanvas\b/);
4193
+ expect(
4194
+ productImplementationSource,
4195
+ "PNG export must pass includeBackground from runtime state to createToolcraftPngExportCanvas; do not hardcode PNG transparency or background inclusion in schema only.",
4196
+ ).toMatch(/\bcreateToolcraftPngExportCanvas\s*\(\s*\{[\s\S]*\bincludeBackground\s*:/);
4197
+ expect(
4198
+ productRuntimeImplementationSource,
4199
+ "Live preview must read include-background state through shouldIncludeToolcraftPreviewBackground(state) so turning Include off hides the product preview background without affecting video export.",
4200
+ ).toMatch(/\bshouldIncludeToolcraftPreviewBackground\b/);
4201
+ expect(
4202
+ backgroundColorTargets.length,
4203
+ "Every product app with Export PNG must expose a user-facing background color control.",
4204
+ ).toBeGreaterThan(0);
2682
4205
  expect(
2683
4206
  backgroundToggleTargets.length,
2684
4207
  'Every product app with Export PNG must expose export.includeBackground in the required "Background" section as a Switch labeled "Include".',
@@ -2812,8 +4335,24 @@ describe("Toolcraft template app acceptance coverage", () => {
2812
4335
  ).toBeGreaterThanOrEqual(2);
2813
4336
  expect(
2814
4337
  productImplementationSource,
2815
- "Video export must use getToolcraftRetinaExportSize so video dimensions follow the same retina export contract.",
2816
- ).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);
2817
4356
  expect(
2818
4357
  productImplementationSource,
2819
4358
  "Video export must use shouldIncludeToolcraftExportBackground so PNG transparency does not remove the video background.",
@@ -2892,6 +4431,10 @@ describe("Toolcraft template app acceptance coverage", () => {
2892
4431
  sourceHasVideoDurationMetadataCoverage(browserTestSources),
2893
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.",
2894
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);
2895
4438
  if (hasMovOrProResFormat) {
2896
4439
  expect(
2897
4440
  sourceHasCustomMovOrProResEncoder(productImplementationSource),
@@ -2920,7 +4463,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2920
4463
  value: "reset",
2921
4464
  },
2922
4465
  {
2923
- icon: "export",
4466
+ icon: "upload-simple",
2924
4467
  label: "Export PNG",
2925
4468
  value: "export.png",
2926
4469
  },
@@ -2975,7 +4518,7 @@ describe("Toolcraft template app acceptance coverage", () => {
2975
4518
  outputActions: {
2976
4519
  actions: [
2977
4520
  {
2978
- icon: "export",
4521
+ icon: "upload-simple",
2979
4522
  label: "Export PNG",
2980
4523
  value: "export.png",
2981
4524
  },
@@ -3044,7 +4587,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3044
4587
  outputActions: {
3045
4588
  actions: [
3046
4589
  {
3047
- icon: "export",
4590
+ icon: "upload-simple",
3048
4591
  label: "Export PNG",
3049
4592
  value: "export.png",
3050
4593
  },
@@ -3110,7 +4653,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3110
4653
  outputActions: {
3111
4654
  actions: [
3112
4655
  {
3113
- icon: "export",
4656
+ icon: "upload-simple",
3114
4657
  label: "Export PNG",
3115
4658
  value: "export.png",
3116
4659
  },
@@ -3177,7 +4720,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3177
4720
  outputActions: {
3178
4721
  actions: [
3179
4722
  {
3180
- icon: "export",
4723
+ icon: "upload-simple",
3181
4724
  label: "Export PNG",
3182
4725
  value: "export.png",
3183
4726
  },
@@ -3240,7 +4783,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3240
4783
  outputActions: {
3241
4784
  actions: [
3242
4785
  {
3243
- icon: "export",
4786
+ icon: "upload-simple",
3244
4787
  label: "Export PNG",
3245
4788
  value: "export.png",
3246
4789
  },
@@ -3305,7 +4848,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3305
4848
  outputActions: {
3306
4849
  actions: [
3307
4850
  {
3308
- icon: "export",
4851
+ icon: "upload-simple",
3309
4852
  label: "Export PNG",
3310
4853
  value: "export.png",
3311
4854
  },
@@ -3387,12 +4930,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3387
4930
  outputActions: {
3388
4931
  actions: [
3389
4932
  {
3390
- icon: "download",
4933
+ icon: "upload-simple",
3391
4934
  label: "Export Video",
3392
4935
  value: "export.video",
3393
4936
  },
3394
4937
  {
3395
- icon: "export",
4938
+ icon: "upload-simple",
3396
4939
  label: "Export PNG",
3397
4940
  value: "export.png",
3398
4941
  },
@@ -3406,7 +4949,6 @@ describe("Toolcraft template app acceptance coverage", () => {
3406
4949
  ],
3407
4950
  title: "Controls",
3408
4951
  },
3409
- timeline: { enabled: true, mode: "playback" },
3410
4952
  },
3411
4953
  });
3412
4954
 
@@ -3461,12 +5003,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3461
5003
  outputActions: {
3462
5004
  actions: [
3463
5005
  {
3464
- icon: "download",
5006
+ icon: "upload-simple",
3465
5007
  label: "Export Video",
3466
5008
  value: "export.video",
3467
5009
  },
3468
5010
  {
3469
- icon: "export",
5011
+ icon: "upload-simple",
3470
5012
  label: "Export PNG",
3471
5013
  value: "export.png",
3472
5014
  },
@@ -3480,7 +5022,6 @@ describe("Toolcraft template app acceptance coverage", () => {
3480
5022
  ],
3481
5023
  title: "Controls",
3482
5024
  },
3483
- timeline: { enabled: true, mode: "playback" },
3484
5025
  },
3485
5026
  });
3486
5027
  const imageFormatAcceptance = makeControlAcceptance("export.image.format", "select");
@@ -3532,7 +5073,7 @@ describe("Toolcraft template app acceptance coverage", () => {
3532
5073
  );
3533
5074
  });
3534
5075
 
3535
- 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", () => {
3536
5077
  const schemaWithDisabledDependency = defineToolcraft({
3537
5078
  canvas: { enabled: true },
3538
5079
  panels: {
@@ -3582,12 +5123,12 @@ describe("Toolcraft template app acceptance coverage", () => {
3582
5123
  "disabledWhen target distribution.mode does not match another schema control target",
3583
5124
  ),
3584
5125
  expect.stringContaining(
3585
- "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",
3586
5127
  ),
3587
5128
  ]),
3588
5129
  );
3589
5130
 
3590
- const schemaWithValidDependency = defineToolcraft({
5131
+ const schemaWithBranchDisabledDependency = defineToolcraft({
3591
5132
  canvas: { enabled: true },
3592
5133
  panels: {
3593
5134
  controls: {
@@ -3606,10 +5147,52 @@ describe("Toolcraft template app acceptance coverage", () => {
3606
5147
  },
3607
5148
  fillAmount: {
3608
5149
  defaultValue: 50,
3609
- disabledWhen: {
3610
- equals: "full",
3611
- target: "distribution.fillMode",
3612
- },
5150
+ disabledWhen: {
5151
+ equals: "full",
5152
+ target: "distribution.fillMode",
5153
+ },
5154
+ label: "Fill level",
5155
+ max: 100,
5156
+ min: 0,
5157
+ target: "distribution.fillAmount",
5158
+ type: "slider",
5159
+ },
5160
+ },
5161
+ title: "Distribution",
5162
+ },
5163
+ ],
5164
+ title: "Controls",
5165
+ },
5166
+ },
5167
+ });
5168
+ const branchErrors = validateToolcraftAcceptanceCoverage(schemaWithBranchDisabledDependency, [
5169
+ makeControlAcceptance("distribution.fillMode", "segmented"),
5170
+ {
5171
+ ...makeControlAcceptance("distribution.fillAmount", "slider"),
5172
+ expectedObservable:
5173
+ "Fill level changes partial fill output and becomes disabled when Fill mode is Full.",
5174
+ userAction: "Switch Fill mode to Full, verify Fill level is disabled, then switch to Partial and drag it.",
5175
+ },
5176
+ ]);
5177
+
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,
3613
5196
  label: "Fill level",
3614
5197
  max: 100,
3615
5198
  min: 0,
@@ -3624,20 +5207,16 @@ describe("Toolcraft template app acceptance coverage", () => {
3624
5207
  },
3625
5208
  },
3626
5209
  });
3627
- const errors = validateToolcraftAcceptanceCoverage(schemaWithValidDependency, [
3628
- makeControlAcceptance("distribution.fillMode", "segmented"),
3629
- {
3630
- ...makeControlAcceptance("distribution.fillAmount", "slider"),
3631
- expectedObservable:
3632
- "Fill level changes partial fill output and becomes disabled when Fill mode is Full.",
3633
- userAction: "Switch Fill mode to Full, verify Fill level is disabled, then switch to Partial and drag it.",
3634
- },
3635
- ]);
3636
5210
 
3637
- expect(errors).not.toEqual(
5211
+ expect(
5212
+ validateToolcraftAcceptanceCoverage(schemaWithDisabledControl, [
5213
+ makeControlAcceptance("distribution.fillAmount", "slider"),
5214
+ ]),
5215
+ ).toEqual(
3638
5216
  expect.arrayContaining([
3639
- expect.stringContaining("disabledWhen target"),
3640
- 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
+ ),
3641
5220
  ]),
3642
5221
  );
3643
5222
  });
@@ -3761,18 +5340,271 @@ describe("Toolcraft template app acceptance coverage", () => {
3761
5340
  controls: {
3762
5341
  crt: {
3763
5342
  defaultValue: true,
3764
- label: "Enable CRT",
3765
- target: "style.crt",
5343
+ label: "Enable CRT",
5344
+ target: "style.crt",
5345
+ type: "switch",
5346
+ },
5347
+ guides: {
5348
+ defaultValue: false,
5349
+ label: "Disable guides",
5350
+ target: "overlay.guides",
5351
+ type: "checkbox",
5352
+ },
5353
+ },
5354
+ title: "Style",
5355
+ },
5356
+ ],
5357
+ title: "Controls",
5358
+ },
5359
+ },
5360
+ });
5361
+
5362
+ expect(
5363
+ validateToolcraftAcceptanceCoverage(schemaWithActionLabels, [
5364
+ makeControlAcceptance("style.crt", "switch"),
5365
+ makeControlAcceptance("overlay.guides", "checkbox"),
5366
+ ]),
5367
+ ).toEqual(
5368
+ expect.arrayContaining([
5369
+ expect.stringContaining(
5370
+ 'style.crt) toggle labels must name the setting context only; use "CRT", "Background", "Glow", or "Loop" instead of "Enable CRT".',
5371
+ ),
5372
+ expect.stringContaining(
5373
+ 'overlay.guides) toggle labels must name the setting context only; use "CRT", "Background", "Glow", or "Loop" instead of "Disable guides".',
5374
+ ),
5375
+ ]),
5376
+ );
5377
+
5378
+ const schemaWithContextLabels = defineToolcraft({
5379
+ canvas: { enabled: true },
5380
+ panels: {
5381
+ controls: {
5382
+ sections: [
5383
+ {
5384
+ controls: {
5385
+ crt: {
5386
+ defaultValue: true,
5387
+ label: "CRT",
5388
+ target: "style.crt",
5389
+ type: "switch",
5390
+ },
5391
+ guides: {
5392
+ defaultValue: false,
5393
+ label: "Guides",
5394
+ target: "overlay.guides",
5395
+ type: "checkbox",
5396
+ },
5397
+ },
5398
+ title: "Style",
5399
+ },
5400
+ ],
5401
+ title: "Controls",
5402
+ },
5403
+ },
5404
+ });
5405
+
5406
+ const errors = validateToolcraftAcceptanceCoverage(schemaWithContextLabels, [
5407
+ makeControlAcceptance("style.crt", "switch"),
5408
+ makeControlAcceptance("overlay.guides", "checkbox"),
5409
+ ]);
5410
+
5411
+ expect(errors).not.toEqual(
5412
+ expect.arrayContaining([
5413
+ expect.stringContaining("toggle labels must name the setting context only"),
5414
+ ]),
5415
+ );
5416
+ });
5417
+
5418
+ it("rejects binary control labels that duplicate their section title", () => {
5419
+ const schemaWithDuplicateToggleLabel = defineToolcraft({
5420
+ canvas: { enabled: true },
5421
+ panels: {
5422
+ controls: {
5423
+ sections: [
5424
+ {
5425
+ controls: {
5426
+ includeBackground: {
5427
+ defaultValue: true,
5428
+ label: "Background",
5429
+ target: "export.includeBackground",
5430
+ type: "switch",
5431
+ },
5432
+ },
5433
+ title: "Background",
5434
+ },
5435
+ ],
5436
+ title: "Controls",
5437
+ },
5438
+ },
5439
+ });
5440
+
5441
+ expect(
5442
+ validateToolcraftAcceptanceCoverage(schemaWithDuplicateToggleLabel, [
5443
+ makeControlAcceptance("export.includeBackground", "switch"),
5444
+ ]),
5445
+ ).toEqual(
5446
+ expect.arrayContaining([
5447
+ expect.stringContaining(
5448
+ 'export.includeBackground) toggle label "Background" duplicates section title "Background". Use a shorter contextual label such as "Include" or rename the toggle to a more specific setting.',
5449
+ ),
5450
+ ]),
5451
+ );
5452
+ });
5453
+
5454
+ it("rejects single Actions controls that duplicate their only button label", () => {
5455
+ const schemaWithDuplicateActionLabel = defineToolcraft({
5456
+ canvas: { enabled: true },
5457
+ panels: {
5458
+ controls: {
5459
+ sections: [
5460
+ {
5461
+ controls: {
5462
+ wash: {
5463
+ actions: [{ label: "Wash", value: "wash" }],
5464
+ defaultValue: null,
5465
+ label: "Wash",
5466
+ target: "flow.washSignal",
5467
+ type: "actions",
5468
+ },
5469
+ },
5470
+ title: "Flow",
5471
+ },
5472
+ ],
5473
+ title: "Controls",
5474
+ },
5475
+ },
5476
+ });
5477
+
5478
+ expect(
5479
+ validateToolcraftAcceptanceCoverage(schemaWithDuplicateActionLabel, [
5480
+ makeControlAcceptance("flow.washSignal", "actions"),
5481
+ ]),
5482
+ ).toEqual(
5483
+ expect.arrayContaining([
5484
+ expect.stringContaining(
5485
+ 'flow.washSignal) single Actions control label "Wash" duplicates its only button label "Wash". Keep the button as the command and use a short context label such as "Ink wash", "Palette action", or "Current layer".',
5486
+ ),
5487
+ ]),
5488
+ );
5489
+ });
5490
+
5491
+ it("allows single Actions controls with a concise context label", () => {
5492
+ const schemaWithContextActionLabel = defineToolcraft({
5493
+ canvas: { enabled: true },
5494
+ panels: {
5495
+ controls: {
5496
+ sections: [
5497
+ {
5498
+ controls: {
5499
+ wash: {
5500
+ actions: [{ label: "Wash", value: "wash" }],
5501
+ defaultValue: null,
5502
+ label: "Ink wash",
5503
+ target: "flow.washSignal",
5504
+ type: "actions",
5505
+ },
5506
+ },
5507
+ title: "Flow",
5508
+ },
5509
+ ],
5510
+ title: "Controls",
5511
+ },
5512
+ },
5513
+ });
5514
+
5515
+ const errors = validateToolcraftAcceptanceCoverage(schemaWithContextActionLabel, [
5516
+ makeControlAcceptance("flow.washSignal", "actions"),
5517
+ ]);
5518
+
5519
+ expect(errors).not.toEqual(
5520
+ expect.arrayContaining([
5521
+ expect.stringContaining("single Actions control label"),
5522
+ ]),
5523
+ );
5524
+ });
5525
+
5526
+ it("allows short Include toggle plus an unlabeled background color in the required row", () => {
5527
+ const schema = defineToolcraft({
5528
+ canvas: { enabled: true },
5529
+ panels: {
5530
+ controls: {
5531
+ sections: [
5532
+ {
5533
+ controls: {
5534
+ includeBackground: {
5535
+ defaultValue: true,
5536
+ description:
5537
+ "Controls preview and PNG background visibility while video keeps the background.",
5538
+ label: "Include",
5539
+ target: "export.includeBackground",
5540
+ type: "switch",
5541
+ },
5542
+ background: {
5543
+ defaultValue: "#0F0F0F",
5544
+ label: false,
5545
+ target: "appearance.background",
5546
+ type: "color",
5547
+ },
5548
+ },
5549
+ layoutGroups: [
5550
+ {
5551
+ columns: 2,
5552
+ controls: ["includeBackground", "background"],
5553
+ layout: "inline",
5554
+ },
5555
+ ],
5556
+ title: "Background",
5557
+ },
5558
+ ],
5559
+ title: "Controls",
5560
+ },
5561
+ },
5562
+ });
5563
+
5564
+ const errors = validateToolcraftAcceptanceCoverage(schema, [
5565
+ makeControlAcceptance("export.includeBackground", "switch"),
5566
+ makeControlAcceptance("appearance.background", "color"),
5567
+ ]);
5568
+
5569
+ expect(errors).not.toEqual(
5570
+ expect.arrayContaining([
5571
+ expect.stringContaining("duplicates section title"),
5572
+ expect.stringContaining("too long for a two-column toggle row"),
5573
+ expect.stringContaining("must use the short visible label"),
5574
+ expect.stringContaining("must use label false"),
5575
+ ]),
5576
+ );
5577
+ });
5578
+
5579
+ it("allows short visible toggle labels beside one related parameter", () => {
5580
+ const schema = defineToolcraft({
5581
+ canvas: { enabled: true },
5582
+ panels: {
5583
+ controls: {
5584
+ sections: [
5585
+ {
5586
+ controls: {
5587
+ loop: {
5588
+ defaultValue: true,
5589
+ label: "Loop",
5590
+ target: "animation.loop",
3766
5591
  type: "switch",
3767
5592
  },
3768
- guides: {
3769
- defaultValue: false,
3770
- label: "Disable guides",
3771
- target: "overlay.guides",
3772
- type: "checkbox",
5593
+ duration: {
5594
+ defaultValue: "8",
5595
+ label: false,
5596
+ target: "animation.duration",
5597
+ type: "text",
3773
5598
  },
3774
5599
  },
3775
- title: "Style",
5600
+ layoutGroups: [
5601
+ {
5602
+ columns: 2,
5603
+ controls: ["loop", "duration"],
5604
+ layout: "inline",
5605
+ },
5606
+ ],
5607
+ title: "Playback",
3776
5608
  },
3777
5609
  ],
3778
5610
  title: "Controls",
@@ -3780,43 +5612,32 @@ describe("Toolcraft template app acceptance coverage", () => {
3780
5612
  },
3781
5613
  });
3782
5614
 
3783
- expect(
3784
- validateToolcraftAcceptanceCoverage(schemaWithActionLabels, [
3785
- makeControlAcceptance("style.crt", "switch"),
3786
- makeControlAcceptance("overlay.guides", "checkbox"),
3787
- ]),
3788
- ).toEqual(
3789
- expect.arrayContaining([
3790
- expect.stringContaining(
3791
- 'style.crt) toggle labels must name the setting context only; use "CRT", "Background", "Glow", or "Loop" instead of "Enable CRT".',
3792
- ),
3793
- expect.stringContaining(
3794
- 'overlay.guides) toggle labels must name the setting context only; use "CRT", "Background", "Glow", or "Loop" instead of "Disable guides".',
3795
- ),
3796
- ]),
5615
+ const errors = validateToolcraftAcceptanceCoverage(schema, [
5616
+ makeControlAcceptance("animation.loop", "switch"),
5617
+ makeControlAcceptance("animation.duration", "text"),
5618
+ ]);
5619
+
5620
+ expect(errors).not.toEqual(
5621
+ expect.arrayContaining([expect.stringContaining("compact toggle-plus-parameter row")]),
3797
5622
  );
5623
+ });
3798
5624
 
3799
- const schemaWithContextLabels = defineToolcraft({
5625
+ it("rejects CodeTextarea for short single-line text content", () => {
5626
+ const schema = defineToolcraft({
3800
5627
  canvas: { enabled: true },
3801
5628
  panels: {
3802
5629
  controls: {
3803
5630
  sections: [
3804
5631
  {
3805
5632
  controls: {
3806
- crt: {
3807
- defaultValue: true,
3808
- label: "CRT",
3809
- target: "style.crt",
3810
- type: "switch",
3811
- },
3812
- guides: {
3813
- defaultValue: false,
3814
- label: "Guides",
3815
- target: "overlay.guides",
3816
- type: "checkbox",
5633
+ buttonText: {
5634
+ defaultValue: "Glass",
5635
+ label: "Text",
5636
+ target: "button.text",
5637
+ type: "code",
3817
5638
  },
3818
5639
  },
3819
- title: "Style",
5640
+ title: "Button",
3820
5641
  },
3821
5642
  ],
3822
5643
  title: "Controls",
@@ -3824,34 +5645,35 @@ describe("Toolcraft template app acceptance coverage", () => {
3824
5645
  },
3825
5646
  });
3826
5647
 
3827
- const errors = validateToolcraftAcceptanceCoverage(schemaWithContextLabels, [
3828
- makeControlAcceptance("style.crt", "switch"),
3829
- makeControlAcceptance("overlay.guides", "checkbox"),
3830
- ]);
3831
-
3832
- expect(errors).not.toEqual(
5648
+ expect(
5649
+ validateToolcraftAcceptanceCoverage(schema, [
5650
+ makeControlAcceptance("button.text", "code"),
5651
+ ]),
5652
+ ).toEqual(
3833
5653
  expect.arrayContaining([
3834
- expect.stringContaining("toggle labels must name the setting context only"),
5654
+ expect.stringContaining("uses CodeTextarea for short single-line text"),
3835
5655
  ]),
3836
5656
  );
3837
5657
  });
3838
5658
 
3839
- it("rejects binary control labels that duplicate their section title", () => {
3840
- const schemaWithDuplicateToggleLabel = defineToolcraft({
5659
+ it("allows CodeTextarea when a short default documents long multiline intent", () => {
5660
+ const schema = defineToolcraft({
3841
5661
  canvas: { enabled: true },
3842
5662
  panels: {
3843
5663
  controls: {
3844
5664
  sections: [
3845
5665
  {
3846
5666
  controls: {
3847
- includeBackground: {
3848
- defaultValue: true,
3849
- label: "Background",
3850
- target: "export.includeBackground",
3851
- type: "switch",
5667
+ prompt: {
5668
+ defaultValue: "Describe the scene",
5669
+ description:
5670
+ "Long multiline prompt content for generated output.",
5671
+ label: "Prompt",
5672
+ target: "generation.prompt",
5673
+ type: "code",
3852
5674
  },
3853
5675
  },
3854
- title: "Background",
5676
+ title: "Generation",
3855
5677
  },
3856
5678
  ],
3857
5679
  title: "Controls",
@@ -3860,19 +5682,17 @@ describe("Toolcraft template app acceptance coverage", () => {
3860
5682
  });
3861
5683
 
3862
5684
  expect(
3863
- validateToolcraftAcceptanceCoverage(schemaWithDuplicateToggleLabel, [
3864
- makeControlAcceptance("export.includeBackground", "switch"),
5685
+ validateToolcraftAcceptanceCoverage(schema, [
5686
+ makeControlAcceptance("generation.prompt", "code"),
3865
5687
  ]),
3866
- ).toEqual(
5688
+ ).not.toEqual(
3867
5689
  expect.arrayContaining([
3868
- expect.stringContaining(
3869
- 'export.includeBackground) toggle label "Background" duplicates section title "Background". Use a shorter contextual label such as "Include" or rename the toggle to a more specific setting.',
3870
- ),
5690
+ expect.stringContaining("uses CodeTextarea for short single-line text"),
3871
5691
  ]),
3872
5692
  );
3873
5693
  });
3874
5694
 
3875
- it("allows short Include toggle plus an unlabeled background color in the required row", () => {
5695
+ it("rejects visible parameter labels beside one related toggle", () => {
3876
5696
  const schema = defineToolcraft({
3877
5697
  canvas: { enabled: true },
3878
5698
  panels: {
@@ -3880,29 +5700,27 @@ describe("Toolcraft template app acceptance coverage", () => {
3880
5700
  sections: [
3881
5701
  {
3882
5702
  controls: {
3883
- includeBackground: {
5703
+ loop: {
3884
5704
  defaultValue: true,
3885
- description:
3886
- "Controls preview and PNG background visibility while video keeps the background.",
3887
- label: "Include",
3888
- target: "export.includeBackground",
5705
+ label: "Loop",
5706
+ target: "animation.loop",
3889
5707
  type: "switch",
3890
5708
  },
3891
- background: {
3892
- defaultValue: "#0F0F0F",
3893
- label: false,
3894
- target: "appearance.background",
3895
- type: "color",
5709
+ duration: {
5710
+ defaultValue: "8",
5711
+ label: "Duration",
5712
+ target: "animation.duration",
5713
+ type: "text",
3896
5714
  },
3897
5715
  },
3898
5716
  layoutGroups: [
3899
5717
  {
3900
5718
  columns: 2,
3901
- controls: ["includeBackground", "background"],
5719
+ controls: ["loop", "duration"],
3902
5720
  layout: "inline",
3903
5721
  },
3904
5722
  ],
3905
- title: "Background",
5723
+ title: "Playback",
3906
5724
  },
3907
5725
  ],
3908
5726
  title: "Controls",
@@ -3910,22 +5728,21 @@ describe("Toolcraft template app acceptance coverage", () => {
3910
5728
  },
3911
5729
  });
3912
5730
 
3913
- const errors = validateToolcraftAcceptanceCoverage(schema, [
3914
- makeControlAcceptance("export.includeBackground", "switch"),
3915
- makeControlAcceptance("appearance.background", "color"),
3916
- ]);
3917
-
3918
- expect(errors).not.toEqual(
5731
+ expect(
5732
+ validateToolcraftAcceptanceCoverage(schema, [
5733
+ makeControlAcceptance("animation.loop", "switch"),
5734
+ makeControlAcceptance("animation.duration", "text"),
5735
+ ]),
5736
+ ).toEqual(
3919
5737
  expect.arrayContaining([
3920
- expect.stringContaining("duplicates section title"),
3921
- expect.stringContaining("too long for a two-column toggle row"),
3922
- expect.stringContaining("must use the short visible label"),
3923
- expect.stringContaining("must use label false"),
5738
+ expect.stringContaining(
5739
+ 'Playback layoutGroups inline row "loop, duration" pairs a toggle with parameter labels duration "Duration". In toggle-plus-parameter rows, the non-toggle parameter must use label false; if that label is needed, stack the controls instead.',
5740
+ ),
3924
5741
  ]),
3925
5742
  );
3926
5743
  });
3927
5744
 
3928
- it("allows short visible toggle labels beside one related parameter", () => {
5745
+ it("rejects segmented controls in inline half-width layout rows", () => {
3929
5746
  const schema = defineToolcraft({
3930
5747
  canvas: { enabled: true },
3931
5748
  panels: {
@@ -3933,27 +5750,31 @@ describe("Toolcraft template app acceptance coverage", () => {
3933
5750
  sections: [
3934
5751
  {
3935
5752
  controls: {
3936
- loop: {
5753
+ includeText: {
3937
5754
  defaultValue: true,
3938
- label: "Loop",
3939
- target: "animation.loop",
5755
+ label: "Include",
5756
+ target: "text.enabled",
3940
5757
  type: "switch",
3941
5758
  },
3942
- duration: {
3943
- defaultValue: "8",
3944
- label: "Duration",
3945
- target: "animation.duration",
3946
- type: "text",
5759
+ dragTarget: {
5760
+ defaultValue: "glass",
5761
+ label: "Drag",
5762
+ options: [
5763
+ { label: "Glass", value: "glass" },
5764
+ { label: "Text", value: "text" },
5765
+ ],
5766
+ target: "text.dragTarget",
5767
+ type: "segmented",
3947
5768
  },
3948
5769
  },
3949
5770
  layoutGroups: [
3950
5771
  {
3951
5772
  columns: 2,
3952
- controls: ["loop", "duration"],
5773
+ controls: ["includeText", "dragTarget"],
3953
5774
  layout: "inline",
3954
5775
  },
3955
5776
  ],
3956
- title: "Playback",
5777
+ title: "Glass Text",
3957
5778
  },
3958
5779
  ],
3959
5780
  title: "Controls",
@@ -3961,13 +5782,17 @@ describe("Toolcraft template app acceptance coverage", () => {
3961
5782
  },
3962
5783
  });
3963
5784
 
3964
- const errors = validateToolcraftAcceptanceCoverage(schema, [
3965
- makeControlAcceptance("animation.loop", "switch"),
3966
- makeControlAcceptance("animation.duration", "text"),
3967
- ]);
3968
-
3969
- expect(errors).not.toEqual(
3970
- expect.arrayContaining([expect.stringContaining("compact toggle-plus-parameter row")]),
5785
+ expect(
5786
+ validateToolcraftAcceptanceCoverage(schema, [
5787
+ makeControlAcceptance("text.enabled", "switch"),
5788
+ makeControlAcceptance("text.dragTarget", "segmented"),
5789
+ ]),
5790
+ ).toEqual(
5791
+ expect.arrayContaining([
5792
+ expect.stringContaining(
5793
+ 'Glass Text layoutGroups inline row "includeText, dragTarget" includes segmented control dragTarget. Segmented is full-width and must not share a two-column or half-width row; use Select when a finite choice must fit beside another control.',
5794
+ ),
5795
+ ]),
3971
5796
  );
3972
5797
  });
3973
5798
 
@@ -4185,44 +6010,227 @@ describe("Toolcraft template app acceptance coverage", () => {
4185
6010
  title: "Output",
4186
6011
  },
4187
6012
  ],
4188
- title: "Controls",
6013
+ title: "Controls",
6014
+ },
6015
+ },
6016
+ });
6017
+
6018
+ expect(
6019
+ validateToolcraftAcceptanceCoverage(schema, [
6020
+ makeControlAcceptance("output.background", "switch"),
6021
+ makeControlAcceptance("debug.diagnosticOverlay", "switch"),
6022
+ ]),
6023
+ ).toEqual(
6024
+ expect.arrayContaining([
6025
+ 'Output layoutGroups inline row "background, diagnosticOverlay" includes switch labels diagnosticOverlay "Diagnostic overlay" that are too long for a two-column toggle row. Switches share a row only when every visible label fits without truncation; shorten labels or stack them.',
6026
+ ]),
6027
+ );
6028
+ });
6029
+
6030
+ it("requires explicit reference behavior coverage in reference-runtime-clone mode", () => {
6031
+ expect(
6032
+ validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
6033
+ behaviorCoverage: [
6034
+ "canvas-sizing",
6035
+ "control-mapping",
6036
+ "renderer-state",
6037
+ "renderer-loop",
6038
+ ],
6039
+ mode: "reference-runtime-clone",
6040
+ referenceName: "legacy badge wall",
6041
+ sourceOfTruth: "reference-runtime",
6042
+ } as never),
6043
+ ).toEqual(
6044
+ expect.arrayContaining([
6045
+ 'reference-runtime-clone behaviorCoverage "canvas-sizing" is missing an acceptance entry with referenceCoverage "canvas-sizing".',
6046
+ 'reference-runtime-clone behaviorCoverage "control-mapping" is missing an acceptance entry with referenceCoverage "control-mapping".',
6047
+ 'reference-runtime-clone behaviorCoverage "renderer-state" is missing an acceptance entry with referenceCoverage "renderer-state".',
6048
+ 'reference-runtime-clone behaviorCoverage "renderer-loop" is missing an acceptance entry with referenceCoverage "renderer-loop".',
6049
+ 'reference-runtime-clone transferMode must declare referenceTimeline with mode "none", "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline".',
6050
+ ]),
6051
+ );
6052
+ });
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",
4189
6168
  },
4190
- },
4191
- });
4192
-
4193
- expect(
4194
- validateToolcraftAcceptanceCoverage(schema, [
4195
- makeControlAcceptance("output.background", "switch"),
4196
- makeControlAcceptance("debug.diagnosticOverlay", "switch"),
4197
- ]),
6169
+ ),
4198
6170
  ).toEqual(
4199
6171
  expect.arrayContaining([
4200
- 'Output layoutGroups inline row "background, diagnosticOverlay" includes switch labels diagnosticOverlay "Diagnostic overlay" that are too long for a two-column toggle row. Switches share a row only when every visible label fits without truncation; shorten labels or stack them.',
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.',
4201
6174
  ]),
4202
6175
  );
4203
6176
  });
4204
6177
 
4205
- it("requires explicit reference behavior coverage in reference-runtime-clone mode", () => {
6178
+ it("requires feature-level behavior evidence for reference inventory items", () => {
4206
6179
  expect(
4207
- validateToolcraftAcceptanceCoverage(starterSchema, starterAcceptance, {
4208
- behaviorCoverage: [
4209
- "canvas-sizing",
4210
- "control-mapping",
4211
- "renderer-state",
4212
- "renderer-loop",
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"),
4213
6187
  ],
4214
- mode: "reference-runtime-clone",
4215
- referenceName: "legacy badge wall",
4216
- sourceOfTruth: "reference-runtime",
4217
- } as never),
4218
- ).toEqual(
4219
- expect.arrayContaining([
4220
- 'reference-runtime-clone behaviorCoverage "canvas-sizing" is missing an acceptance entry with referenceCoverage "canvas-sizing".',
4221
- 'reference-runtime-clone behaviorCoverage "control-mapping" is missing an acceptance entry with referenceCoverage "control-mapping".',
4222
- 'reference-runtime-clone behaviorCoverage "renderer-state" is missing an acceptance entry with referenceCoverage "renderer-state".',
4223
- 'reference-runtime-clone behaviorCoverage "renderer-loop" is missing an acceptance entry with referenceCoverage "renderer-loop".',
4224
- 'reference-runtime-clone transferMode must declare referenceTimeline with mode "none", "toolcraft-playback", "toolcraft-keyframes", or "custom-reference-timeline".',
4225
- ]),
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.',
4226
6234
  );
4227
6235
  });
4228
6236
 
@@ -4360,7 +6368,9 @@ describe("Toolcraft template app acceptance coverage", () => {
4360
6368
  {
4361
6369
  behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
4362
6370
  mode: "reference-runtime-clone",
6371
+ referenceFeatureInventory: makeReferenceFeatureInventory(),
4363
6372
  referenceName: "legacy badge wall",
6373
+ referenceStudy: referenceStudyEvidence,
4364
6374
  referenceTimeline: { behaviorCoverage: [], mode: "none" },
4365
6375
  sourceOfTruth: "reference-runtime",
4366
6376
  },
@@ -4421,6 +6431,130 @@ describe("Toolcraft template app acceptance coverage", () => {
4421
6431
  );
4422
6432
  });
4423
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.',
6469
+ );
6470
+ });
6471
+
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
+
6481
+ expect(
6482
+ validateToolcraftAcceptanceCoverage(
6483
+ schemaWithPlaybackTimeline,
6484
+ [
6485
+ ...starterAcceptance,
6486
+ {
6487
+ ...playbackTimelineAcceptance,
6488
+ id: "reference.timeline.playback",
6489
+ referenceTimelineCoverage: "playback",
6490
+ },
6491
+ ],
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
+ ),
6514
+ ).toContain(
6515
+ "panels.timeline.defaultDurationSeconds (8) must match referenceTimeline.loopDuration.seconds (6).",
6516
+ );
6517
+ });
6518
+
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
+
6528
+ expect(
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
+ ),
6553
+ ).toContain(
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.',
6555
+ );
6556
+ });
6557
+
4424
6558
  it("rejects right-panel transport controls when timeline playback owns transport", () => {
4425
6559
  const schemaWithPanelTransportControls = {
4426
6560
  ...starterSchema,
@@ -4457,7 +6591,7 @@ describe("Toolcraft template app acceptance coverage", () => {
4457
6591
  ],
4458
6592
  title: "Controls",
4459
6593
  },
4460
- timeline: { enabled: true, mode: "playback" as const },
6594
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
4461
6595
  },
4462
6596
  } as const;
4463
6597
 
@@ -4589,10 +6723,13 @@ describe("Toolcraft template app acceptance coverage", () => {
4589
6723
  });
4590
6724
 
4591
6725
  it("accepts explicit autonomous animation intent for decorative self-running output", () => {
4592
- const schemaWithAutonomousAnimation = {
4593
- ...starterSchema,
6726
+ const schemaWithAutonomousAnimation = defineToolcraft({
6727
+ canvas: {
6728
+ enabled: true,
6729
+ sizing: { mode: "editable-output" },
6730
+ upload: true,
6731
+ },
4594
6732
  panels: {
4595
- ...starterSchema.panels,
4596
6733
  controls: {
4597
6734
  sections: [
4598
6735
  {
@@ -4615,7 +6752,12 @@ describe("Toolcraft template app acceptance coverage", () => {
4615
6752
  },
4616
6753
  timeline: undefined,
4617
6754
  },
4618
- } as const;
6755
+ toolbar: {
6756
+ history: true,
6757
+ radar: true,
6758
+ zoom: true,
6759
+ },
6760
+ });
4619
6761
 
4620
6762
  expect(
4621
6763
  validateToolcraftAcceptanceCoverage(
@@ -4633,12 +6775,109 @@ describe("Toolcraft template app acceptance coverage", () => {
4633
6775
  ],
4634
6776
  mode: "autonomous",
4635
6777
  reason:
4636
- "The shimmer is decorative self-running output and does not expose product time transport.",
6778
+ "The shimmer is decorative self-running output and does not expose product time transport.",
6779
+ },
6780
+ mode: "new-toolcraft-app",
6781
+ },
6782
+ ),
6783
+ ).toEqual([]);
6784
+ });
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.",
4637
6874
  },
4638
6875
  mode: "new-toolcraft-app",
4639
6876
  },
4640
6877
  ),
4641
- ).toEqual([]);
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
+ );
4642
6881
  });
4643
6882
 
4644
6883
  it("rejects timeline animation intent when the timeline mode does not match", () => {
@@ -4646,7 +6885,7 @@ describe("Toolcraft template app acceptance coverage", () => {
4646
6885
  ...starterSchema,
4647
6886
  panels: {
4648
6887
  ...starterSchema.panels,
4649
- timeline: { enabled: true, mode: "playback" as const },
6888
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
4650
6889
  },
4651
6890
  };
4652
6891
 
@@ -4655,7 +6894,10 @@ describe("Toolcraft template app acceptance coverage", () => {
4655
6894
  schemaWithPlaybackTimeline,
4656
6895
  [playbackTimelineAcceptance],
4657
6896
  {
4658
- animationIntent: { mode: "timeline-keyframes" },
6897
+ animationIntent: {
6898
+ loopDuration: productDerivedEightSecondLoop,
6899
+ mode: "timeline-keyframes",
6900
+ },
4659
6901
  mode: "new-toolcraft-app",
4660
6902
  },
4661
6903
  ),
@@ -4771,7 +7013,36 @@ describe("Toolcraft template app acceptance coverage", () => {
4771
7013
  {
4772
7014
  behaviorCoverage: ["canvas-sizing", "control-mapping", "renderer-state"],
4773
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
+ ]),
4774
7044
  referenceName: "legacy state timeline",
7045
+ referenceStudy: referenceStudyEvidence,
4775
7046
  referenceTimeline: {
4776
7047
  behaviorCoverage: ["state-jump", "trim-range"],
4777
7048
  mode: "custom-reference-timeline",
@@ -5023,7 +7294,7 @@ describe("Toolcraft template app acceptance coverage", () => {
5023
7294
  }
5024
7295
  });
5025
7296
 
5026
- 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", () => {
5027
7298
  const browserTestSources = readBrowserTestSources();
5028
7299
 
5029
7300
  for (const entry of starterAcceptance) {
@@ -5033,7 +7304,7 @@ describe("Toolcraft template app acceptance coverage", () => {
5033
7304
 
5034
7305
  expect(
5035
7306
  browserTestSources,
5036
- `${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}".`,
5037
7308
  ).toContain(entry.browserTestName);
5038
7309
 
5039
7310
  if (entry.timelineCoverage === "playback" && acceptanceCoversTimelineDurationEdit(entry)) {
@@ -5110,10 +7381,13 @@ describe("Toolcraft template app acceptance coverage", () => {
5110
7381
  });
5111
7382
 
5112
7383
  it("accepts stepped continuous sliders without forcing the discrete visual variant", () => {
5113
- const schemaWithAmbiguousSlider = {
5114
- ...starterSchema,
7384
+ const schemaWithAmbiguousSlider = defineToolcraft({
7385
+ canvas: {
7386
+ enabled: true,
7387
+ sizing: { mode: "editable-output" },
7388
+ upload: true,
7389
+ },
5115
7390
  panels: {
5116
- ...starterSchema.panels,
5117
7391
  controls: {
5118
7392
  sections: [
5119
7393
  {
@@ -5136,7 +7410,12 @@ describe("Toolcraft template app acceptance coverage", () => {
5136
7410
  },
5137
7411
  timeline: undefined,
5138
7412
  },
5139
- };
7413
+ toolbar: {
7414
+ history: true,
7415
+ radar: true,
7416
+ zoom: true,
7417
+ },
7418
+ });
5140
7419
 
5141
7420
  expect(
5142
7421
  validateToolcraftAcceptanceCoverage(schemaWithAmbiguousSlider, [
@@ -5252,7 +7531,6 @@ describe("Toolcraft template app acceptance coverage", () => {
5252
7531
  step: 0.1,
5253
7532
  target: "animation.speed",
5254
7533
  type: "slider",
5255
- unit: "x",
5256
7534
  variant: "continuous",
5257
7535
  },
5258
7536
  depth: {
@@ -5626,8 +7904,8 @@ describe("Toolcraft template app acceptance coverage", () => {
5626
7904
  controls: schema.panels.controls
5627
7905
  ? {
5628
7906
  ...schema.panels.controls,
5629
- sections: schema.panels.controls.sections.map((section, index) =>
5630
- index === 0 ? { ...section, title: undefined } : section,
7907
+ sections: schema.panels.controls.sections.map((section) =>
7908
+ section.title === "Shader" ? { ...section, title: undefined } : section,
5631
7909
  ),
5632
7910
  }
5633
7911
  : schema.panels.controls,
@@ -5640,7 +7918,7 @@ describe("Toolcraft template app acceptance coverage", () => {
5640
7918
  ]),
5641
7919
  ).toEqual(
5642
7920
  expect.arrayContaining([
5643
- "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.",
5644
7922
  ]),
5645
7923
  );
5646
7924
  });
@@ -6145,7 +8423,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6145
8423
  ]),
6146
8424
  ).toEqual(
6147
8425
  expect.arrayContaining([
6148
- 'Controls for product entity "squares.right" are split across sections: Square 1 (Right), Appearance. Keep controls for the same product entity in one semantic section unless the spec names a real workflow split.',
8426
+ 'Controls for product entity "squares.right" are split across sections: Square 1 (Right), Appearance. Keep controls for the same product entity in one semantic section unless the Control Section Inventory declares workflowStage and splitReason for every split section.',
6149
8427
  ]),
6150
8428
  );
6151
8429
  });
@@ -6201,6 +8479,252 @@ describe("Toolcraft template app acceptance coverage", () => {
6201
8479
  ).toEqual([]);
6202
8480
  });
6203
8481
 
8482
+ it("rejects stale control section inventory entries", () => {
8483
+ const schemaWithInventoryMismatch = defineToolcraft({
8484
+ canvas: { enabled: true },
8485
+ panels: {
8486
+ controls: {
8487
+ sections: [
8488
+ {
8489
+ controls: {
8490
+ kind: {
8491
+ defaultValue: "soft",
8492
+ label: "Kind",
8493
+ options: [
8494
+ { label: "Soft", value: "soft" },
8495
+ { label: "Sharp", value: "sharp" },
8496
+ ],
8497
+ orderRole: "mode",
8498
+ target: "shape.kind",
8499
+ type: "select",
8500
+ },
8501
+ count: {
8502
+ defaultValue: 12,
8503
+ label: "Count",
8504
+ max: 40,
8505
+ min: 1,
8506
+ orderRole: "detail",
8507
+ target: "shape.count",
8508
+ type: "slider",
8509
+ variant: "continuous",
8510
+ },
8511
+ },
8512
+ title: "Shape",
8513
+ },
8514
+ ],
8515
+ title: "Controls",
8516
+ },
8517
+ },
8518
+ });
8519
+
8520
+ expect(
8521
+ validateToolcraftAcceptanceCoverage(
8522
+ schemaWithInventoryMismatch,
8523
+ [
8524
+ makeControlAcceptance("shape.kind", "select"),
8525
+ makeControlAcceptance("shape.count", "slider"),
8526
+ ],
8527
+ starterTransferMode,
8528
+ [
8529
+ {
8530
+ entity: "Shape",
8531
+ groupingReason: "Shape setup",
8532
+ targets: ["shape.kind"],
8533
+ title: "Shape",
8534
+ },
8535
+ ],
8536
+ ),
8537
+ ).toEqual(
8538
+ expect.arrayContaining([
8539
+ 'Control Section Inventory entry "Shape" must include a concrete groupingReason explaining why these controls belong together.',
8540
+ 'Control Section Inventory entry "Shape" is missing rendered target "shape.count". The inventory must cover every product control in the section.',
8541
+ ]),
8542
+ );
8543
+ });
8544
+
8545
+ it("allows explicit workflow split evidence for one target entity", () => {
8546
+ const schemaWithWorkflowSplit = defineToolcraft({
8547
+ canvas: { enabled: true },
8548
+ panels: {
8549
+ controls: {
8550
+ sections: [
8551
+ {
8552
+ controls: {
8553
+ size: {
8554
+ defaultValue: 64,
8555
+ label: "Size",
8556
+ max: 128,
8557
+ min: 16,
8558
+ orderRole: "primary",
8559
+ target: "object.shape.size",
8560
+ type: "slider",
8561
+ unit: "px",
8562
+ variant: "continuous",
8563
+ },
8564
+ },
8565
+ title: "Shape Structure",
8566
+ },
8567
+ {
8568
+ controls: {
8569
+ count: {
8570
+ defaultValue: 12,
8571
+ label: "Count",
8572
+ max: 40,
8573
+ min: 1,
8574
+ orderRole: "detail",
8575
+ target: "object.shape.count",
8576
+ type: "slider",
8577
+ variant: "continuous",
8578
+ },
8579
+ },
8580
+ title: "Shape Density",
8581
+ },
8582
+ ],
8583
+ title: "Controls",
8584
+ },
8585
+ },
8586
+ });
8587
+
8588
+ expect(
8589
+ validateToolcraftAcceptanceCoverage(
8590
+ schemaWithWorkflowSplit,
8591
+ [
8592
+ makeControlAcceptance("object.shape.size", "slider"),
8593
+ makeControlAcceptance("object.shape.count", "slider"),
8594
+ ],
8595
+ starterTransferMode,
8596
+ [
8597
+ {
8598
+ entity: "Object shape",
8599
+ groupingReason: "Structure controls tune the physical footprint of the object.",
8600
+ splitReason:
8601
+ "Structure and density are separate workflow stages in this editor.",
8602
+ targets: ["object.shape.size"],
8603
+ title: "Shape Structure",
8604
+ workflowStage: "structure",
8605
+ },
8606
+ {
8607
+ entity: "Object shape",
8608
+ groupingReason: "Density controls tune how many objects appear after structure is set.",
8609
+ splitReason:
8610
+ "Density is separated from structure because users set count after the shape footprint.",
8611
+ targets: ["object.shape.count"],
8612
+ title: "Shape Density",
8613
+ workflowStage: "density",
8614
+ },
8615
+ ],
8616
+ ),
8617
+ ).toEqual([]);
8618
+ });
8619
+
8620
+ it("rejects mode-gated controls split away from their selector section", () => {
8621
+ const schemaWithSplitModeBranch = defineToolcraft({
8622
+ canvas: { enabled: true },
8623
+ panels: {
8624
+ controls: {
8625
+ sections: [
8626
+ {
8627
+ controls: {
8628
+ sourceMode: {
8629
+ defaultValue: "preset",
8630
+ label: "Source",
8631
+ options: [
8632
+ { label: "Preset", value: "preset" },
8633
+ { label: "Image", value: "image" },
8634
+ ],
8635
+ orderRole: "mode",
8636
+ target: "source.mode",
8637
+ type: "segmented",
8638
+ },
8639
+ },
8640
+ title: "Source",
8641
+ },
8642
+ {
8643
+ controls: {
8644
+ sourceUpload: {
8645
+ accept: "image/*",
8646
+ assetKind: "image",
8647
+ defaultValue: null,
8648
+ label: "Image",
8649
+ orderRole: "input",
8650
+ target: "source.upload",
8651
+ type: "fileDrop",
8652
+ visibleWhen: { equals: "image", target: "source.mode" },
8653
+ },
8654
+ },
8655
+ title: "Image",
8656
+ },
8657
+ ],
8658
+ title: "Controls",
8659
+ },
8660
+ },
8661
+ });
8662
+
8663
+ expect(
8664
+ validateToolcraftAcceptanceCoverage(schemaWithSplitModeBranch, [
8665
+ makeControlAcceptance("source.mode", "segmented"),
8666
+ makeControlAcceptance("source.upload", "fileDrop"),
8667
+ ]),
8668
+ ).toEqual(
8669
+ expect.arrayContaining([
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.',
8671
+ ]),
8672
+ );
8673
+ });
8674
+
8675
+ it("rejects selector branch sections even when targets do not share a prefix", () => {
8676
+ const schemaWithSplitOptionBranch = defineToolcraft({
8677
+ canvas: { enabled: true },
8678
+ panels: {
8679
+ controls: {
8680
+ sections: [
8681
+ {
8682
+ controls: {
8683
+ shapeKind: {
8684
+ defaultValue: "generated",
8685
+ label: "Shape",
8686
+ options: [
8687
+ { label: "Generated", value: "generated" },
8688
+ { label: "Library", value: "library" },
8689
+ ],
8690
+ orderRole: "mode",
8691
+ target: "shape.kind",
8692
+ type: "segmented",
8693
+ },
8694
+ },
8695
+ title: "Shape",
8696
+ },
8697
+ {
8698
+ controls: {
8699
+ libraryAsset: {
8700
+ defaultValue: null,
8701
+ label: "Library",
8702
+ orderRole: "input",
8703
+ target: "asset.upload",
8704
+ type: "fileDrop",
8705
+ visibleWhen: { equals: "library", target: "shape.kind" },
8706
+ },
8707
+ },
8708
+ title: "Library",
8709
+ },
8710
+ ],
8711
+ title: "Controls",
8712
+ },
8713
+ },
8714
+ });
8715
+
8716
+ expect(
8717
+ validateToolcraftAcceptanceCoverage(schemaWithSplitOptionBranch, [
8718
+ makeControlAcceptance("shape.kind", "segmented"),
8719
+ makeControlAcceptance("asset.upload", "fileDrop"),
8720
+ ]),
8721
+ ).toEqual(
8722
+ expect.arrayContaining([
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.',
8724
+ ]),
8725
+ );
8726
+ });
8727
+
6204
8728
  it("rejects splitting FontPicker-owned typography into sibling controls", () => {
6205
8729
  const schemaWithSplitTypographyBlock = defineToolcraft({
6206
8730
  canvas: { enabled: true },
@@ -6548,38 +9072,109 @@ describe("Toolcraft template app acceptance coverage", () => {
6548
9072
  ],
6549
9073
  title: "Controls",
6550
9074
  },
6551
- },
6552
- });
6553
-
6554
- expect(
6555
- validateToolcraftAcceptanceCoverage(schemaWithUsefulColorLabels, [
6556
- makeControlAcceptance("palette.accent1", "color"),
6557
- makeControlAcceptance("palette.accent2", "color"),
6558
- makeControlAcceptance("palette.spread", "slider"),
6559
- makeControlAcceptance("object.fill", "color"),
6560
- 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
+ },
6561
9158
  ]),
6562
- ).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
+ );
6563
9164
  });
6564
9165
 
6565
- it("requires mode selectors to appear before dependent controls", () => {
6566
- const schemaWithLateModeSelector = {
6567
- ...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
+ },
6568
9173
  panels: {
6569
- ...starterSchema.panels,
6570
9174
  controls: {
6571
9175
  sections: [
6572
9176
  {
6573
9177
  controls: {
6574
- depth: {
6575
- defaultValue: 0.64,
6576
- label: "Depth",
6577
- max: 1,
6578
- min: 0,
6579
- target: "shader.depth",
6580
- type: "slider",
6581
- variant: "continuous",
6582
- },
6583
9178
  blend: {
6584
9179
  defaultValue: "liquid",
6585
9180
  label: "Blend",
@@ -6588,9 +9183,20 @@ describe("Toolcraft template app acceptance coverage", () => {
6588
9183
  { label: "Liquid", value: "liquid" },
6589
9184
  { label: "Crystal", value: "crystal" },
6590
9185
  ],
9186
+ orderRole: "mode" as const,
6591
9187
  target: "shader.blend",
6592
9188
  type: "segmented",
6593
9189
  },
9190
+ depth: {
9191
+ defaultValue: 0.64,
9192
+ label: "Depth",
9193
+ max: 1,
9194
+ min: 0,
9195
+ orderRole: "strength" as const,
9196
+ target: "shader.depth",
9197
+ type: "slider",
9198
+ variant: "continuous",
9199
+ },
6594
9200
  },
6595
9201
  title: "Volume",
6596
9202
  },
@@ -6598,10 +9204,34 @@ describe("Toolcraft template app acceptance coverage", () => {
6598
9204
  title: "Shader",
6599
9205
  },
6600
9206
  },
6601
- };
9207
+ toolbar: {
9208
+ history: true,
9209
+ radar: true,
9210
+ zoom: true,
9211
+ },
9212
+ });
6602
9213
 
9214
+ expect(getToolcraftControlOrderTargets(schemaWithOrderedControls)).toEqual([
9215
+ "shader.blend",
9216
+ "shader.depth",
9217
+ ]);
6603
9218
  expect(
6604
- 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
+ },
6605
9235
  {
6606
9236
  automated: true,
6607
9237
  automatedTestName: "depth changes rendered output",
@@ -6616,188 +9246,484 @@ describe("Toolcraft template app acceptance coverage", () => {
6616
9246
  target: "shader.depth",
6617
9247
  userAction: "Drag the Depth slider.",
6618
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, [
6619
9287
  {
6620
9288
  automated: true,
6621
- automatedTestName: "blend changes rendered output",
9289
+ automatedTestName: "fx preset changes rendered output",
6622
9290
  browser: true,
6623
- browserTestName: "browser: blend selector changes rendered output",
9291
+ browserTestName: "browser: fx preset selector changes rendered output",
6624
9292
  componentType: "segmented",
6625
9293
  evidence: "rendered-pixels",
6626
- expectedObservable: "Changing Blend switches shader pattern.",
6627
- fixture: "blend fixture",
6628
- id: "shader.blend",
9294
+ expectedObservable: "Changing FX Preset switches the shader preset.",
9295
+ fixture: "fx preset fixture",
9296
+ id: "shader.fxPreset",
6629
9297
  kind: "control",
6630
9298
  optionCoverage: "each-visible-item",
6631
- target: "shader.blend",
6632
- userAction: "Select each Blend option.",
9299
+ target: "shader.fxPreset",
9300
+ userAction: "Select each FX Preset option.",
6633
9301
  },
6634
9302
  ]),
6635
9303
  ).toEqual(
6636
9304
  expect.arrayContaining([
6637
- 'Volume / blend (shader.blend) 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
+ },
6638
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).",
6639
9457
  );
6640
9458
  });
6641
9459
 
6642
- it("accepts explicit order roles when selectors lead dependent controls", () => {
6643
- const schemaWithOrderedControls = {
9460
+ it("rejects runtime fallback evidence as a loop duration source", () => {
9461
+ const schemaWithPlaybackTimeline = {
6644
9462
  ...starterSchema,
6645
9463
  panels: {
6646
9464
  ...starterSchema.panels,
6647
- controls: {
6648
- sections: [
6649
- {
6650
- controls: {
6651
- blend: {
6652
- defaultValue: "liquid",
6653
- label: "Blend",
6654
- options: [
6655
- { label: "Silk", value: "silk" },
6656
- { label: "Liquid", value: "liquid" },
6657
- { label: "Crystal", value: "crystal" },
6658
- ],
6659
- orderRole: "mode" as const,
6660
- target: "shader.blend",
6661
- type: "segmented",
6662
- },
6663
- depth: {
6664
- defaultValue: 0.64,
6665
- label: "Depth",
6666
- max: 1,
6667
- min: 0,
6668
- orderRole: "strength" as const,
6669
- target: "shader.depth",
6670
- type: "slider",
6671
- variant: "continuous",
6672
- },
6673
- },
6674
- 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",
6675
9479
  },
6676
- ],
6677
- title: "Shader",
9480
+ mode: "timeline-playback",
9481
+ },
9482
+ mode: "new-toolcraft-app",
6678
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 },
6679
9496
  },
6680
9497
  };
6681
9498
 
6682
- expect(getToolcraftControlOrderTargets(schemaWithOrderedControls)).toEqual([
6683
- "shader.blend",
6684
- "shader.depth",
6685
- ]);
6686
9499
  expect(
6687
- validateToolcraftAcceptanceCoverage(schemaWithOrderedControls, [
6688
- playbackTimelineAcceptance,
9500
+ validateToolcraftAcceptanceCoverage(
9501
+ schemaWithPlaybackTimeline,
9502
+ [playbackTimelineAcceptance],
6689
9503
  {
6690
- automated: true,
6691
- automatedTestName: "blend changes rendered output",
6692
- browser: true,
6693
- browserTestName: "browser: blend selector changes rendered output",
6694
- componentType: "segmented",
6695
- evidence: "rendered-pixels",
6696
- expectedObservable: "Changing Blend switches shader pattern.",
6697
- fixture: "blend fixture",
6698
- id: "shader.blend",
6699
- kind: "control",
6700
- optionCoverage: "each-visible-item",
6701
- target: "shader.blend",
6702
- 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",
6703
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],
6704
9545
  {
6705
- automated: true,
6706
- automatedTestName: "depth changes rendered output",
6707
- browser: true,
6708
- browserTestName: "browser: depth slider changes rendered output",
6709
- componentType: "slider",
6710
- evidence: "rendered-pixels",
6711
- expectedObservable: "Changing Depth changes shader contrast.",
6712
- fixture: "depth fixture",
6713
- id: "shader.depth",
6714
- kind: "control",
6715
- target: "shader.depth",
6716
- 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",
6717
9556
  },
6718
- ]),
9557
+ ),
6719
9558
  ).toEqual([]);
6720
9559
  });
6721
9560
 
6722
- it("requires overwide segmented controls to shorten labels or use select", () => {
6723
- const schemaWithOverwideSegmentedControl = {
6724
- ...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
+ },
6725
9567
  panels: {
6726
- ...starterSchema.panels,
6727
9568
  controls: {
6728
- 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: [
6729
9608
  {
6730
- controls: {
6731
- preset: {
6732
- defaultValue: "full-stack",
6733
- label: "FX Preset",
6734
- options: [
6735
- { label: "Full Stack", value: "full-stack" },
6736
- { label: "RGB Split", value: "rgb-split" },
6737
- { label: "Shade", value: "shade" },
6738
- { label: "Lines", value: "lines" },
6739
- { label: "Off", value: "off" },
6740
- ],
6741
- orderRole: "mode" as const,
6742
- target: "shader.fxPreset",
6743
- type: "segmented",
6744
- },
6745
- },
6746
- title: "Style",
9609
+ acceptanceId: "missing.acceptance",
9610
+ behavior: "Motion behavior",
9611
+ frameIds: ["f001"],
6747
9612
  },
6748
9613
  ],
6749
- 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: [],
6750
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 },
6751
9646
  },
6752
9647
  };
6753
9648
 
6754
9649
  expect(
6755
- validateToolcraftAcceptanceCoverage(schemaWithOverwideSegmentedControl, [
9650
+ validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
6756
9651
  {
6757
9652
  automated: true,
6758
- automatedTestName: "fx preset changes rendered output",
9653
+ automatedTestName: "timeline duration edit drives renderer output",
6759
9654
  browser: true,
6760
- browserTestName: "browser: fx preset selector changes rendered output",
6761
- componentType: "segmented",
6762
- evidence: "rendered-pixels",
6763
- expectedObservable: "Changing FX Preset switches the shader preset.",
6764
- fixture: "fx preset fixture",
6765
- id: "shader.fxPreset",
6766
- kind: "control",
6767
- optionCoverage: "each-visible-item",
6768
- target: "shader.fxPreset",
6769
- 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.",
6770
9673
  },
6771
9674
  ]),
6772
- ).toEqual(
6773
- expect.arrayContaining([
6774
- "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.",
6775
- ]),
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.',
6776
9677
  );
6777
9678
  });
6778
9679
 
6779
- it("requires timeline playback coverage when a playback timeline is enabled", () => {
9680
+ it("rejects incomplete seamless loop evidence that omits fallback direction checks", () => {
6780
9681
  const schemaWithPlaybackTimeline = {
6781
9682
  ...starterSchema,
6782
9683
  panels: {
6783
9684
  ...starterSchema.panels,
6784
- timeline: { enabled: true, mode: "playback" as const },
9685
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
6785
9686
  },
6786
9687
  };
6787
9688
 
6788
- expect(validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [])).toEqual(
6789
- expect.arrayContaining([
6790
- '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
+ },
6791
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.',
6792
9718
  );
6793
9719
  });
6794
9720
 
6795
- it("requires playback timeline coverage to prove duration drives renderer progress", () => {
9721
+ it("rejects generic seamless loop evidence that omits first-last frame stitching", () => {
6796
9722
  const schemaWithPlaybackTimeline = {
6797
9723
  ...starterSchema,
6798
9724
  panels: {
6799
9725
  ...starterSchema.panels,
6800
- timeline: { enabled: true, mode: "playback" as const },
9726
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "playback" as const },
6801
9727
  },
6802
9728
  };
6803
9729
 
@@ -6805,24 +9731,31 @@ describe("Toolcraft template app acceptance coverage", () => {
6805
9731
  validateToolcraftAcceptanceCoverage(schemaWithPlaybackTimeline, [
6806
9732
  {
6807
9733
  automated: true,
6808
- automatedTestName: "timeline playback controls drive rendered output",
9734
+ automatedTestName: "timeline duration edit verifies a generic loop",
6809
9735
  browser: true,
6810
- browserTestName: "browser: timeline playback controls drive rendered output",
9736
+ browserTestName: "browser: timeline duration edit verifies a generic loop",
6811
9737
  componentType: "timeline",
6812
9738
  evidence: "timeline-output",
6813
9739
  expectedObservable:
6814
- "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.",
6815
9741
  fixture: "timeline playback fixture",
6816
9742
  id: "timeline.playback",
6817
9743
  kind: "runtime",
6818
9744
  target: "timeline.playback",
6819
9745
  timelineCoverage: "playback",
6820
- timelinePlaybackCoverage: ["pause-resume", "scrub", "rendered-frame"],
6821
- 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.",
6822
9755
  },
6823
9756
  ]),
6824
9757
  ).toContain(
6825
- '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.',
6826
9759
  );
6827
9760
  });
6828
9761
 
@@ -6860,7 +9793,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6860
9793
  ],
6861
9794
  title: "Controls",
6862
9795
  },
6863
- timeline: { enabled: true, mode: "keyframes" as const },
9796
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
6864
9797
  },
6865
9798
  };
6866
9799
 
@@ -6965,7 +9898,7 @@ describe("Toolcraft template app acceptance coverage", () => {
6965
9898
  ],
6966
9899
  title: "Controls",
6967
9900
  },
6968
- timeline: { enabled: true, mode: "keyframes" as const },
9901
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
6969
9902
  },
6970
9903
  };
6971
9904
 
@@ -7057,7 +9990,7 @@ describe("Toolcraft template app acceptance coverage", () => {
7057
9990
  ],
7058
9991
  title: "Controls",
7059
9992
  },
7060
- timeline: { enabled: true, mode: "keyframes" as const },
9993
+ timeline: { defaultDurationSeconds: 8, enabled: true, mode: "keyframes" as const },
7061
9994
  },
7062
9995
  };
7063
9996