@pixel-point/toolcraft 0.0.7 → 0.0.9

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 (62) hide show
  1. package/package.json +1 -1
  2. package/src/generate.mjs +34 -5
  3. package/src/generate.test.mjs +12 -0
  4. package/src/package-json.mjs +15 -0
  5. package/src/package-json.test.mjs +14 -1
  6. package/templates/runtime/contracts/component-contracts.test.ts +185 -23
  7. package/templates/runtime/contracts/component-contracts.ts +91 -36
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +4 -4
  10. package/templates/runtime/export/export.test.ts +31 -0
  11. package/templates/runtime/export/export.ts +41 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  13. package/templates/runtime/react/canvas-shell.tsx +178 -23
  14. package/templates/runtime/react/control-conditions.ts +166 -0
  15. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  16. package/templates/runtime/react/controls-panel.test.tsx +774 -17
  17. package/templates/runtime/react/controls-panel.tsx +155 -8
  18. package/templates/runtime/react/media-file.ts +19 -0
  19. package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
  20. package/templates/runtime/schema/define-toolcraft.ts +29 -3
  21. package/templates/runtime/schema/types.ts +7 -0
  22. package/templates/runtime/state/reducer.test.ts +304 -0
  23. package/templates/runtime/state/reducer.ts +148 -9
  24. package/templates/runtime/state/types.ts +10 -2
  25. package/templates/runtime/testing/performance.test.ts +1424 -56
  26. package/templates/runtime/testing/performance.ts +710 -43
  27. package/templates/starter/AGENTS.md +10 -9
  28. package/templates/starter/docs/toolcraft/README.md +1 -1
  29. package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
  30. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
  31. package/templates/starter/docs/toolcraft/component-rules.md +46 -16
  32. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  33. package/templates/starter/docs/toolcraft/performance.md +54 -6
  34. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  35. package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
  36. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  37. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  38. package/templates/starter/e2e/performance-helpers.ts +197 -0
  39. package/templates/starter/gitignore +1 -0
  40. package/templates/starter/package.json +5 -0
  41. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  42. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  43. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  44. package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
  45. package/templates/starter/src/app/starter-acceptance.ts +740 -24
  46. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  47. package/templates/ui/components/control-layout/index.tsx +8 -3
  48. package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
  49. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  50. package/templates/ui/components/controls/color/index.ts +4 -1
  51. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  52. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  53. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  54. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  55. package/templates/ui/components/controls/index.ts +3 -0
  56. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  57. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  58. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  59. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  60. package/templates/ui/components/controls/vector/index.ts +1 -0
  61. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  62. package/templates/ui/components/panel/panel-section.tsx +82 -6
@@ -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,47 @@ 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 getSliderDragControlType(
459
+ control: ToolcraftControlSchema | undefined,
460
+ ): "rangeSlider" | "slider" | null {
461
+ if (control?.type === "slider" || control?.type === "rangeSlider") {
462
+ return control.type;
463
+ }
464
+
465
+ return null;
466
+ }
467
+
468
+ function hasControlDragScenario(scenarios: readonly ToolcraftPerformanceScenario[]): boolean {
469
+ return scenarios.some((scenario) => scenario.interaction === "control-drag");
470
+ }
471
+
472
+ function hasPerformanceFixtureValue(
473
+ fixture: ToolcraftPerformanceFixture,
474
+ ): fixture is ToolcraftPerformanceFixture & { value: unknown } {
315
475
  return Object.prototype.hasOwnProperty.call(fixture, "value");
316
476
  }
317
477
 
478
+ function isPerformanceFixtureObjectValue(value: unknown): value is Record<string, unknown> {
479
+ return typeof value === "object" && value !== null && !Array.isArray(value);
480
+ }
481
+
482
+ function getMediaStressFixtureDimensions(
483
+ value: unknown,
484
+ ): { height: number; width: number } | null {
485
+ if (!isPerformanceFixtureObjectValue(value)) {
486
+ return null;
487
+ }
488
+
489
+ const width = Number(value.width);
490
+ const height = Number(value.height);
491
+
492
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
493
+ return null;
494
+ }
495
+
496
+ return { height, width };
497
+ }
498
+
318
499
  function getTextLineCount(value: string): number {
319
500
  if (value.length === 0) {
320
501
  return 0;
@@ -340,50 +521,79 @@ function isLargeTextWorkloadControl(
340
521
  );
341
522
  }
342
523
 
343
- function getStressFixtureErrors(
344
- scenario: ToolcraftPerformanceScenario,
345
- control: ToolcraftControlSchema | undefined,
524
+ function getPerformanceFixtureShapeErrors(
525
+ scenarioId: string,
526
+ fieldName: "stressFixture" | "workloadFixture",
527
+ fixture: ToolcraftPerformanceFixture,
528
+ options: {
529
+ requireLargeText?: boolean;
530
+ requireMedia?: boolean;
531
+ } = {},
346
532
  ): string[] {
347
- if (!scenario.workload) {
348
- return [];
349
- }
350
-
351
533
  const errors: string[] = [];
352
- const fixture = scenario.stressFixture;
353
534
 
354
- if (!fixture) {
355
- return [
356
- `${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
357
- ];
535
+ if (!performanceFixtureKinds.has(fixture.kind)) {
536
+ errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
358
537
  }
359
538
 
360
- if (!stressFixtureKinds.has(fixture.kind)) {
361
- errors.push(`${scenario.id} stressFixture.kind "${fixture.kind}" is not supported.`);
539
+ if (options.requireMedia) {
540
+ if (fixture.kind !== "media") {
541
+ errors.push(
542
+ `${scenarioId} media-import scenario must use ${fieldName}.kind "media" with a realistic uploaded source size.`,
543
+ );
544
+ }
362
545
  }
363
546
 
364
547
  if (!fixture.reason.trim()) {
365
548
  errors.push(
366
- `${scenario.id} stressFixture must explain why this is the heaviest useful fixture.`,
549
+ `${scenarioId} ${fieldName} must explain why this is the heaviest useful fixture.`,
367
550
  );
368
551
  }
369
552
 
370
- if (fixture.kind !== "custom" && !hasStressFixtureValue(fixture)) {
371
- errors.push(`${scenario.id} stressFixture must include value so browser tests can use it.`);
553
+ if (!hasPerformanceFixtureValue(fixture)) {
554
+ errors.push(
555
+ `${scenarioId} ${fieldName} must include value so browser tests can apply the exact heavy state.`,
556
+ );
557
+ } else if (fixture.kind === "custom") {
558
+ if (!isPerformanceFixtureObjectValue(fixture.value)) {
559
+ errors.push(
560
+ `${scenarioId} custom ${fieldName}.value must be an object with one key per heavy state part so browser tests can apply every key.`,
561
+ );
562
+ } else if (Object.keys(fixture.value).length === 0) {
563
+ errors.push(
564
+ `${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
565
+ );
566
+ }
567
+ } else if (fixture.kind === "media") {
568
+ const dimensions = getMediaStressFixtureDimensions(fixture.value);
569
+
570
+ if (!dimensions) {
571
+ errors.push(
572
+ `${scenarioId} media ${fieldName}.value must be an object with numeric width and height so browser tests can generate or load a realistic source image.`,
573
+ );
574
+ } else if (
575
+ Math.max(dimensions.width, dimensions.height) < mediaStressMinLongEdge ||
576
+ dimensions.width * dimensions.height < mediaStressMinArea
577
+ ) {
578
+ errors.push(
579
+ `${scenarioId} media ${fieldName}.value must be at least 1920x1080-equivalent; received ${dimensions.width}x${dimensions.height}.`,
580
+ );
581
+ }
372
582
  }
373
583
 
374
- if (!isLargeTextWorkloadControl(control) && fixture.kind !== "large-text") {
584
+ if (!options.requireLargeText && fixture.kind !== "large-text") {
375
585
  return errors;
376
586
  }
377
587
 
378
588
  if (fixture.kind !== "large-text") {
379
589
  errors.push(
380
- `${scenario.id} text workload scenario must use stressFixture.kind "large-text" with a real long text value.`,
590
+ `${scenarioId} text workload scenario must use ${fieldName}.kind "large-text" with a real long text value.`,
381
591
  );
382
592
  return errors;
383
593
  }
384
594
 
385
595
  if (typeof fixture.value !== "string") {
386
- errors.push(`${scenario.id} large-text stressFixture.value must be a string.`);
596
+ errors.push(`${scenarioId} large-text ${fieldName}.value must be a string.`);
387
597
  return errors;
388
598
  }
389
599
 
@@ -392,31 +602,148 @@ function getStressFixtureErrors(
392
602
 
393
603
  if (minChars < largeTextStressMinChars) {
394
604
  errors.push(
395
- `${scenario.id} large-text stressFixture.minChars must be >= ${largeTextStressMinChars}.`,
605
+ `${scenarioId} large-text ${fieldName}.minChars must be >= ${largeTextStressMinChars}.`,
396
606
  );
397
607
  }
398
608
 
399
609
  if (minLines < largeTextStressMinLines) {
400
610
  errors.push(
401
- `${scenario.id} large-text stressFixture.minLines must be >= ${largeTextStressMinLines}.`,
611
+ `${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
402
612
  );
403
613
  }
404
614
 
405
615
  if (fixture.value.length < minChars) {
406
616
  errors.push(
407
- `${scenario.id} large-text stressFixture.value must contain at least ${minChars} characters.`,
617
+ `${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
408
618
  );
409
619
  }
410
620
 
411
621
  if (getTextLineCount(fixture.value) < minLines) {
412
622
  errors.push(
413
- `${scenario.id} large-text stressFixture.value must contain at least ${minLines} lines.`,
623
+ `${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
414
624
  );
415
625
  }
416
626
 
417
627
  return errors;
418
628
  }
419
629
 
630
+ function getStressFixtureErrors(
631
+ scenario: ToolcraftPerformanceScenario,
632
+ control: ToolcraftControlSchema | undefined,
633
+ ): string[] {
634
+ const isMediaImport = scenario.interaction === "media-import";
635
+
636
+ if (!scenario.workload && scenario.stress !== true && !scenario.stressFixture && !isMediaImport) {
637
+ return [];
638
+ }
639
+
640
+ const fixture = scenario.stressFixture;
641
+
642
+ if (!fixture) {
643
+ if (isMediaImport) {
644
+ return [
645
+ `${scenario.id} media-import scenario must declare stressFixture.kind "media" with a realistic uploaded source size.`,
646
+ ];
647
+ }
648
+
649
+ return scenario.workload
650
+ ? [
651
+ `${scenario.id} workload scenario must declare stressFixture with the real heaviest value used by browser performance tests.`,
652
+ ]
653
+ : [
654
+ `${scenario.id} stress scenario must declare stressFixture with the real heaviest state used by browser performance tests.`,
655
+ ];
656
+ }
657
+
658
+ const errors: string[] = [];
659
+
660
+ if (isMediaImport && !scenario.workload) {
661
+ errors.push(
662
+ `${scenario.id} media-import scenario must set workload true because decoded source size changes renderer workload.`,
663
+ );
664
+ }
665
+
666
+ errors.push(
667
+ ...getPerformanceFixtureShapeErrors(scenario.id, "stressFixture", fixture, {
668
+ requireLargeText: isLargeTextWorkloadControl(control),
669
+ requireMedia: isMediaImport,
670
+ }),
671
+ );
672
+
673
+ return errors;
674
+ }
675
+
676
+ function stressFixtureAlreadyDefinesIndependentWorkload(
677
+ scenario: ToolcraftPerformanceScenario,
678
+ control: ToolcraftControlSchema | undefined,
679
+ ): boolean {
680
+ return (
681
+ scenario.stressFixture?.kind === "large-text" &&
682
+ isLargeTextWorkloadControl(control)
683
+ );
684
+ }
685
+
686
+ function isWorkloadBaselineSensitiveScenario(
687
+ config: ToolcraftPerformanceConfig,
688
+ scenario: ToolcraftPerformanceScenario,
689
+ control: ToolcraftControlSchema | undefined,
690
+ ): boolean {
691
+ if (
692
+ !scenario.workload ||
693
+ (scenario.interaction !== "control-change" && scenario.interaction !== "control-drag")
694
+ ) {
695
+ return false;
696
+ }
697
+
698
+ if (!config.usesCustomRenderer) {
699
+ return false;
700
+ }
701
+
702
+ if (config.rendererWorkload === "none" || config.rendererWorkload === "simple-composition") {
703
+ return false;
704
+ }
705
+
706
+ return !stressFixtureAlreadyDefinesIndependentWorkload(scenario, control);
707
+ }
708
+
709
+ function getWorkloadFixtureErrors(
710
+ config: ToolcraftPerformanceConfig,
711
+ scenario: ToolcraftPerformanceScenario,
712
+ control: ToolcraftControlSchema | undefined,
713
+ ): string[] {
714
+ const requiresBaseline = isWorkloadBaselineSensitiveScenario(config, scenario, control);
715
+ const errors: string[] = [];
716
+
717
+ if (scenario.workloadFixture && !scenario.stressFixture) {
718
+ errors.push(
719
+ `${scenario.id} workloadFixture must be paired with stressFixture so tests apply a heavy baseline and then the measured scenario value.`,
720
+ );
721
+ }
722
+
723
+ if (!requiresBaseline && !scenario.workloadFixture) {
724
+ return errors;
725
+ }
726
+
727
+ const fixture = scenario.workloadFixture;
728
+
729
+ if (!fixture) {
730
+ errors.push(
731
+ `${scenario.id} workload control scenario must declare workloadFixture for the app's heavy baseline state; stressFixture covers the control value only.`,
732
+ );
733
+ return errors;
734
+ }
735
+
736
+ errors.push(
737
+ ...getPerformanceFixtureShapeErrors(
738
+ scenario.id,
739
+ "workloadFixture",
740
+ fixture,
741
+ ),
742
+ );
743
+
744
+ return errors;
745
+ }
746
+
420
747
  function hasPositiveBudgetField(
421
748
  budget: ToolcraftPerformanceBudget,
422
749
  field: keyof ToolcraftPerformanceBudget,
@@ -438,6 +765,7 @@ function getMissingInteractionBudgetFields(
438
765
  ? ["maxFrameGapMs"]
439
766
  : ["maxFrameGapMs", "maxLongTaskMs"];
440
767
  case "animation-viewport-drag":
768
+ case "mask-drag":
441
769
  case "viewport-zoom-stress":
442
770
  return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
443
771
  (field) => !hasPositiveBudgetField(scenario.budget, field),
@@ -459,6 +787,14 @@ function getMissingInteractionBudgetFields(
459
787
  hasPositiveBudgetField(scenario.budget, "maxRenderMs")
460
788
  ? []
461
789
  : ["maxPreviewMs or maxRenderMs"];
790
+ case "timeline-playback":
791
+ return (["maxFrameGapMs", "maxLongTaskMs"] as const).filter(
792
+ (field) => !hasPositiveBudgetField(scenario.budget, field),
793
+ );
794
+ case "timeline-scrub":
795
+ return (["maxInteractionMs", "maxFrameGapMs", "maxLongTaskMs"] as const).filter(
796
+ (field) => !hasPositiveBudgetField(scenario.budget, field),
797
+ );
462
798
  }
463
799
  }
464
800
 
@@ -476,7 +812,13 @@ function getBudgetCapErrors(scenario: ToolcraftPerformanceScenario): string[] {
476
812
  }
477
813
 
478
814
  function requiresConcreteUiTarget(interaction: ToolcraftPerformanceInteraction): boolean {
479
- return interaction === "control-change" || interaction === "control-drag";
815
+ return (
816
+ interaction === "control-change" ||
817
+ interaction === "control-drag" ||
818
+ interaction === "mask-drag" ||
819
+ interaction === "timeline-playback" ||
820
+ interaction === "timeline-scrub"
821
+ );
480
822
  }
481
823
 
482
824
  function getAllSchemaControls(
@@ -588,6 +930,32 @@ function hasHighCountCanvas2DRendererLayer(
588
930
  );
589
931
  }
590
932
 
933
+ function hasDetailHeavyCanvas2DRendererLayer(
934
+ technique: ToolcraftRendererTechnique | undefined,
935
+ ): boolean {
936
+ return (technique?.layers ?? []).some(
937
+ (layer) =>
938
+ layer.renderer === "canvas-2d" &&
939
+ layer.content.some((content) => detailHeavyRendererContent.has(content)),
940
+ );
941
+ }
942
+
943
+ function hasMeasuredGpuAlternativeEvidence(
944
+ technique: ToolcraftRendererTechnique,
945
+ ): boolean {
946
+ const evidenceText = [
947
+ ...technique.whyNotAlternativeStrategies,
948
+ ...technique.performanceRisks,
949
+ ].join(" ");
950
+
951
+ return (
952
+ /\b(?:webgl|webgpu|gpu)\b/i.test(evidenceText) &&
953
+ /\b(?:budget|evidence|frame|long task|measure|measured|perf|performance|stress)\b/i.test(
954
+ evidenceText,
955
+ )
956
+ );
957
+ }
958
+
591
959
  function hasStressPreviewOrAnimationScenario(config: ToolcraftPerformanceConfig): boolean {
592
960
  return config.scenarios.some(
593
961
  (scenario) =>
@@ -767,6 +1135,267 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
767
1135
 
768
1136
  errors.push(...getRendererLayerErrors(technique));
769
1137
 
1138
+ if (
1139
+ config.usesCustomRenderer &&
1140
+ technique.rendererStrategy === "canvas-2d" &&
1141
+ hasDetailHeavyCanvas2DRendererLayer(technique) &&
1142
+ !hasMeasuredGpuAlternativeEvidence(technique)
1143
+ ) {
1144
+ errors.push(
1145
+ "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.",
1146
+ );
1147
+ }
1148
+
1149
+ return errors;
1150
+ }
1151
+
1152
+ function getPassById(
1153
+ pipeline: ToolcraftRendererPipeline,
1154
+ ): Map<string, ToolcraftRenderPass> {
1155
+ return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
1156
+ }
1157
+
1158
+ function hasPipelineReference(value: string): boolean {
1159
+ return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
1160
+ }
1161
+
1162
+ function getPipelineReferenceErrors(
1163
+ passId: string,
1164
+ field: string,
1165
+ references: readonly string[] | undefined,
1166
+ ): string[] {
1167
+ if (!references || references.length === 0) {
1168
+ return [`rendererPipeline pass "${passId}" must list ${field}.`];
1169
+ }
1170
+
1171
+ return references.flatMap((reference) =>
1172
+ hasPipelineReference(reference)
1173
+ ? []
1174
+ : [
1175
+ `rendererPipeline pass "${passId}" ${field} entry "${reference}" is too vague. Name the concrete runtime target, source key, resource key, or cache key part.`,
1176
+ ],
1177
+ );
1178
+ }
1179
+
1180
+ function getPipelineInteractionForScenario(
1181
+ interaction: ToolcraftPerformanceInteraction,
1182
+ ): ToolcraftPipelineInteraction | null {
1183
+ switch (interaction) {
1184
+ case "animation-frame":
1185
+ return "animation-frame";
1186
+ case "animation-viewport-drag":
1187
+ return "viewport-drag";
1188
+ case "control-change":
1189
+ return "control-change";
1190
+ case "control-drag":
1191
+ return "control-drag";
1192
+ case "export-copy":
1193
+ return "export";
1194
+ case "mask-drag":
1195
+ return "mask-drag";
1196
+ case "media-import":
1197
+ return "media-import";
1198
+ case "preview-render":
1199
+ return null;
1200
+ case "timeline-playback":
1201
+ return "timeline-playback";
1202
+ case "timeline-scrub":
1203
+ return "timeline-scrub";
1204
+ case "viewport-zoom-stress":
1205
+ return "viewport-zoom";
1206
+ case "viewport-stability":
1207
+ return null;
1208
+ }
1209
+ }
1210
+
1211
+ function getRendererPipelineErrors(
1212
+ _schema: ResolvedToolcraftAppSchema,
1213
+ config: ToolcraftPerformanceConfig,
1214
+ ): string[] {
1215
+ const errors: string[] = [];
1216
+ const pipeline = config.rendererPipeline;
1217
+
1218
+ if (config.usesCustomRenderer && !pipeline) {
1219
+ return [
1220
+ "Custom renderers must declare rendererPipeline so render passes, cache keys, and invalidation are machine-checkable.",
1221
+ ];
1222
+ }
1223
+
1224
+ if (!config.usesCustomRenderer && pipeline) {
1225
+ return ["Non-custom renderer configs must omit rendererPipeline."];
1226
+ }
1227
+
1228
+ if (!pipeline) {
1229
+ return errors;
1230
+ }
1231
+
1232
+ if (pipeline.passes.length === 0) {
1233
+ errors.push("rendererPipeline must declare at least one render pass.");
1234
+ }
1235
+
1236
+ if (pipeline.interactionInvalidation.length === 0) {
1237
+ errors.push(
1238
+ "rendererPipeline must declare interactionInvalidation so high-frequency UI work cannot accidentally invalidate expensive passes.",
1239
+ );
1240
+ }
1241
+
1242
+ const passIds = new Set<string>();
1243
+
1244
+ for (const pass of pipeline.passes) {
1245
+ const passId = pass.id.trim();
1246
+
1247
+ if (!passId) {
1248
+ errors.push("rendererPipeline passes must have non-empty ids.");
1249
+ } else if (passIds.has(passId)) {
1250
+ errors.push(`rendererPipeline pass id "${passId}" must be unique.`);
1251
+ } else {
1252
+ passIds.add(passId);
1253
+ }
1254
+
1255
+ if (!renderPassKinds.has(pass.kind)) {
1256
+ errors.push(`rendererPipeline pass "${pass.id}" kind "${pass.kind}" is not supported.`);
1257
+ }
1258
+
1259
+ if (!renderPassRunLocations.has(pass.runsOn)) {
1260
+ errors.push(
1261
+ `rendererPipeline pass "${pass.id}" runsOn "${pass.runsOn}" is not supported.`,
1262
+ );
1263
+ }
1264
+
1265
+ if (!renderPassOutputs.has(pass.output)) {
1266
+ errors.push(
1267
+ `rendererPipeline pass "${pass.id}" output "${pass.output}" is not supported.`,
1268
+ );
1269
+ }
1270
+
1271
+ if (!renderPassQualities.has(pass.quality)) {
1272
+ errors.push(
1273
+ `rendererPipeline pass "${pass.id}" quality "${pass.quality}" is not supported.`,
1274
+ );
1275
+ }
1276
+
1277
+ errors.push(...getPipelineReferenceErrors(pass.id, "inputs", pass.inputs));
1278
+ errors.push(...getPipelineReferenceErrors(pass.id, "invalidatedBy", pass.invalidatedBy));
1279
+
1280
+ if (pass.cacheKey) {
1281
+ errors.push(...getPipelineReferenceErrors(pass.id, "cacheKey", pass.cacheKey));
1282
+ }
1283
+
1284
+ if (
1285
+ cacheRequiredRenderPassKinds.has(pass.kind) &&
1286
+ (!pass.cacheKey || pass.cacheKey.length === 0)
1287
+ ) {
1288
+ errors.push(
1289
+ `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.`,
1290
+ );
1291
+ }
1292
+
1293
+ if (pass.kind === "decode") {
1294
+ const hasMediaImportScenario = config.scenarios.some(
1295
+ (scenario) => scenario.interaction === "media-import",
1296
+ );
1297
+
1298
+ if (!hasMediaImportScenario) {
1299
+ errors.push(
1300
+ `rendererPipeline pass "${pass.id}" decodes media, so performance scenarios must include media-import coverage.`,
1301
+ );
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ const passesById = getPassById(pipeline);
1307
+ const invalidationTargets = new Set<string>();
1308
+ const pipelineInteractionSet = new Set(
1309
+ pipeline.interactionInvalidation.map((invalidation) => invalidation.interaction),
1310
+ );
1311
+
1312
+ for (const invalidation of pipeline.interactionInvalidation) {
1313
+ if (!pipelineInteractions.has(invalidation.interaction)) {
1314
+ errors.push(
1315
+ `rendererPipeline interaction "${invalidation.interaction}" is not supported.`,
1316
+ );
1317
+ }
1318
+
1319
+ errors.push(
1320
+ ...getPipelineReferenceErrors(
1321
+ invalidation.interaction,
1322
+ "targets",
1323
+ invalidation.targets,
1324
+ ),
1325
+ );
1326
+
1327
+ for (const target of invalidation.targets) {
1328
+ if (hasPipelineReference(target)) {
1329
+ invalidationTargets.add(target);
1330
+ }
1331
+ }
1332
+
1333
+ const mustNotInvalidate = new Set(invalidation.mustNotInvalidate ?? []);
1334
+
1335
+ for (const passId of invalidation.invalidates) {
1336
+ if (!passId.trim()) {
1337
+ errors.push(
1338
+ `rendererPipeline ${invalidation.interaction} invalidates contains an empty pass id.`,
1339
+ );
1340
+ continue;
1341
+ }
1342
+
1343
+ const pass = passesById.get(passId);
1344
+
1345
+ if (!pass) {
1346
+ errors.push(
1347
+ `rendererPipeline ${invalidation.interaction} invalidates unknown pass "${passId}".`,
1348
+ );
1349
+ continue;
1350
+ }
1351
+
1352
+ if (mustNotInvalidate.has(passId)) {
1353
+ errors.push(
1354
+ `rendererPipeline ${invalidation.interaction} cannot both invalidate and mustNotInvalidate pass "${passId}".`,
1355
+ );
1356
+ }
1357
+
1358
+ if (
1359
+ highFrequencyViewportInteractions.has(invalidation.interaction) &&
1360
+ expensiveRenderPassKinds.has(pass.kind)
1361
+ ) {
1362
+ errors.push(
1363
+ `rendererPipeline ${invalidation.interaction} must not invalidate expensive pass "${passId}" (${pass.kind}). Move viewport work to transforms/uniforms or explain it through a cheaper pass.`,
1364
+ );
1365
+ }
1366
+ }
1367
+
1368
+ for (const passId of invalidation.mustNotInvalidate ?? []) {
1369
+ if (!passId.trim()) {
1370
+ errors.push(
1371
+ `rendererPipeline ${invalidation.interaction} mustNotInvalidate contains an empty pass id.`,
1372
+ );
1373
+ } else if (!passesById.has(passId)) {
1374
+ errors.push(
1375
+ `rendererPipeline ${invalidation.interaction} mustNotInvalidate unknown pass "${passId}".`,
1376
+ );
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ for (const scenario of config.scenarios) {
1382
+ const pipelineInteraction = getPipelineInteractionForScenario(scenario.interaction);
1383
+
1384
+ if (pipelineInteraction && !pipelineInteractionSet.has(pipelineInteraction)) {
1385
+ errors.push(
1386
+ `Performance scenario ${scenario.id} exercises ${pipelineInteraction}, so rendererPipeline.interactionInvalidation must declare that interaction.`,
1387
+ );
1388
+ }
1389
+ }
1390
+
1391
+ for (const target of config.workloadTargets) {
1392
+ if (!invalidationTargets.has(target)) {
1393
+ errors.push(
1394
+ `Performance workload target ${target} must appear in rendererPipeline interactionInvalidation targets.`,
1395
+ );
1396
+ }
1397
+ }
1398
+
770
1399
  return errors;
771
1400
  }
772
1401
 
@@ -790,6 +1419,7 @@ export function validateToolcraftPerformanceCoverage(
790
1419
  );
791
1420
 
792
1421
  errors.push(...getRendererTechniqueErrors(config));
1422
+ errors.push(...getRendererPipelineErrors(schema, config));
793
1423
 
794
1424
  if (config.usesCustomRenderer && !customRendererStrategies.has(config.rendererStrategy)) {
795
1425
  errors.push(
@@ -817,10 +1447,11 @@ export function validateToolcraftPerformanceCoverage(
817
1447
 
818
1448
  if (
819
1449
  config.rendererWorkload === "pixel-output" &&
820
- !gpuRendererStrategies.has(config.rendererStrategy)
1450
+ !gpuRendererStrategies.has(config.rendererStrategy) &&
1451
+ (!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
821
1452
  ) {
822
1453
  errors.push(
823
- `rendererWorkload "pixel-output" must use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}".`,
1454
+ `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
1455
  );
825
1456
  }
826
1457
 
@@ -910,11 +1541,17 @@ export function validateToolcraftPerformanceCoverage(
910
1541
  }
911
1542
  }
912
1543
 
913
- if (scenario.workload && !hasMinDefaultMax(scenario.values)) {
1544
+ if (
1545
+ scenario.workload &&
1546
+ scenario.interaction !== "media-import" &&
1547
+ !hasMinDefaultMax(scenario.values)
1548
+ ) {
914
1549
  errors.push(`${scenario.id} workload scenario must include min/default/max values.`);
915
1550
  }
916
1551
 
917
- errors.push(...getStressFixtureErrors(scenario, controlsByTarget.get(scenario.target ?? "")));
1552
+ const scenarioControl = controlsByTarget.get(scenario.target ?? "");
1553
+ errors.push(...getStressFixtureErrors(scenario, scenarioControl));
1554
+ errors.push(...getWorkloadFixtureErrors(config, scenario, scenarioControl));
918
1555
 
919
1556
  if (
920
1557
  requiresConcreteUiTarget(scenario.interaction) &&
@@ -964,24 +1601,40 @@ export function validateToolcraftPerformanceCoverage(
964
1601
  }
965
1602
 
966
1603
  const targetScenarios = scenariosByTarget.get(target) ?? [];
1604
+ const targetControl = controlsByTarget.get(target);
1605
+ const targetRequiresDrag = getSliderDragControlType(targetControl) !== null;
967
1606
  const hasWorkloadCoverage = targetScenarios.some(
968
1607
  (scenario) =>
969
1608
  scenario.workload &&
970
- (scenario.interaction === "control-drag" ||
971
- scenario.interaction === "control-change") &&
1609
+ (targetRequiresDrag
1610
+ ? scenario.interaction === "control-drag"
1611
+ : scenario.interaction === "control-drag" ||
1612
+ scenario.interaction === "control-change") &&
972
1613
  hasMinDefaultMax(scenario.values),
973
1614
  );
974
1615
 
975
1616
  if (!hasWorkloadCoverage) {
976
- errors.push(`${target} must have min/default/max workload performance coverage.`);
1617
+ errors.push(
1618
+ targetRequiresDrag
1619
+ ? `${target} must have min/default/max workload performance coverage through a real control-drag scenario.`
1620
+ : `${target} must have min/default/max workload performance coverage.`,
1621
+ );
977
1622
  }
978
1623
  }
979
1624
 
980
1625
  for (const target of getVisiblePerformanceControlTargets(schema)) {
981
- if (!scenariosByTarget.has(target)) {
1626
+ const targetScenarios = scenariosByTarget.get(target) ?? [];
1627
+ const targetControl = controlsByTarget.get(target);
1628
+ const sliderControlType = getSliderDragControlType(targetControl);
1629
+
1630
+ if (targetScenarios.length === 0) {
982
1631
  errors.push(
983
1632
  `${target} must have a performance scenario because every visible control can affect app responsiveness.`,
984
1633
  );
1634
+ } else if (sliderControlType && !hasControlDragScenario(targetScenarios)) {
1635
+ errors.push(
1636
+ `${target} is a ${sliderControlType} and must have a control-drag performance scenario proving live canvas/product feedback while dragging.`,
1637
+ );
985
1638
  }
986
1639
  }
987
1640
 
@@ -1039,10 +1692,24 @@ export function validateToolcraftPerformanceCoverage(
1039
1692
  }
1040
1693
  }
1041
1694
 
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.",
1695
+ if (schema.canvas.upload) {
1696
+ const mediaImportScenarios = config.scenarios.filter(
1697
+ (scenario) => scenario.interaction === "media-import",
1045
1698
  );
1699
+
1700
+ if (mediaImportScenarios.length === 0) {
1701
+ errors.push(
1702
+ "Custom renderers with canvas upload must include a media-import performance scenario.",
1703
+ );
1704
+ } else if (
1705
+ !mediaImportScenarios.some(
1706
+ (scenario) => scenario.workload && scenario.stressFixture?.kind === "media",
1707
+ )
1708
+ ) {
1709
+ errors.push(
1710
+ 'Custom renderers with canvas upload must include a workload media-import performance scenario with stressFixture.kind "media".',
1711
+ );
1712
+ }
1046
1713
  }
1047
1714
 
1048
1715
  if (hasKeyframeTimeline(schema)) {