@pixel-point/toolcraft 0.0.9 → 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 (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -2,6 +2,7 @@ import type {
2
2
  ToolcraftControlSchema,
3
3
  ResolvedToolcraftAppSchema,
4
4
  } from "../schema/types";
5
+ import { isToolcraftRuntimeOwnedTarget } from "../schema/runtime-targets";
5
6
 
6
7
  export type ToolcraftPerformanceInteraction =
7
8
  | "animation-frame"
@@ -24,6 +25,20 @@ export type ToolcraftPerformanceCoverage = {
24
25
  browserTestName: string;
25
26
  };
26
27
 
28
+ export type ToolcraftBrowserCheckPreferredRunner = "agent-browser";
29
+
30
+ export type ToolcraftBrowserCheckFallbackRunner = "playwright";
31
+
32
+ export type ToolcraftBrowserCheckFallbackCondition =
33
+ | "agent-browser-unavailable"
34
+ | "ci";
35
+
36
+ export type ToolcraftBrowserCheckPolicy = {
37
+ fallbackRunner: ToolcraftBrowserCheckFallbackRunner;
38
+ fallbackWhen: readonly ToolcraftBrowserCheckFallbackCondition[];
39
+ preferredRunner: ToolcraftBrowserCheckPreferredRunner;
40
+ };
41
+
27
42
  export type ToolcraftPerformanceBudget = {
28
43
  maxExportMs?: number;
29
44
  maxFrameGapMs?: number;
@@ -48,8 +63,42 @@ export type ToolcraftPerformanceFixtureKind =
48
63
  | "max-value"
49
64
  | "media";
50
65
 
66
+ export type ToolcraftPerformanceLoadMetric =
67
+ | "canvas-area"
68
+ | "count"
69
+ | "custom"
70
+ | "media-area"
71
+ | "numeric-max"
72
+ | "numeric-min"
73
+ | "text-length";
74
+
75
+ export type ToolcraftPerformanceUserFacingRange =
76
+ | "experimental-above-smooth"
77
+ | "fully-guaranteed";
78
+
79
+ export type ToolcraftPerformanceLoadEvidence = {
80
+ attemptedTarget: unknown;
81
+ decision: string;
82
+ measuredResult: string;
83
+ optimizationAttempted?: string;
84
+ result: "failed" | "passed";
85
+ scenarioId: string;
86
+ };
87
+
88
+ export type ToolcraftPerformanceLoadProfile = {
89
+ degradationStepPercent?: 10;
90
+ evidence?: readonly ToolcraftPerformanceLoadEvidence[];
91
+ hardLimit: unknown;
92
+ metric: ToolcraftPerformanceLoadMetric;
93
+ smoothTarget: unknown;
94
+ smoothTargetRatio: number;
95
+ target: string;
96
+ userFacingRange: ToolcraftPerformanceUserFacingRange;
97
+ };
98
+
51
99
  export type ToolcraftPerformanceFixture = {
52
100
  kind: ToolcraftPerformanceFixtureKind;
101
+ loadProfile?: ToolcraftPerformanceLoadProfile;
53
102
  minChars?: number;
54
103
  minCount?: number;
55
104
  minLines?: number;
@@ -162,11 +211,20 @@ export type ToolcraftRendererLayer = {
162
211
  uiSelector?: string;
163
212
  };
164
213
 
214
+ export type ToolcraftRendererMeasuredAlternativeEvidence = {
215
+ alternativeStrategy: "webgl" | "webgpu";
216
+ decision: string;
217
+ fixture: string;
218
+ measuredResult: string;
219
+ scenarioId: string;
220
+ };
221
+
165
222
  export type ToolcraftRendererTechnique = {
166
223
  exportRenderer: ToolcraftExportRenderer;
167
224
  fidelityRisks: readonly string[];
168
225
  intentionalRasterizationReason?: string;
169
226
  layers?: readonly ToolcraftRendererLayer[];
227
+ measuredAlternativeEvidence?: readonly ToolcraftRendererMeasuredAlternativeEvidence[];
170
228
  performanceRisks: readonly string[];
171
229
  previewExportDifferenceReason?: string;
172
230
  previewRenderer: ToolcraftPreviewRenderer;
@@ -241,6 +299,7 @@ export type ToolcraftRendererPipeline = {
241
299
  };
242
300
 
243
301
  export type ToolcraftPerformanceConfig = {
302
+ browserCheckPolicy?: ToolcraftBrowserCheckPolicy;
244
303
  rendererPipeline?: ToolcraftRendererPipeline;
245
304
  rendererStrategy: ToolcraftRendererStrategy;
246
305
  rendererTechnique?: ToolcraftRendererTechnique;
@@ -271,10 +330,25 @@ const maxPerformanceBudgetCaps: Required<ToolcraftPerformanceBudget> = {
271
330
  maxRenderMs: 2000,
272
331
  };
273
332
 
333
+ export const defaultToolcraftBrowserCheckPolicy: ToolcraftBrowserCheckPolicy = {
334
+ fallbackRunner: "playwright",
335
+ fallbackWhen: ["agent-browser-unavailable", "ci"],
336
+ preferredRunner: "agent-browser",
337
+ };
338
+
274
339
  export function defineToolcraftPerformance(
275
340
  config: ToolcraftPerformanceConfig,
276
- ): ToolcraftPerformanceConfig {
277
- return config;
341
+ ): ToolcraftPerformanceConfig & { browserCheckPolicy: ToolcraftBrowserCheckPolicy } {
342
+ return {
343
+ ...config,
344
+ browserCheckPolicy: {
345
+ ...defaultToolcraftBrowserCheckPolicy,
346
+ ...config.browserCheckPolicy,
347
+ fallbackWhen:
348
+ config.browserCheckPolicy?.fallbackWhen ??
349
+ defaultToolcraftBrowserCheckPolicy.fallbackWhen,
350
+ },
351
+ };
278
352
  }
279
353
 
280
354
  const workloadControlPattern =
@@ -297,6 +371,42 @@ const performanceFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
297
371
  "media",
298
372
  ]);
299
373
 
374
+ const performanceLoadMetrics = new Set<ToolcraftPerformanceLoadMetric>([
375
+ "canvas-area",
376
+ "count",
377
+ "custom",
378
+ "media-area",
379
+ "numeric-max",
380
+ "numeric-min",
381
+ "text-length",
382
+ ]);
383
+
384
+ const performanceUserFacingRanges = new Set<ToolcraftPerformanceUserFacingRange>([
385
+ "experimental-above-smooth",
386
+ "fully-guaranteed",
387
+ ]);
388
+
389
+ const browserCheckPreferredRunners = new Set<ToolcraftBrowserCheckPreferredRunner>([
390
+ "agent-browser",
391
+ ]);
392
+
393
+ const browserCheckFallbackRunners = new Set<ToolcraftBrowserCheckFallbackRunner>([
394
+ "playwright",
395
+ ]);
396
+
397
+ const browserCheckFallbackConditions = new Set<ToolcraftBrowserCheckFallbackCondition>([
398
+ "agent-browser-unavailable",
399
+ "ci",
400
+ ]);
401
+
402
+ const loadProfileRequiredFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
403
+ "high-density",
404
+ "large-canvas",
405
+ "many-items",
406
+ "max-value",
407
+ "media",
408
+ ]);
409
+
300
410
  const renderPassKinds = new Set<ToolcraftRenderPassKind>([
301
411
  "decode",
302
412
  "preprocess",
@@ -401,7 +511,10 @@ export function collectToolcraftPerformanceSensitiveControls(
401
511
  ): ToolcraftPerformanceSensitiveControl[] {
402
512
  return (schema.panels.controls?.sections ?? []).flatMap((section) =>
403
513
  Object.entries(section.controls)
404
- .filter(([, control]) => isPotentialWorkloadControl(control))
514
+ .filter(
515
+ ([, control]) =>
516
+ !isToolcraftRuntimeOwnedTarget(control.target) && isPotentialWorkloadControl(control),
517
+ )
405
518
  .map(([controlId, control]) => ({
406
519
  control,
407
520
  controlId,
@@ -417,6 +530,7 @@ function collectToolcraftPerformanceRoleConflicts(
417
530
  Object.entries(section.controls)
418
531
  .filter(
419
532
  ([, control]) =>
533
+ !isToolcraftRuntimeOwnedTarget(control.target) &&
420
534
  control.performanceRole === "responsiveness" &&
421
535
  isSemanticallyWorkloadControl(control),
422
536
  )
@@ -436,6 +550,8 @@ export function collectToolcraftUnclassifiedPerformanceControls(
436
550
  .filter(
437
551
  ([, control]) =>
438
552
  control.type !== "panelActions" &&
553
+ control.type !== "settingsTransfer" &&
554
+ !isToolcraftRuntimeOwnedTarget(control.target) &&
439
555
  control.performanceRole !== "workload" &&
440
556
  control.performanceRole !== "responsiveness",
441
557
  )
@@ -455,6 +571,67 @@ function hasMinDefaultMax(values: ToolcraftPerformanceScenario["values"]): boole
455
571
  return values !== undefined && "default" in values && "min" in values && "max" in values;
456
572
  }
457
573
 
574
+ function getBrowserCheckPolicyErrors(config: ToolcraftPerformanceConfig): string[] {
575
+ const errors: string[] = [];
576
+ const policy = config.browserCheckPolicy ?? defaultToolcraftBrowserCheckPolicy;
577
+ const fallbackWhen = policy.fallbackWhen as unknown;
578
+
579
+ if (
580
+ !browserCheckPreferredRunners.has(
581
+ policy.preferredRunner as ToolcraftBrowserCheckPreferredRunner,
582
+ )
583
+ ) {
584
+ errors.push(
585
+ 'browserCheckPolicy.preferredRunner must be "agent-browser" so AI agents use their controlled browser before fallback automation.',
586
+ );
587
+ }
588
+
589
+ if (
590
+ !browserCheckFallbackRunners.has(
591
+ policy.fallbackRunner as ToolcraftBrowserCheckFallbackRunner,
592
+ )
593
+ ) {
594
+ errors.push(
595
+ 'browserCheckPolicy.fallbackRunner must be "playwright" so generated apps keep a portable CI/non-agent fallback.',
596
+ );
597
+ }
598
+
599
+ if (!Array.isArray(fallbackWhen)) {
600
+ errors.push(
601
+ 'browserCheckPolicy.fallbackWhen must include "agent-browser-unavailable" and "ci".',
602
+ );
603
+ return errors;
604
+ }
605
+
606
+ for (const condition of fallbackWhen) {
607
+ if (
608
+ !browserCheckFallbackConditions.has(
609
+ condition as ToolcraftBrowserCheckFallbackCondition,
610
+ )
611
+ ) {
612
+ errors.push(
613
+ `browserCheckPolicy.fallbackWhen contains unsupported condition "${String(
614
+ condition,
615
+ )}".`,
616
+ );
617
+ }
618
+ }
619
+
620
+ if (!fallbackWhen.includes("agent-browser-unavailable")) {
621
+ errors.push(
622
+ 'browserCheckPolicy.fallbackWhen must include "agent-browser-unavailable" so Playwright is only used when no agent browser exists.',
623
+ );
624
+ }
625
+
626
+ if (!fallbackWhen.includes("ci")) {
627
+ errors.push(
628
+ 'browserCheckPolicy.fallbackWhen must include "ci" so CI/non-agent automation has a portable performance runner.',
629
+ );
630
+ }
631
+
632
+ return errors;
633
+ }
634
+
458
635
  function getSliderDragControlType(
459
636
  control: ToolcraftControlSchema | undefined,
460
637
  ): "rangeSlider" | "slider" | null {
@@ -504,6 +681,503 @@ function getTextLineCount(value: string): number {
504
681
  return value.split(/\r\n|\r|\n/).length;
505
682
  }
506
683
 
684
+ function getPerformanceFixtureLargeTextValueErrors(
685
+ scenarioId: string,
686
+ valueLabel: string,
687
+ value: unknown,
688
+ options: {
689
+ minChars?: number;
690
+ minLines?: number;
691
+ } = {},
692
+ ): string[] {
693
+ const errors: string[] = [];
694
+
695
+ if (typeof value !== "string") {
696
+ errors.push(`${scenarioId} ${valueLabel} must be a string.`);
697
+ return errors;
698
+ }
699
+
700
+ const minChars = options.minChars ?? largeTextStressMinChars;
701
+ const minLines = options.minLines ?? largeTextStressMinLines;
702
+
703
+ if (minChars < largeTextStressMinChars) {
704
+ errors.push(`${scenarioId} ${valueLabel}.minChars must be >= ${largeTextStressMinChars}.`);
705
+ }
706
+
707
+ if (minLines < largeTextStressMinLines) {
708
+ errors.push(`${scenarioId} ${valueLabel}.minLines must be >= ${largeTextStressMinLines}.`);
709
+ }
710
+
711
+ if (value.length < minChars) {
712
+ errors.push(`${scenarioId} ${valueLabel} must contain at least ${minChars} characters.`);
713
+ }
714
+
715
+ if (getTextLineCount(value) < minLines) {
716
+ errors.push(`${scenarioId} ${valueLabel} must contain at least ${minLines} lines.`);
717
+ }
718
+
719
+ return errors;
720
+ }
721
+
722
+ function getLastFixturePathSegment(path: string): string {
723
+ return path.split(/[._-]/).filter(Boolean).at(-1) ?? path;
724
+ }
725
+
726
+ function isCustomLargeTextFixturePath(path: string): boolean {
727
+ return /^(?:content|text|prompt|code|script|shader|template|json|css)$/i.test(
728
+ getLastFixturePathSegment(path),
729
+ );
730
+ }
731
+
732
+ function isCustomMediaFixturePath(path: string): boolean {
733
+ return /(?:^|[._-])(?:media|image|video|sourceMedia|sourceImage)(?:$|[._-])/i.test(path);
734
+ }
735
+
736
+ function isCustomRenderScaleFixturePath(path: string): boolean {
737
+ return /^(?:canvas\.)?renderScale$|(?:^|[._-])resolutionScale$/i.test(path);
738
+ }
739
+
740
+ function isCustomCountFixturePath(path: string): boolean {
741
+ return /(?:^|[._-])(?:count|items|layers|particles|points|rows|columns|density|detail|quality|samples|iterations)(?:$|[._-])/i.test(
742
+ path,
743
+ );
744
+ }
745
+
746
+ function customFixtureValueNeedsLoadProfile(value: unknown, pathPrefix = ""): boolean {
747
+ if (!isPerformanceFixtureObjectValue(value)) {
748
+ return false;
749
+ }
750
+
751
+ return Object.entries(value).some(([key, itemValue]) => {
752
+ const itemPath = pathPrefix ? `${pathPrefix}.${key}` : key;
753
+
754
+ if (
755
+ isCustomLargeTextFixturePath(itemPath) ||
756
+ isCustomMediaFixturePath(itemPath) ||
757
+ isCustomRenderScaleFixturePath(itemPath) ||
758
+ isCustomCountFixturePath(itemPath)
759
+ ) {
760
+ return true;
761
+ }
762
+
763
+ if (typeof itemValue === "number" && Number.isFinite(itemValue)) {
764
+ return true;
765
+ }
766
+
767
+ return customFixtureValueNeedsLoadProfile(itemValue, itemPath);
768
+ });
769
+ }
770
+
771
+ function getSortedJsonValue(value: unknown): unknown {
772
+ if (Array.isArray(value)) {
773
+ return value.map(getSortedJsonValue);
774
+ }
775
+
776
+ if (isPerformanceFixtureObjectValue(value)) {
777
+ return Object.fromEntries(
778
+ Object.entries(value)
779
+ .sort(([left], [right]) => left.localeCompare(right))
780
+ .map(([key, itemValue]) => [key, getSortedJsonValue(itemValue)]),
781
+ );
782
+ }
783
+
784
+ return value;
785
+ }
786
+
787
+ function arePerformanceFixtureValuesEqual(left: unknown, right: unknown): boolean {
788
+ return JSON.stringify(getSortedJsonValue(left)) === JSON.stringify(getSortedJsonValue(right));
789
+ }
790
+
791
+ function getNumericFixtureValue(value: unknown): number | null {
792
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
793
+ }
794
+
795
+ function getCountFixtureValue(value: unknown): number | null {
796
+ if (typeof value === "number" && Number.isFinite(value)) {
797
+ return value;
798
+ }
799
+
800
+ if (Array.isArray(value)) {
801
+ return value.length;
802
+ }
803
+
804
+ if (isPerformanceFixtureObjectValue(value)) {
805
+ const count = Number(value.count ?? value.items ?? value.length);
806
+
807
+ if (Number.isFinite(count)) {
808
+ return count;
809
+ }
810
+
811
+ if (Array.isArray(value.items)) {
812
+ return value.items.length;
813
+ }
814
+ }
815
+
816
+ return null;
817
+ }
818
+
819
+ function getAreaFixtureDimensions(value: unknown): { height: number; width: number } | null {
820
+ return getMediaStressFixtureDimensions(value);
821
+ }
822
+
823
+ function getAreaFixtureValue(value: unknown): number | null {
824
+ const dimensions = getAreaFixtureDimensions(value);
825
+
826
+ if (!dimensions) {
827
+ return null;
828
+ }
829
+
830
+ return dimensions.width * dimensions.height;
831
+ }
832
+
833
+ function formatLoadProfileRatio(value: number): string {
834
+ return Number(value.toFixed(4)).toString();
835
+ }
836
+
837
+ function getLoadProfileActualSmoothRatio(
838
+ profile: ToolcraftPerformanceLoadProfile,
839
+ ): number | null {
840
+ if (profile.metric === "numeric-max") {
841
+ const hardLimit = getNumericFixtureValue(profile.hardLimit);
842
+ const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
843
+
844
+ return hardLimit !== null && smoothTarget !== null && hardLimit > 0
845
+ ? smoothTarget / hardLimit
846
+ : null;
847
+ }
848
+
849
+ if (profile.metric === "numeric-min") {
850
+ const hardLimit = getNumericFixtureValue(profile.hardLimit);
851
+ const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
852
+
853
+ return hardLimit !== null && smoothTarget !== null && smoothTarget > 0
854
+ ? hardLimit / smoothTarget
855
+ : null;
856
+ }
857
+
858
+ if (profile.metric === "count") {
859
+ const hardLimit = getCountFixtureValue(profile.hardLimit);
860
+ const smoothTarget = getCountFixtureValue(profile.smoothTarget);
861
+
862
+ return hardLimit !== null && smoothTarget !== null && hardLimit > 0
863
+ ? smoothTarget / hardLimit
864
+ : null;
865
+ }
866
+
867
+ if (profile.metric === "canvas-area" || profile.metric === "media-area") {
868
+ const hardArea = getAreaFixtureValue(profile.hardLimit);
869
+ const smoothArea = getAreaFixtureValue(profile.smoothTarget);
870
+
871
+ return hardArea !== null && smoothArea !== null && hardArea > 0
872
+ ? smoothArea / hardArea
873
+ : null;
874
+ }
875
+
876
+ return null;
877
+ }
878
+
879
+ function getLoadProfileSmoothTargetRatioError(
880
+ label: string,
881
+ profile: ToolcraftPerformanceLoadProfile,
882
+ ): string | null {
883
+ const declaredRatio = profile.smoothTargetRatio;
884
+
885
+ if (
886
+ typeof declaredRatio !== "number" ||
887
+ !Number.isFinite(declaredRatio) ||
888
+ declaredRatio <= 0 ||
889
+ declaredRatio > 1
890
+ ) {
891
+ return null;
892
+ }
893
+
894
+ if (
895
+ declaredRatio === 1 &&
896
+ !arePerformanceFixtureValuesEqual(profile.smoothTarget, profile.hardLimit)
897
+ ) {
898
+ return `${label}.smoothTargetRatio 1 requires smoothTarget to equal hardLimit.`;
899
+ }
900
+
901
+ const actualRatio = getLoadProfileActualSmoothRatio(profile);
902
+
903
+ if (actualRatio === null) {
904
+ return null;
905
+ }
906
+
907
+ const tolerance = 0.0001;
908
+
909
+ if (declaredRatio === 1) {
910
+ return Math.abs(actualRatio - 1) <= tolerance
911
+ ? null
912
+ : `${label}.smoothTargetRatio 1 must describe the actual smooth target ratio ${formatLoadProfileRatio(actualRatio)}.`;
913
+ }
914
+
915
+ const previousStepRatio = Math.min(1, declaredRatio + 0.1);
916
+
917
+ return actualRatio >= declaredRatio - tolerance &&
918
+ actualRatio < previousStepRatio - tolerance
919
+ ? null
920
+ : `${label}.smoothTargetRatio ${formatLoadProfileRatio(declaredRatio)} must describe the actual smooth target ratio ${formatLoadProfileRatio(actualRatio)}.`;
921
+ }
922
+
923
+ function fixtureRequiresLoadProfile(fixture: ToolcraftPerformanceFixture): boolean {
924
+ return (
925
+ loadProfileRequiredFixtureKinds.has(fixture.kind) ||
926
+ (fixture.kind === "custom" && customFixtureValueNeedsLoadProfile(fixture.value))
927
+ );
928
+ }
929
+
930
+ function getLoadProfileRatioStepCount(ratio: number): number {
931
+ return Math.round((1 - ratio) / 0.1);
932
+ }
933
+
934
+ function isTenPercentRatioStep(ratio: number): boolean {
935
+ return Math.abs(ratio * 10 - Math.round(ratio * 10)) < 0.0001;
936
+ }
937
+
938
+ function getSchemaNumericHardLimit(
939
+ control: ToolcraftControlSchema | undefined,
940
+ metric: ToolcraftPerformanceLoadMetric,
941
+ ): number | null {
942
+ if (!control || (metric !== "numeric-max" && metric !== "numeric-min")) {
943
+ return null;
944
+ }
945
+
946
+ const value = metric === "numeric-max" ? control.max : control.min;
947
+
948
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
949
+ }
950
+
951
+ function getPerformanceLoadProfileErrors(
952
+ scenario: ToolcraftPerformanceScenario,
953
+ fieldName: "stressFixture" | "workloadFixture",
954
+ fixture: ToolcraftPerformanceFixture,
955
+ control: ToolcraftControlSchema | undefined,
956
+ ): string[] {
957
+ const errors: string[] = [];
958
+ const profile = fixture.loadProfile;
959
+
960
+ if (!profile) {
961
+ return fixtureRequiresLoadProfile(fixture)
962
+ ? [
963
+ `${scenario.id} ${fieldName}.loadProfile must declare hardLimit, smoothTarget, and smoothTargetRatio so performance workload is a measured smooth target rather than a hidden toy fixture.`,
964
+ ]
965
+ : [];
966
+ }
967
+
968
+ const label = `${scenario.id} ${fieldName}.loadProfile`;
969
+
970
+ if (!profile.target.trim()) {
971
+ errors.push(`${label}.target must name the product workload target.`);
972
+ }
973
+
974
+ if (!performanceLoadMetrics.has(profile.metric)) {
975
+ errors.push(`${label}.metric "${profile.metric}" is not supported.`);
976
+ }
977
+
978
+ if (!performanceUserFacingRanges.has(profile.userFacingRange)) {
979
+ errors.push(`${label}.userFacingRange "${profile.userFacingRange}" is not supported.`);
980
+ }
981
+
982
+ if (
983
+ typeof profile.smoothTargetRatio !== "number" ||
984
+ !Number.isFinite(profile.smoothTargetRatio) ||
985
+ profile.smoothTargetRatio <= 0 ||
986
+ profile.smoothTargetRatio > 1
987
+ ) {
988
+ errors.push(`${label}.smoothTargetRatio must be > 0 and <= 1.`);
989
+ } else if (!isTenPercentRatioStep(profile.smoothTargetRatio)) {
990
+ errors.push(`${label}.smoothTargetRatio must use 10 percent steps such as 1, 0.9, or 0.8.`);
991
+ }
992
+
993
+ const smoothTargetRatioError = getLoadProfileSmoothTargetRatioError(label, profile);
994
+
995
+ if (smoothTargetRatioError) {
996
+ errors.push(smoothTargetRatioError);
997
+ }
998
+
999
+ if (!hasPerformanceFixtureValue(fixture)) {
1000
+ errors.push(`${label}.smoothTarget cannot be checked because ${fieldName}.value is missing.`);
1001
+ } else if (!arePerformanceFixtureValuesEqual(profile.smoothTarget, fixture.value)) {
1002
+ errors.push(`${label}.smoothTarget must match ${fieldName}.value so browser tests apply the documented smooth workload target.`);
1003
+ }
1004
+
1005
+ const schemaHardLimit = getSchemaNumericHardLimit(control, profile.metric);
1006
+
1007
+ if (
1008
+ schemaHardLimit !== null &&
1009
+ !arePerformanceFixtureValuesEqual(profile.hardLimit, schemaHardLimit)
1010
+ ) {
1011
+ errors.push(`${label}.hardLimit must match schema ${profile.metric === "numeric-max" ? "max" : "min"} ${schemaHardLimit}.`);
1012
+ }
1013
+
1014
+ if (profile.metric === "numeric-max" || profile.metric === "numeric-min") {
1015
+ const hardLimit = getNumericFixtureValue(profile.hardLimit);
1016
+ const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
1017
+
1018
+ if (hardLimit === null || smoothTarget === null) {
1019
+ errors.push(`${label}.${profile.metric} hardLimit and smoothTarget must be numeric.`);
1020
+ } else if (profile.metric === "numeric-max" && smoothTarget > hardLimit) {
1021
+ errors.push(`${label}.smoothTarget must be <= hardLimit for numeric-max workloads.`);
1022
+ } else if (profile.metric === "numeric-min" && smoothTarget < hardLimit) {
1023
+ errors.push(`${label}.smoothTarget must be >= hardLimit for numeric-min workloads.`);
1024
+ }
1025
+ }
1026
+
1027
+ if (profile.metric === "count") {
1028
+ const hardLimit = getCountFixtureValue(profile.hardLimit);
1029
+ const smoothTarget = getCountFixtureValue(profile.smoothTarget);
1030
+
1031
+ if (hardLimit === null || smoothTarget === null) {
1032
+ errors.push(`${label}.count hardLimit and smoothTarget must be numeric counts, arrays, or objects with count/items.`);
1033
+ } else if (smoothTarget > hardLimit) {
1034
+ errors.push(`${label}.smoothTarget count must be <= hardLimit count.`);
1035
+ }
1036
+ }
1037
+
1038
+ if (profile.metric === "canvas-area" || profile.metric === "media-area") {
1039
+ const hardArea = getAreaFixtureValue(profile.hardLimit);
1040
+ const smoothArea = getAreaFixtureValue(profile.smoothTarget);
1041
+
1042
+ if (hardArea === null || smoothArea === null) {
1043
+ errors.push(`${label}.${profile.metric} hardLimit and smoothTarget must include numeric width and height.`);
1044
+ } else if (smoothArea > hardArea) {
1045
+ errors.push(`${label}.smoothTarget area must be <= hardLimit area.`);
1046
+ }
1047
+ }
1048
+
1049
+ if (fixture.kind === "many-items") {
1050
+ const smoothCount = getCountFixtureValue(fixture.value);
1051
+
1052
+ if (typeof fixture.minCount !== "number" || fixture.minCount <= 0) {
1053
+ errors.push(`${scenario.id} ${fieldName}.minCount must be a positive number for many-items fixtures.`);
1054
+ } else if (smoothCount !== null && smoothCount < fixture.minCount) {
1055
+ errors.push(`${scenario.id} ${fieldName}.value must include at least ${fixture.minCount} items for many-items fixtures.`);
1056
+ }
1057
+ }
1058
+
1059
+ if (
1060
+ typeof profile.smoothTargetRatio === "number" &&
1061
+ Number.isFinite(profile.smoothTargetRatio)
1062
+ ) {
1063
+ if (profile.smoothTargetRatio === 1) {
1064
+ if (profile.userFacingRange !== "fully-guaranteed") {
1065
+ errors.push(`${label}.userFacingRange must be "fully-guaranteed" when smoothTargetRatio is 1.`);
1066
+ }
1067
+ } else if (profile.smoothTargetRatio > 0 && profile.smoothTargetRatio < 1) {
1068
+ if (profile.degradationStepPercent !== 10) {
1069
+ errors.push(`${label}.degradationStepPercent must be 10 when smoothTargetRatio is below 1.`);
1070
+ }
1071
+
1072
+ if (profile.userFacingRange !== "experimental-above-smooth") {
1073
+ errors.push(`${label}.userFacingRange must be "experimental-above-smooth" when smoothTargetRatio is below 1.`);
1074
+ }
1075
+
1076
+ const expectedEvidenceCount = getLoadProfileRatioStepCount(profile.smoothTargetRatio);
1077
+ const evidence = profile.evidence ?? [];
1078
+
1079
+ if (evidence.length < expectedEvidenceCount) {
1080
+ errors.push(`${label}.evidence must include failed measurements for each 10 percent step above smoothTargetRatio ${profile.smoothTargetRatio}.`);
1081
+ }
1082
+
1083
+ for (const [index, entry] of evidence.entries()) {
1084
+ const evidenceLabel = `${label}.evidence[${index}]`;
1085
+
1086
+ if (!entry.scenarioId.trim()) {
1087
+ errors.push(`${evidenceLabel}.scenarioId must name the measured scenario.`);
1088
+ }
1089
+
1090
+ if (entry.result !== "failed") {
1091
+ errors.push(`${evidenceLabel}.result must be "failed" for degraded smooth targets.`);
1092
+ }
1093
+
1094
+ if (!entry.measuredResult.trim()) {
1095
+ errors.push(`${evidenceLabel}.measuredResult must include the measured budget failure.`);
1096
+ }
1097
+
1098
+ if (!entry.optimizationAttempted?.trim()) {
1099
+ errors.push(`${evidenceLabel}.optimizationAttempted must describe the optimization attempted before lowering the smooth target.`);
1100
+ }
1101
+
1102
+ if (!entry.decision.trim()) {
1103
+ errors.push(`${evidenceLabel}.decision must explain why this target was rejected or lowered.`);
1104
+ }
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ return errors;
1110
+ }
1111
+
1112
+ function getCustomPerformanceFixtureSemanticErrors(
1113
+ scenarioId: string,
1114
+ fieldName: "stressFixture" | "workloadFixture",
1115
+ value: Record<string, unknown>,
1116
+ pathPrefix = "",
1117
+ ): string[] {
1118
+ const errors: string[] = [];
1119
+
1120
+ for (const [key, itemValue] of Object.entries(value)) {
1121
+ const itemPath = pathPrefix ? `${pathPrefix}.${key}` : key;
1122
+ const itemLabel = `custom ${fieldName}.${itemPath}`;
1123
+
1124
+ if (isCustomLargeTextFixturePath(itemPath)) {
1125
+ errors.push(
1126
+ ...getPerformanceFixtureLargeTextValueErrors(
1127
+ scenarioId,
1128
+ itemLabel,
1129
+ itemValue,
1130
+ ),
1131
+ );
1132
+ continue;
1133
+ }
1134
+
1135
+ if (isCustomMediaFixturePath(itemPath)) {
1136
+ const dimensions = getMediaStressFixtureDimensions(itemValue);
1137
+
1138
+ if (!dimensions) {
1139
+ errors.push(
1140
+ `${scenarioId} ${itemLabel} must be an object with numeric width and height so browser tests can generate or load realistic media.`,
1141
+ );
1142
+ } else if (
1143
+ Math.max(dimensions.width, dimensions.height) < mediaStressMinLongEdge ||
1144
+ dimensions.width * dimensions.height < mediaStressMinArea
1145
+ ) {
1146
+ errors.push(
1147
+ `${scenarioId} ${itemLabel} must be at least 1920x1080-equivalent; received ${dimensions.width}x${dimensions.height}.`,
1148
+ );
1149
+ }
1150
+ continue;
1151
+ }
1152
+
1153
+ if (isCustomRenderScaleFixturePath(itemPath)) {
1154
+ if (
1155
+ typeof itemValue !== "number" ||
1156
+ !Number.isFinite(itemValue) ||
1157
+ itemValue <= 1
1158
+ ) {
1159
+ errors.push(
1160
+ `${scenarioId} ${itemLabel} must be a numeric Resolution scale greater than 1 so browser tests prove high-resolution backing pixels.`,
1161
+ );
1162
+ }
1163
+ continue;
1164
+ }
1165
+
1166
+ if (isPerformanceFixtureObjectValue(itemValue)) {
1167
+ errors.push(
1168
+ ...getCustomPerformanceFixtureSemanticErrors(
1169
+ scenarioId,
1170
+ fieldName,
1171
+ itemValue,
1172
+ itemPath,
1173
+ ),
1174
+ );
1175
+ }
1176
+ }
1177
+
1178
+ return errors;
1179
+ }
1180
+
507
1181
  function isLargeTextWorkloadControl(
508
1182
  control: ToolcraftControlSchema | undefined,
509
1183
  ): boolean {
@@ -522,15 +1196,17 @@ function isLargeTextWorkloadControl(
522
1196
  }
523
1197
 
524
1198
  function getPerformanceFixtureShapeErrors(
525
- scenarioId: string,
1199
+ scenario: ToolcraftPerformanceScenario,
526
1200
  fieldName: "stressFixture" | "workloadFixture",
527
1201
  fixture: ToolcraftPerformanceFixture,
1202
+ control: ToolcraftControlSchema | undefined,
528
1203
  options: {
529
1204
  requireLargeText?: boolean;
530
1205
  requireMedia?: boolean;
531
1206
  } = {},
532
1207
  ): string[] {
533
1208
  const errors: string[] = [];
1209
+ const scenarioId = scenario.id;
534
1210
 
535
1211
  if (!performanceFixtureKinds.has(fixture.kind)) {
536
1212
  errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
@@ -563,6 +1239,14 @@ function getPerformanceFixtureShapeErrors(
563
1239
  errors.push(
564
1240
  `${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
565
1241
  );
1242
+ } else {
1243
+ errors.push(
1244
+ ...getCustomPerformanceFixtureSemanticErrors(
1245
+ scenarioId,
1246
+ fieldName,
1247
+ fixture.value,
1248
+ ),
1249
+ );
566
1250
  }
567
1251
  } else if (fixture.kind === "media") {
568
1252
  const dimensions = getMediaStressFixtureDimensions(fixture.value);
@@ -582,6 +1266,7 @@ function getPerformanceFixtureShapeErrors(
582
1266
  }
583
1267
 
584
1268
  if (!options.requireLargeText && fixture.kind !== "large-text") {
1269
+ errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
585
1270
  return errors;
586
1271
  }
587
1272
 
@@ -592,37 +1277,19 @@ function getPerformanceFixtureShapeErrors(
592
1277
  return errors;
593
1278
  }
594
1279
 
595
- if (typeof fixture.value !== "string") {
596
- errors.push(`${scenarioId} large-text ${fieldName}.value must be a string.`);
597
- return errors;
598
- }
599
-
600
- const minChars = fixture.minChars ?? largeTextStressMinChars;
601
- const minLines = fixture.minLines ?? largeTextStressMinLines;
602
-
603
- if (minChars < largeTextStressMinChars) {
604
- errors.push(
605
- `${scenarioId} large-text ${fieldName}.minChars must be >= ${largeTextStressMinChars}.`,
606
- );
607
- }
608
-
609
- if (minLines < largeTextStressMinLines) {
610
- errors.push(
611
- `${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
612
- );
613
- }
614
-
615
- if (fixture.value.length < minChars) {
616
- errors.push(
617
- `${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
618
- );
619
- }
1280
+ errors.push(
1281
+ ...getPerformanceFixtureLargeTextValueErrors(
1282
+ scenarioId,
1283
+ `large-text ${fieldName}.value`,
1284
+ fixture.value,
1285
+ {
1286
+ minChars: fixture.minChars,
1287
+ minLines: fixture.minLines,
1288
+ },
1289
+ ),
1290
+ );
620
1291
 
621
- if (getTextLineCount(fixture.value) < minLines) {
622
- errors.push(
623
- `${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
624
- );
625
- }
1292
+ errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
626
1293
 
627
1294
  return errors;
628
1295
  }
@@ -664,7 +1331,7 @@ function getStressFixtureErrors(
664
1331
  }
665
1332
 
666
1333
  errors.push(
667
- ...getPerformanceFixtureShapeErrors(scenario.id, "stressFixture", fixture, {
1334
+ ...getPerformanceFixtureShapeErrors(scenario, "stressFixture", fixture, control, {
668
1335
  requireLargeText: isLargeTextWorkloadControl(control),
669
1336
  requireMedia: isMediaImport,
670
1337
  }),
@@ -735,9 +1402,10 @@ function getWorkloadFixtureErrors(
735
1402
 
736
1403
  errors.push(
737
1404
  ...getPerformanceFixtureShapeErrors(
738
- scenario.id,
1405
+ scenario,
739
1406
  "workloadFixture",
740
1407
  fixture,
1408
+ control,
741
1409
  ),
742
1410
  );
743
1411
 
@@ -940,19 +1608,48 @@ function hasDetailHeavyCanvas2DRendererLayer(
940
1608
  );
941
1609
  }
942
1610
 
1611
+ function getRendererMeasuredAlternativeEvidenceErrors(
1612
+ technique: ToolcraftRendererTechnique,
1613
+ ): string[] {
1614
+ return (technique.measuredAlternativeEvidence ?? []).flatMap((evidence, index) => {
1615
+ const label = `rendererTechnique.measuredAlternativeEvidence[${index}]`;
1616
+ const errors: string[] = [];
1617
+
1618
+ if (evidence.alternativeStrategy !== "webgl" && evidence.alternativeStrategy !== "webgpu") {
1619
+ errors.push(`${label}.alternativeStrategy must be "webgl" or "webgpu".`);
1620
+ }
1621
+
1622
+ if (!evidence.scenarioId.trim()) {
1623
+ errors.push(`${label}.scenarioId must name the measured performance scenario.`);
1624
+ }
1625
+
1626
+ if (!evidence.fixture.trim()) {
1627
+ errors.push(`${label}.fixture must name the exact stress fixture that was measured.`);
1628
+ }
1629
+
1630
+ if (!evidence.measuredResult.trim()) {
1631
+ errors.push(`${label}.measuredResult must summarize measured frame, interaction, render, or long-task results.`);
1632
+ }
1633
+
1634
+ if (!evidence.decision.trim()) {
1635
+ errors.push(`${label}.decision must explain why the selected renderer remains appropriate.`);
1636
+ }
1637
+
1638
+ return errors;
1639
+ });
1640
+ }
1641
+
943
1642
  function hasMeasuredGpuAlternativeEvidence(
944
1643
  technique: ToolcraftRendererTechnique,
945
1644
  ): 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
- )
1645
+ return (technique.measuredAlternativeEvidence ?? []).some(
1646
+ (evidence) =>
1647
+ (evidence.alternativeStrategy === "webgl" ||
1648
+ evidence.alternativeStrategy === "webgpu") &&
1649
+ evidence.scenarioId.trim().length > 0 &&
1650
+ evidence.fixture.trim().length > 0 &&
1651
+ evidence.measuredResult.trim().length > 0 &&
1652
+ evidence.decision.trim().length > 0,
956
1653
  );
957
1654
  }
958
1655
 
@@ -1090,6 +1787,8 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
1090
1787
  errors.push("Custom renderer technique must list performance risks.");
1091
1788
  }
1092
1789
 
1790
+ errors.push(...getRendererMeasuredAlternativeEvidenceErrors(technique));
1791
+
1093
1792
  if (
1094
1793
  technique.productRepresentation === "text" &&
1095
1794
  technique.rendererWorkload !== "text-output" &&
@@ -1142,7 +1841,7 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
1142
1841
  !hasMeasuredGpuAlternativeEvidence(technique)
1143
1842
  ) {
1144
1843
  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.",
1844
+ "Detail-heavy Canvas 2D renderers must include rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison before keeping the pixel work on CPU.",
1146
1845
  );
1147
1846
  }
1148
1847
 
@@ -1155,6 +1854,31 @@ function getPassById(
1155
1854
  return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
1156
1855
  }
1157
1856
 
1857
+ function hasMainThreadCanvasRasterCompositePreviewPressure(
1858
+ config: ToolcraftPerformanceConfig,
1859
+ ): boolean {
1860
+ if (config.rendererStrategy !== "canvas-2d" || !config.rendererPipeline) {
1861
+ return false;
1862
+ }
1863
+
1864
+ const hasMainThreadRasterPass = config.rendererPipeline.passes.some(
1865
+ (pass) =>
1866
+ pass.runsOn === "main" &&
1867
+ (pass.kind === "rasterize" || pass.kind === "pixel-transform") &&
1868
+ (pass.output === "intermediate" || pass.output === "preview") &&
1869
+ (pass.quality === "full" || pass.quality === "retina"),
1870
+ );
1871
+ const hasMainThreadPreviewComposite = config.rendererPipeline.passes.some(
1872
+ (pass) =>
1873
+ pass.runsOn === "main" &&
1874
+ pass.kind === "composite" &&
1875
+ pass.output === "preview" &&
1876
+ (pass.quality === "full" || pass.quality === "retina"),
1877
+ );
1878
+
1879
+ return hasMainThreadRasterPass && hasMainThreadPreviewComposite;
1880
+ }
1881
+
1158
1882
  function hasPipelineReference(value: string): boolean {
1159
1883
  return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
1160
1884
  }
@@ -1418,6 +2142,7 @@ export function validateToolcraftPerformanceCoverage(
1418
2142
  getAllSchemaControls(schema).map((control) => [control.target, control] as const),
1419
2143
  );
1420
2144
 
2145
+ errors.push(...getBrowserCheckPolicyErrors(config));
1421
2146
  errors.push(...getRendererTechniqueErrors(config));
1422
2147
  errors.push(...getRendererPipelineErrors(schema, config));
1423
2148
 
@@ -1445,13 +2170,22 @@ export function validateToolcraftPerformanceCoverage(
1445
2170
  );
1446
2171
  }
1447
2172
 
2173
+ if (
2174
+ hasMainThreadCanvasRasterCompositePreviewPressure(config) &&
2175
+ config.rendererWorkload !== "pixel-output"
2176
+ ) {
2177
+ errors.push(
2178
+ `Canvas 2D pipelines with main-thread rasterize/composite preview pressure must use rendererWorkload "pixel-output" or move expensive passes off the main thread; received "${config.rendererWorkload}".`,
2179
+ );
2180
+ }
2181
+
1448
2182
  if (
1449
2183
  config.rendererWorkload === "pixel-output" &&
1450
2184
  !gpuRendererStrategies.has(config.rendererStrategy) &&
1451
2185
  (!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
1452
2186
  ) {
1453
2187
  errors.push(
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.`,
2188
+ `rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}". Keeping a CPU renderer requires rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison.`,
1455
2189
  );
1456
2190
  }
1457
2191
 
@@ -1577,7 +2311,9 @@ export function validateToolcraftPerformanceCoverage(
1577
2311
  ),
1578
2312
  );
1579
2313
  const sensitiveTargets = new Set(
1580
- collectToolcraftPerformanceSensitiveControls(schema).map((entry) => entry.target),
2314
+ collectToolcraftPerformanceSensitiveControls(schema)
2315
+ .map((entry) => entry.target)
2316
+ .filter((target) => !isToolcraftRuntimeOwnedTarget(target)),
1581
2317
  );
1582
2318
  const performanceRoleConflicts = collectToolcraftPerformanceRoleConflicts(schema);
1583
2319
 
@@ -1623,6 +2359,10 @@ export function validateToolcraftPerformanceCoverage(
1623
2359
  }
1624
2360
 
1625
2361
  for (const target of getVisiblePerformanceControlTargets(schema)) {
2362
+ if (isToolcraftRuntimeOwnedTarget(target)) {
2363
+ continue;
2364
+ }
2365
+
1626
2366
  const targetScenarios = scenariosByTarget.get(target) ?? [];
1627
2367
  const targetControl = controlsByTarget.get(target);
1628
2368
  const sliderControlType = getSliderDragControlType(targetControl);