@pixel-point/toolcraft 0.0.7 → 0.0.8

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 (41) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +84 -12
  3. package/templates/runtime/contracts/component-contracts.ts +40 -17
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +31 -0
  6. package/templates/runtime/export/export.ts +41 -0
  7. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  8. package/templates/runtime/react/canvas-shell.tsx +178 -23
  9. package/templates/runtime/react/control-conditions.ts +166 -0
  10. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  11. package/templates/runtime/react/controls-panel.test.tsx +404 -8
  12. package/templates/runtime/react/controls-panel.tsx +90 -4
  13. package/templates/runtime/react/media-file.ts +19 -0
  14. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  15. package/templates/runtime/schema/define-toolcraft.ts +4 -2
  16. package/templates/runtime/schema/types.ts +4 -0
  17. package/templates/runtime/state/reducer.test.ts +304 -0
  18. package/templates/runtime/state/reducer.ts +148 -9
  19. package/templates/runtime/state/types.ts +10 -2
  20. package/templates/runtime/testing/performance.test.ts +1282 -48
  21. package/templates/runtime/testing/performance.ts +676 -39
  22. package/templates/starter/AGENTS.md +6 -5
  23. package/templates/starter/docs/toolcraft/acceptance-testing.md +11 -5
  24. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  25. package/templates/starter/docs/toolcraft/component-rules.md +16 -7
  26. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  27. package/templates/starter/docs/toolcraft/performance.md +47 -5
  28. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  29. package/templates/starter/docs/toolcraft/schema-reference.md +6 -6
  30. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  31. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  32. package/templates/starter/e2e/performance-helpers.ts +197 -0
  33. package/templates/starter/package.json +3 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  35. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  36. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  37. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  38. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  39. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  40. package/templates/ui/components/controls/index.ts +1 -0
  41. package/templates/ui/components/panel/panel-section.tsx +53 -1
@@ -9,8 +9,11 @@ export type ToolcraftPerformanceInteraction =
9
9
  | "control-change"
10
10
  | "control-drag"
11
11
  | "export-copy"
12
+ | "mask-drag"
12
13
  | "media-import"
13
14
  | "preview-render"
15
+ | "timeline-playback"
16
+ | "timeline-scrub"
14
17
  | "viewport-zoom-stress"
15
18
  | "viewport-stability";
16
19
 
@@ -36,7 +39,7 @@ export type ToolcraftPerformanceValueSet = {
36
39
  min?: unknown;
37
40
  };
38
41
 
39
- export type ToolcraftPerformanceStressFixtureKind =
42
+ export type ToolcraftPerformanceFixtureKind =
40
43
  | "custom"
41
44
  | "high-density"
42
45
  | "large-canvas"
@@ -45,8 +48,8 @@ export type ToolcraftPerformanceStressFixtureKind =
45
48
  | "max-value"
46
49
  | "media";
47
50
 
48
- export type ToolcraftPerformanceStressFixture = {
49
- kind: ToolcraftPerformanceStressFixtureKind;
51
+ export type ToolcraftPerformanceFixture = {
52
+ kind: ToolcraftPerformanceFixtureKind;
50
53
  minChars?: number;
51
54
  minCount?: number;
52
55
  minLines?: number;
@@ -54,6 +57,10 @@ export type ToolcraftPerformanceStressFixture = {
54
57
  value?: unknown;
55
58
  };
56
59
 
60
+ export type ToolcraftPerformanceStressFixtureKind = ToolcraftPerformanceFixtureKind;
61
+ export type ToolcraftPerformanceStressFixture = ToolcraftPerformanceFixture;
62
+ export type ToolcraftPerformanceWorkloadFixture = ToolcraftPerformanceFixture;
63
+
57
64
  export type ToolcraftPerformanceScenario = ToolcraftPerformanceCoverage & {
58
65
  budget: ToolcraftPerformanceBudget;
59
66
  controlLabel?: string;
@@ -64,6 +71,7 @@ export type ToolcraftPerformanceScenario = ToolcraftPerformanceCoverage & {
64
71
  target?: string;
65
72
  stress?: boolean;
66
73
  stressFixture?: ToolcraftPerformanceStressFixture;
74
+ workloadFixture?: ToolcraftPerformanceWorkloadFixture;
67
75
  uiSelector?: string;
68
76
  values?: ToolcraftPerformanceValueSet;
69
77
  workload: boolean;
@@ -170,7 +178,70 @@ export type ToolcraftRendererTechnique = {
170
178
  whyNotAlternativeStrategies: readonly string[];
171
179
  };
172
180
 
181
+ export type ToolcraftRenderPassKind =
182
+ | "decode"
183
+ | "preprocess"
184
+ | "pixel-transform"
185
+ | "vector-build"
186
+ | "text-layout"
187
+ | "rasterize"
188
+ | "composite"
189
+ | "handles"
190
+ | "export";
191
+
192
+ export type ToolcraftRenderPassRunLocation =
193
+ | "main"
194
+ | "worker"
195
+ | "gpu"
196
+ | "worker-or-gpu"
197
+ | "export-only";
198
+
199
+ export type ToolcraftRenderPassOutput =
200
+ | "source"
201
+ | "intermediate"
202
+ | "preview"
203
+ | "overlay"
204
+ | "export";
205
+
206
+ export type ToolcraftRenderPassQuality = "preview" | "full" | "retina" | "export";
207
+
208
+ export type ToolcraftRenderPass = {
209
+ cacheKey?: readonly string[];
210
+ id: string;
211
+ inputs: readonly string[];
212
+ invalidatedBy: readonly string[];
213
+ kind: ToolcraftRenderPassKind;
214
+ output: ToolcraftRenderPassOutput;
215
+ quality: ToolcraftRenderPassQuality;
216
+ runsOn: ToolcraftRenderPassRunLocation;
217
+ };
218
+
219
+ export type ToolcraftPipelineInteraction =
220
+ | "animation-frame"
221
+ | "control-change"
222
+ | "control-drag"
223
+ | "media-import"
224
+ | "mask-drag"
225
+ | "viewport-drag"
226
+ | "viewport-zoom"
227
+ | "timeline-playback"
228
+ | "timeline-scrub"
229
+ | "export";
230
+
231
+ export type ToolcraftInteractionInvalidation = {
232
+ interaction: ToolcraftPipelineInteraction;
233
+ invalidates: readonly string[];
234
+ mustNotInvalidate?: readonly string[];
235
+ targets: readonly string[];
236
+ };
237
+
238
+ export type ToolcraftRendererPipeline = {
239
+ interactionInvalidation: readonly ToolcraftInteractionInvalidation[];
240
+ passes: readonly ToolcraftRenderPass[];
241
+ };
242
+
173
243
  export type ToolcraftPerformanceConfig = {
244
+ rendererPipeline?: ToolcraftRendererPipeline;
174
245
  rendererStrategy: ToolcraftRendererStrategy;
175
246
  rendererTechnique?: ToolcraftRendererTechnique;
176
247
  rendererWorkload: ToolcraftRendererWorkload;
@@ -213,8 +284,10 @@ const heavyTextInputPattern = /code|css|instruction|json|prompt|script|shader|te
213
284
 
214
285
  const largeTextStressMinChars = 50_000;
215
286
  const largeTextStressMinLines = 1_000;
287
+ const mediaStressMinArea = 1920 * 1080;
288
+ const mediaStressMinLongEdge = 1920;
216
289
 
217
- const stressFixtureKinds = new Set<ToolcraftPerformanceStressFixtureKind>([
290
+ const performanceFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
218
291
  "custom",
219
292
  "high-density",
220
293
  "large-canvas",
@@ -224,6 +297,79 @@ const stressFixtureKinds = new Set<ToolcraftPerformanceStressFixtureKind>([
224
297
  "media",
225
298
  ]);
226
299
 
300
+ const renderPassKinds = new Set<ToolcraftRenderPassKind>([
301
+ "decode",
302
+ "preprocess",
303
+ "pixel-transform",
304
+ "vector-build",
305
+ "text-layout",
306
+ "rasterize",
307
+ "composite",
308
+ "handles",
309
+ "export",
310
+ ]);
311
+
312
+ const renderPassRunLocations = new Set<ToolcraftRenderPassRunLocation>([
313
+ "main",
314
+ "worker",
315
+ "gpu",
316
+ "worker-or-gpu",
317
+ "export-only",
318
+ ]);
319
+
320
+ const renderPassOutputs = new Set<ToolcraftRenderPassOutput>([
321
+ "source",
322
+ "intermediate",
323
+ "preview",
324
+ "overlay",
325
+ "export",
326
+ ]);
327
+
328
+ const renderPassQualities = new Set<ToolcraftRenderPassQuality>([
329
+ "preview",
330
+ "full",
331
+ "retina",
332
+ "export",
333
+ ]);
334
+
335
+ const pipelineInteractions = new Set<ToolcraftPipelineInteraction>([
336
+ "animation-frame",
337
+ "control-change",
338
+ "control-drag",
339
+ "media-import",
340
+ "mask-drag",
341
+ "viewport-drag",
342
+ "viewport-zoom",
343
+ "timeline-playback",
344
+ "timeline-scrub",
345
+ "export",
346
+ ]);
347
+
348
+ const expensiveRenderPassKinds = new Set<ToolcraftRenderPassKind>([
349
+ "decode",
350
+ "preprocess",
351
+ "pixel-transform",
352
+ "text-layout",
353
+ "rasterize",
354
+ ]);
355
+
356
+ const cacheRequiredRenderPassKinds = new Set<ToolcraftRenderPassKind>([
357
+ ...expensiveRenderPassKinds,
358
+ "composite",
359
+ ]);
360
+
361
+ const highFrequencyViewportInteractions = new Set<ToolcraftPipelineInteraction>([
362
+ "animation-frame",
363
+ "mask-drag",
364
+ "timeline-playback",
365
+ "timeline-scrub",
366
+ "viewport-drag",
367
+ "viewport-zoom",
368
+ ]);
369
+
370
+ const vaguePipelineReferencePattern =
371
+ /^(?:all|all values|everything|props|runtime|settings|state|values)$/i;
372
+
227
373
  function getControlSemanticText(control: ToolcraftControlSchema): string {
228
374
  return [
229
375
  control.target,
@@ -309,12 +455,33 @@ function hasMinDefaultMax(values: ToolcraftPerformanceScenario["values"]): boole
309
455
  return values !== undefined && "default" in values && "min" in values && "max" in values;
310
456
  }
311
457
 
312
- function hasStressFixtureValue(
313
- fixture: ToolcraftPerformanceStressFixture,
314
- ): fixture is ToolcraftPerformanceStressFixture & { value: unknown } {
458
+ function hasPerformanceFixtureValue(
459
+ fixture: ToolcraftPerformanceFixture,
460
+ ): fixture is ToolcraftPerformanceFixture & { value: unknown } {
315
461
  return Object.prototype.hasOwnProperty.call(fixture, "value");
316
462
  }
317
463
 
464
+ function isPerformanceFixtureObjectValue(value: unknown): value is Record<string, unknown> {
465
+ return typeof value === "object" && value !== null && !Array.isArray(value);
466
+ }
467
+
468
+ function getMediaStressFixtureDimensions(
469
+ value: unknown,
470
+ ): { height: number; width: number } | null {
471
+ if (!isPerformanceFixtureObjectValue(value)) {
472
+ return null;
473
+ }
474
+
475
+ const width = Number(value.width);
476
+ const height = Number(value.height);
477
+
478
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
479
+ return null;
480
+ }
481
+
482
+ return { height, width };
483
+ }
484
+
318
485
  function getTextLineCount(value: string): number {
319
486
  if (value.length === 0) {
320
487
  return 0;
@@ -340,50 +507,79 @@ function isLargeTextWorkloadControl(
340
507
  );
341
508
  }
342
509
 
343
- function getStressFixtureErrors(
344
- scenario: ToolcraftPerformanceScenario,
345
- control: ToolcraftControlSchema | undefined,
510
+ function getPerformanceFixtureShapeErrors(
511
+ scenarioId: string,
512
+ fieldName: "stressFixture" | "workloadFixture",
513
+ fixture: ToolcraftPerformanceFixture,
514
+ options: {
515
+ requireLargeText?: boolean;
516
+ requireMedia?: boolean;
517
+ } = {},
346
518
  ): string[] {
347
- if (!scenario.workload) {
348
- return [];
349
- }
350
-
351
519
  const errors: string[] = [];
352
- const fixture = scenario.stressFixture;
353
520
 
354
- if (!fixture) {
355
- return [
356
- `${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
357
- ];
521
+ if (!performanceFixtureKinds.has(fixture.kind)) {
522
+ errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
358
523
  }
359
524
 
360
- if (!stressFixtureKinds.has(fixture.kind)) {
361
- errors.push(`${scenario.id} stressFixture.kind "${fixture.kind}" is not supported.`);
525
+ if (options.requireMedia) {
526
+ if (fixture.kind !== "media") {
527
+ errors.push(
528
+ `${scenarioId} media-import scenario must use ${fieldName}.kind "media" with a realistic uploaded source size.`,
529
+ );
530
+ }
362
531
  }
363
532
 
364
533
  if (!fixture.reason.trim()) {
365
534
  errors.push(
366
- `${scenario.id} stressFixture must explain why this is the heaviest useful fixture.`,
535
+ `${scenarioId} ${fieldName} must explain why this is the heaviest useful fixture.`,
367
536
  );
368
537
  }
369
538
 
370
- if (fixture.kind !== "custom" && !hasStressFixtureValue(fixture)) {
371
- errors.push(`${scenario.id} stressFixture must include value so browser tests can use it.`);
539
+ if (!hasPerformanceFixtureValue(fixture)) {
540
+ errors.push(
541
+ `${scenarioId} ${fieldName} must include value so browser tests can apply the exact heavy state.`,
542
+ );
543
+ } else if (fixture.kind === "custom") {
544
+ if (!isPerformanceFixtureObjectValue(fixture.value)) {
545
+ errors.push(
546
+ `${scenarioId} custom ${fieldName}.value must be an object with one key per heavy state part so browser tests can apply every key.`,
547
+ );
548
+ } else if (Object.keys(fixture.value).length === 0) {
549
+ errors.push(
550
+ `${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
551
+ );
552
+ }
553
+ } else if (fixture.kind === "media") {
554
+ const dimensions = getMediaStressFixtureDimensions(fixture.value);
555
+
556
+ if (!dimensions) {
557
+ errors.push(
558
+ `${scenarioId} media ${fieldName}.value must be an object with numeric width and height so browser tests can generate or load a realistic source image.`,
559
+ );
560
+ } else if (
561
+ Math.max(dimensions.width, dimensions.height) < mediaStressMinLongEdge ||
562
+ dimensions.width * dimensions.height < mediaStressMinArea
563
+ ) {
564
+ errors.push(
565
+ `${scenarioId} media ${fieldName}.value must be at least 1920x1080-equivalent; received ${dimensions.width}x${dimensions.height}.`,
566
+ );
567
+ }
372
568
  }
373
569
 
374
- if (!isLargeTextWorkloadControl(control) && fixture.kind !== "large-text") {
570
+ if (!options.requireLargeText && fixture.kind !== "large-text") {
375
571
  return errors;
376
572
  }
377
573
 
378
574
  if (fixture.kind !== "large-text") {
379
575
  errors.push(
380
- `${scenario.id} text workload scenario must use stressFixture.kind "large-text" with a real long text value.`,
576
+ `${scenarioId} text workload scenario must use ${fieldName}.kind "large-text" with a real long text value.`,
381
577
  );
382
578
  return errors;
383
579
  }
384
580
 
385
581
  if (typeof fixture.value !== "string") {
386
- errors.push(`${scenario.id} large-text stressFixture.value must be a string.`);
582
+ errors.push(`${scenarioId} large-text ${fieldName}.value must be a string.`);
387
583
  return errors;
388
584
  }
389
585
 
@@ -392,31 +588,148 @@ function getStressFixtureErrors(
392
588
 
393
589
  if (minChars < largeTextStressMinChars) {
394
590
  errors.push(
395
- `${scenario.id} large-text stressFixture.minChars must be >= ${largeTextStressMinChars}.`,
591
+ `${scenarioId} large-text ${fieldName}.minChars must be >= ${largeTextStressMinChars}.`,
396
592
  );
397
593
  }
398
594
 
399
595
  if (minLines < largeTextStressMinLines) {
400
596
  errors.push(
401
- `${scenario.id} large-text stressFixture.minLines must be >= ${largeTextStressMinLines}.`,
597
+ `${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
402
598
  );
403
599
  }
404
600
 
405
601
  if (fixture.value.length < minChars) {
406
602
  errors.push(
407
- `${scenario.id} large-text stressFixture.value must contain at least ${minChars} characters.`,
603
+ `${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
408
604
  );
409
605
  }
410
606
 
411
607
  if (getTextLineCount(fixture.value) < minLines) {
412
608
  errors.push(
413
- `${scenario.id} large-text stressFixture.value must contain at least ${minLines} lines.`,
609
+ `${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
414
610
  );
415
611
  }
416
612
 
417
613
  return errors;
418
614
  }
419
615
 
616
+ function getStressFixtureErrors(
617
+ scenario: ToolcraftPerformanceScenario,
618
+ control: ToolcraftControlSchema | undefined,
619
+ ): string[] {
620
+ const isMediaImport = scenario.interaction === "media-import";
621
+
622
+ if (!scenario.workload && scenario.stress !== true && !scenario.stressFixture && !isMediaImport) {
623
+ return [];
624
+ }
625
+
626
+ const fixture = scenario.stressFixture;
627
+
628
+ if (!fixture) {
629
+ if (isMediaImport) {
630
+ return [
631
+ `${scenario.id} media-import scenario must declare stressFixture.kind "media" with a realistic uploaded source size.`,
632
+ ];
633
+ }
634
+
635
+ return scenario.workload
636
+ ? [
637
+ `${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
638
+ ]
639
+ : [
640
+ `${scenario.id} stress scenario must declare stressFixture with the real heaviest state used by browser performance tests.`,
641
+ ];
642
+ }
643
+
644
+ const errors: string[] = [];
645
+
646
+ if (isMediaImport && !scenario.workload) {
647
+ errors.push(
648
+ `${scenario.id} media-import scenario must set workload true because decoded source size changes renderer workload.`,
649
+ );
650
+ }
651
+
652
+ errors.push(
653
+ ...getPerformanceFixtureShapeErrors(scenario.id, "stressFixture", fixture, {
654
+ requireLargeText: isLargeTextWorkloadControl(control),
655
+ requireMedia: isMediaImport,
656
+ }),
657
+ );
658
+
659
+ return errors;
660
+ }
661
+
662
+ function stressFixtureAlreadyDefinesIndependentWorkload(
663
+ scenario: ToolcraftPerformanceScenario,
664
+ control: ToolcraftControlSchema | undefined,
665
+ ): boolean {
666
+ return (
667
+ scenario.stressFixture?.kind === "large-text" &&
668
+ isLargeTextWorkloadControl(control)
669
+ );
670
+ }
671
+
672
+ function isWorkloadBaselineSensitiveScenario(
673
+ config: ToolcraftPerformanceConfig,
674
+ scenario: ToolcraftPerformanceScenario,
675
+ control: ToolcraftControlSchema | undefined,
676
+ ): boolean {
677
+ if (
678
+ !scenario.workload ||
679
+ (scenario.interaction !== "control-change" && scenario.interaction !== "control-drag")
680
+ ) {
681
+ return false;
682
+ }
683
+
684
+ if (!config.usesCustomRenderer) {
685
+ return false;
686
+ }
687
+
688
+ if (config.rendererWorkload === "none" || config.rendererWorkload === "simple-composition") {
689
+ return false;
690
+ }
691
+
692
+ return !stressFixtureAlreadyDefinesIndependentWorkload(scenario, control);
693
+ }
694
+
695
+ function getWorkloadFixtureErrors(
696
+ config: ToolcraftPerformanceConfig,
697
+ scenario: ToolcraftPerformanceScenario,
698
+ control: ToolcraftControlSchema | undefined,
699
+ ): string[] {
700
+ const requiresBaseline = isWorkloadBaselineSensitiveScenario(config, scenario, control);
701
+ const errors: string[] = [];
702
+
703
+ if (scenario.workloadFixture && !scenario.stressFixture) {
704
+ errors.push(
705
+ `${scenario.id} workloadFixture must be paired with stressFixture so tests apply a heavy baseline and then the measured scenario value.`,
706
+ );
707
+ }
708
+
709
+ if (!requiresBaseline && !scenario.workloadFixture) {
710
+ return errors;
711
+ }
712
+
713
+ const fixture = scenario.workloadFixture;
714
+
715
+ if (!fixture) {
716
+ errors.push(
717
+ `${scenario.id} workload control scenario must declare workloadFixture for the app's heavy baseline state; stressFixture covers the control value only.`,
718
+ );
719
+ return errors;
720
+ }
721
+
722
+ errors.push(
723
+ ...getPerformanceFixtureShapeErrors(
724
+ scenario.id,
725
+ "workloadFixture",
726
+ fixture,
727
+ ),
728
+ );
729
+
730
+ return errors;
731
+ }
732
+
420
733
  function hasPositiveBudgetField(
421
734
  budget: ToolcraftPerformanceBudget,
422
735
  field: keyof ToolcraftPerformanceBudget,
@@ -438,6 +751,7 @@ function getMissingInteractionBudgetFields(
438
751
  ? ["maxFrameGapMs"]
439
752
  : ["maxFrameGapMs", "maxLongTaskMs"];
440
753
  case "animation-viewport-drag":
754
+ case "mask-drag":
441
755
  case "viewport-zoom-stress":
442
756
  return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
443
757
  (field) => !hasPositiveBudgetField(scenario.budget, field),
@@ -459,6 +773,14 @@ function getMissingInteractionBudgetFields(
459
773
  hasPositiveBudgetField(scenario.budget, "maxRenderMs")
460
774
  ? []
461
775
  : ["maxPreviewMs or maxRenderMs"];
776
+ case "timeline-playback":
777
+ return (["maxFrameGapMs", "maxLongTaskMs"] as const).filter(
778
+ (field) => !hasPositiveBudgetField(scenario.budget, field),
779
+ );
780
+ case "timeline-scrub":
781
+ return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
782
+ (field) => !hasPositiveBudgetField(scenario.budget, field),
783
+ );
462
784
  }
463
785
  }
464
786
 
@@ -476,7 +798,13 @@ function getBudgetCapErrors(scenario: ToolcraftPerformanceScenario): string[] {
476
798
  }
477
799
 
478
800
  function requiresConcreteUiTarget(interaction: ToolcraftPerformanceInteraction): boolean {
479
- return interaction === "control-change" || interaction === "control-drag";
801
+ return (
802
+ interaction === "control-change" ||
803
+ interaction === "control-drag" ||
804
+ interaction === "mask-drag" ||
805
+ interaction === "timeline-playback" ||
806
+ interaction === "timeline-scrub"
807
+ );
480
808
  }
481
809
 
482
810
  function getAllSchemaControls(
@@ -588,6 +916,32 @@ function hasHighCountCanvas2DRendererLayer(
588
916
  );
589
917
  }
590
918
 
919
+ function hasDetailHeavyCanvas2DRendererLayer(
920
+ technique: ToolcraftRendererTechnique | undefined,
921
+ ): boolean {
922
+ return (technique?.layers ?? []).some(
923
+ (layer) =>
924
+ layer.renderer === "canvas-2d" &&
925
+ layer.content.some((content) => detailHeavyRendererContent.has(content)),
926
+ );
927
+ }
928
+
929
+ function hasMeasuredGpuAlternativeEvidence(
930
+ technique: ToolcraftRendererTechnique,
931
+ ): boolean {
932
+ const evidenceText = [
933
+ ...technique.whyNotAlternativeStrategies,
934
+ ...technique.performanceRisks,
935
+ ].join(" ");
936
+
937
+ return (
938
+ /\b(?:webgl|webgpu|gpu)\b/i.test(evidenceText) &&
939
+ /\b(?:budget|evidence|frame|long task|measure|measured|perf|performance|stress)\b/i.test(
940
+ evidenceText,
941
+ )
942
+ );
943
+ }
944
+
591
945
  function hasStressPreviewOrAnimationScenario(config: ToolcraftPerformanceConfig): boolean {
592
946
  return config.scenarios.some(
593
947
  (scenario) =>
@@ -767,6 +1121,267 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
767
1121
 
768
1122
  errors.push(...getRendererLayerErrors(technique));
769
1123
 
1124
+ if (
1125
+ config.usesCustomRenderer &&
1126
+ technique.rendererStrategy === "canvas-2d" &&
1127
+ hasDetailHeavyCanvas2DRendererLayer(technique) &&
1128
+ !hasMeasuredGpuAlternativeEvidence(technique)
1129
+ ) {
1130
+ errors.push(
1131
+ "Detail-heavy Canvas 2D renderers must evaluate WebGL/WebGPU in rendererTechnique.whyNotAlternativeStrategies or performanceRisks with measured stress evidence before keeping the pixel work on CPU.",
1132
+ );
1133
+ }
1134
+
1135
+ return errors;
1136
+ }
1137
+
1138
+ function getPassById(
1139
+ pipeline: ToolcraftRendererPipeline,
1140
+ ): Map<string, ToolcraftRenderPass> {
1141
+ return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
1142
+ }
1143
+
1144
+ function hasPipelineReference(value: string): boolean {
1145
+ return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
1146
+ }
1147
+
1148
+ function getPipelineReferenceErrors(
1149
+ passId: string,
1150
+ field: string,
1151
+ references: readonly string[] | undefined,
1152
+ ): string[] {
1153
+ if (!references || references.length === 0) {
1154
+ return [`rendererPipeline pass "${passId}" must list ${field}.`];
1155
+ }
1156
+
1157
+ return references.flatMap((reference) =>
1158
+ hasPipelineReference(reference)
1159
+ ? []
1160
+ : [
1161
+ `rendererPipeline pass "${passId}" ${field} entry "${reference}" is too vague. Name the concrete runtime target, source key, resource key, or cache key part.`,
1162
+ ],
1163
+ );
1164
+ }
1165
+
1166
+ function getPipelineInteractionForScenario(
1167
+ interaction: ToolcraftPerformanceInteraction,
1168
+ ): ToolcraftPipelineInteraction | null {
1169
+ switch (interaction) {
1170
+ case "animation-frame":
1171
+ return "animation-frame";
1172
+ case "animation-viewport-drag":
1173
+ return "viewport-drag";
1174
+ case "control-change":
1175
+ return "control-change";
1176
+ case "control-drag":
1177
+ return "control-drag";
1178
+ case "export-copy":
1179
+ return "export";
1180
+ case "mask-drag":
1181
+ return "mask-drag";
1182
+ case "media-import":
1183
+ return "media-import";
1184
+ case "preview-render":
1185
+ return null;
1186
+ case "timeline-playback":
1187
+ return "timeline-playback";
1188
+ case "timeline-scrub":
1189
+ return "timeline-scrub";
1190
+ case "viewport-zoom-stress":
1191
+ return "viewport-zoom";
1192
+ case "viewport-stability":
1193
+ return null;
1194
+ }
1195
+ }
1196
+
1197
+ function getRendererPipelineErrors(
1198
+ _schema: ResolvedToolcraftAppSchema,
1199
+ config: ToolcraftPerformanceConfig,
1200
+ ): string[] {
1201
+ const errors: string[] = [];
1202
+ const pipeline = config.rendererPipeline;
1203
+
1204
+ if (config.usesCustomRenderer && !pipeline) {
1205
+ return [
1206
+ "Custom renderers must declare rendererPipeline so render passes, cache keys, and invalidation are machine-checkable.",
1207
+ ];
1208
+ }
1209
+
1210
+ if (!config.usesCustomRenderer && pipeline) {
1211
+ return ["Non-custom renderer configs must omit rendererPipeline."];
1212
+ }
1213
+
1214
+ if (!pipeline) {
1215
+ return errors;
1216
+ }
1217
+
1218
+ if (pipeline.passes.length === 0) {
1219
+ errors.push("rendererPipeline must declare at least one render pass.");
1220
+ }
1221
+
1222
+ if (pipeline.interactionInvalidation.length === 0) {
1223
+ errors.push(
1224
+ "rendererPipeline must declare interactionInvalidation so high-frequency UI work cannot accidentally invalidate expensive passes.",
1225
+ );
1226
+ }
1227
+
1228
+ const passIds = new Set<string>();
1229
+
1230
+ for (const pass of pipeline.passes) {
1231
+ const passId = pass.id.trim();
1232
+
1233
+ if (!passId) {
1234
+ errors.push("rendererPipeline passes must have non-empty ids.");
1235
+ } else if (passIds.has(passId)) {
1236
+ errors.push(`rendererPipeline pass id "${passId}" must be unique.`);
1237
+ } else {
1238
+ passIds.add(passId);
1239
+ }
1240
+
1241
+ if (!renderPassKinds.has(pass.kind)) {
1242
+ errors.push(`rendererPipeline pass "${pass.id}" kind "${pass.kind}" is not supported.`);
1243
+ }
1244
+
1245
+ if (!renderPassRunLocations.has(pass.runsOn)) {
1246
+ errors.push(
1247
+ `rendererPipeline pass "${pass.id}" runsOn "${pass.runsOn}" is not supported.`,
1248
+ );
1249
+ }
1250
+
1251
+ if (!renderPassOutputs.has(pass.output)) {
1252
+ errors.push(
1253
+ `rendererPipeline pass "${pass.id}" output "${pass.output}" is not supported.`,
1254
+ );
1255
+ }
1256
+
1257
+ if (!renderPassQualities.has(pass.quality)) {
1258
+ errors.push(
1259
+ `rendererPipeline pass "${pass.id}" quality "${pass.quality}" is not supported.`,
1260
+ );
1261
+ }
1262
+
1263
+ errors.push(...getPipelineReferenceErrors(pass.id, "inputs", pass.inputs));
1264
+ errors.push(...getPipelineReferenceErrors(pass.id, "invalidatedBy", pass.invalidatedBy));
1265
+
1266
+ if (pass.cacheKey) {
1267
+ errors.push(...getPipelineReferenceErrors(pass.id, "cacheKey", pass.cacheKey));
1268
+ }
1269
+
1270
+ if (
1271
+ cacheRequiredRenderPassKinds.has(pass.kind) &&
1272
+ (!pass.cacheKey || pass.cacheKey.length === 0)
1273
+ ) {
1274
+ errors.push(
1275
+ `rendererPipeline pass "${pass.id}" is a cache-sensitive ${pass.kind} pass and must declare cacheKey so tests can reject full recomputation on every control change.`,
1276
+ );
1277
+ }
1278
+
1279
+ if (pass.kind === "decode") {
1280
+ const hasMediaImportScenario = config.scenarios.some(
1281
+ (scenario) => scenario.interaction === "media-import",
1282
+ );
1283
+
1284
+ if (!hasMediaImportScenario) {
1285
+ errors.push(
1286
+ `rendererPipeline pass "${pass.id}" decodes media, so performance scenarios must include media-import coverage.`,
1287
+ );
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ const passesById = getPassById(pipeline);
1293
+ const invalidationTargets = new Set<string>();
1294
+ const pipelineInteractionSet = new Set(
1295
+ pipeline.interactionInvalidation.map((invalidation) => invalidation.interaction),
1296
+ );
1297
+
1298
+ for (const invalidation of pipeline.interactionInvalidation) {
1299
+ if (!pipelineInteractions.has(invalidation.interaction)) {
1300
+ errors.push(
1301
+ `rendererPipeline interaction "${invalidation.interaction}" is not supported.`,
1302
+ );
1303
+ }
1304
+
1305
+ errors.push(
1306
+ ...getPipelineReferenceErrors(
1307
+ invalidation.interaction,
1308
+ "targets",
1309
+ invalidation.targets,
1310
+ ),
1311
+ );
1312
+
1313
+ for (const target of invalidation.targets) {
1314
+ if (hasPipelineReference(target)) {
1315
+ invalidationTargets.add(target);
1316
+ }
1317
+ }
1318
+
1319
+ const mustNotInvalidate = new Set(invalidation.mustNotInvalidate ?? []);
1320
+
1321
+ for (const passId of invalidation.invalidates) {
1322
+ if (!passId.trim()) {
1323
+ errors.push(
1324
+ `rendererPipeline ${invalidation.interaction} invalidates contains an empty pass id.`,
1325
+ );
1326
+ continue;
1327
+ }
1328
+
1329
+ const pass = passesById.get(passId);
1330
+
1331
+ if (!pass) {
1332
+ errors.push(
1333
+ `rendererPipeline ${invalidation.interaction} invalidates unknown pass "${passId}".`,
1334
+ );
1335
+ continue;
1336
+ }
1337
+
1338
+ if (mustNotInvalidate.has(passId)) {
1339
+ errors.push(
1340
+ `rendererPipeline ${invalidation.interaction} cannot both invalidate and mustNotInvalidate pass "${passId}".`,
1341
+ );
1342
+ }
1343
+
1344
+ if (
1345
+ highFrequencyViewportInteractions.has(invalidation.interaction) &&
1346
+ expensiveRenderPassKinds.has(pass.kind)
1347
+ ) {
1348
+ errors.push(
1349
+ `rendererPipeline ${invalidation.interaction} must not invalidate expensive pass "${passId}" (${pass.kind}). Move viewport work to transforms/uniforms or explain it through a cheaper pass.`,
1350
+ );
1351
+ }
1352
+ }
1353
+
1354
+ for (const passId of invalidation.mustNotInvalidate ?? []) {
1355
+ if (!passId.trim()) {
1356
+ errors.push(
1357
+ `rendererPipeline ${invalidation.interaction} mustNotInvalidate contains an empty pass id.`,
1358
+ );
1359
+ } else if (!passesById.has(passId)) {
1360
+ errors.push(
1361
+ `rendererPipeline ${invalidation.interaction} mustNotInvalidate unknown pass "${passId}".`,
1362
+ );
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ for (const scenario of config.scenarios) {
1368
+ const pipelineInteraction = getPipelineInteractionForScenario(scenario.interaction);
1369
+
1370
+ if (pipelineInteraction && !pipelineInteractionSet.has(pipelineInteraction)) {
1371
+ errors.push(
1372
+ `Performance scenario ${scenario.id} exercises ${pipelineInteraction}, so rendererPipeline.interactionInvalidation must declare that interaction.`,
1373
+ );
1374
+ }
1375
+ }
1376
+
1377
+ for (const target of config.workloadTargets) {
1378
+ if (!invalidationTargets.has(target)) {
1379
+ errors.push(
1380
+ `Performance workload target ${target} must appear in rendererPipeline interactionInvalidation targets.`,
1381
+ );
1382
+ }
1383
+ }
1384
+
770
1385
  return errors;
771
1386
  }
772
1387
 
@@ -790,6 +1405,7 @@ export function validateToolcraftPerformanceCoverage(
790
1405
  );
791
1406
 
792
1407
  errors.push(...getRendererTechniqueErrors(config));
1408
+ errors.push(...getRendererPipelineErrors(schema, config));
793
1409
 
794
1410
  if (config.usesCustomRenderer && !customRendererStrategies.has(config.rendererStrategy)) {
795
1411
  errors.push(
@@ -817,10 +1433,11 @@ export function validateToolcraftPerformanceCoverage(
817
1433
 
818
1434
  if (
819
1435
  config.rendererWorkload === "pixel-output" &&
820
- !gpuRendererStrategies.has(config.rendererStrategy)
1436
+ !gpuRendererStrategies.has(config.rendererStrategy) &&
1437
+ (!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
821
1438
  ) {
822
1439
  errors.push(
823
- `rendererWorkload "pixel-output" must use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}".`,
1440
+ `rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}". Keeping a CPU renderer requires measured WebGL/WebGPU evidence in rendererTechnique.whyNotAlternativeStrategies or performanceRisks.`,
824
1441
  );
825
1442
  }
826
1443
 
@@ -910,11 +1527,17 @@ export function validateToolcraftPerformanceCoverage(
910
1527
  }
911
1528
  }
912
1529
 
913
- if (scenario.workload && !hasMinDefaultMax(scenario.values)) {
1530
+ if (
1531
+ scenario.workload &&
1532
+ scenario.interaction !== "media-import" &&
1533
+ !hasMinDefaultMax(scenario.values)
1534
+ ) {
914
1535
  errors.push(`${scenario.id} workload scenario must include min/default/max values.`);
915
1536
  }
916
1537
 
917
- errors.push(...getStressFixtureErrors(scenario, controlsByTarget.get(scenario.target ?? "")));
1538
+ const scenarioControl = controlsByTarget.get(scenario.target ?? "");
1539
+ errors.push(...getStressFixtureErrors(scenario, scenarioControl));
1540
+ errors.push(...getWorkloadFixtureErrors(config, scenario, scenarioControl));
918
1541
 
919
1542
  if (
920
1543
  requiresConcreteUiTarget(scenario.interaction) &&
@@ -1039,10 +1662,24 @@ export function validateToolcraftPerformanceCoverage(
1039
1662
  }
1040
1663
  }
1041
1664
 
1042
- if (schema.canvas.upload && !interactions.has("media-import")) {
1043
- errors.push(
1044
- "Custom renderers with canvas upload must include a media-import performance scenario.",
1665
+ if (schema.canvas.upload) {
1666
+ const mediaImportScenarios = config.scenarios.filter(
1667
+ (scenario) => scenario.interaction === "media-import",
1045
1668
  );
1669
+
1670
+ if (mediaImportScenarios.length === 0) {
1671
+ errors.push(
1672
+ "Custom renderers with canvas upload must include a media-import performance scenario.",
1673
+ );
1674
+ } else if (
1675
+ !mediaImportScenarios.some(
1676
+ (scenario) => scenario.workload && scenario.stressFixture?.kind === "media",
1677
+ )
1678
+ ) {
1679
+ errors.push(
1680
+ 'Custom renderers with canvas upload must include a workload media-import performance scenario with stressFixture.kind "media".',
1681
+ );
1682
+ }
1046
1683
  }
1047
1684
 
1048
1685
  if (hasKeyframeTimeline(schema)) {