@pixel-point/toolcraft 0.0.8 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -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,81 @@ 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
+
635
+ function getSliderDragControlType(
636
+ control: ToolcraftControlSchema | undefined,
637
+ ): "rangeSlider" | "slider" | null {
638
+ if (control?.type === "slider" || control?.type === "rangeSlider") {
639
+ return control.type;
640
+ }
641
+
642
+ return null;
643
+ }
644
+
645
+ function hasControlDragScenario(scenarios: readonly ToolcraftPerformanceScenario[]): boolean {
646
+ return scenarios.some((scenario) => scenario.interaction === "control-drag");
647
+ }
648
+
458
649
  function hasPerformanceFixtureValue(
459
650
  fixture: ToolcraftPerformanceFixture,
460
651
  ): fixture is ToolcraftPerformanceFixture & { value: unknown } {
@@ -490,6 +681,503 @@ function getTextLineCount(value: string): number {
490
681
  return value.split(/\r\n|\r|\n/).length;
491
682
  }
492
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
+
493
1181
  function isLargeTextWorkloadControl(
494
1182
  control: ToolcraftControlSchema | undefined,
495
1183
  ): boolean {
@@ -508,15 +1196,17 @@ function isLargeTextWorkloadControl(
508
1196
  }
509
1197
 
510
1198
  function getPerformanceFixtureShapeErrors(
511
- scenarioId: string,
1199
+ scenario: ToolcraftPerformanceScenario,
512
1200
  fieldName: "stressFixture" | "workloadFixture",
513
1201
  fixture: ToolcraftPerformanceFixture,
1202
+ control: ToolcraftControlSchema | undefined,
514
1203
  options: {
515
1204
  requireLargeText?: boolean;
516
1205
  requireMedia?: boolean;
517
1206
  } = {},
518
1207
  ): string[] {
519
1208
  const errors: string[] = [];
1209
+ const scenarioId = scenario.id;
520
1210
 
521
1211
  if (!performanceFixtureKinds.has(fixture.kind)) {
522
1212
  errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
@@ -549,6 +1239,14 @@ function getPerformanceFixtureShapeErrors(
549
1239
  errors.push(
550
1240
  `${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
551
1241
  );
1242
+ } else {
1243
+ errors.push(
1244
+ ...getCustomPerformanceFixtureSemanticErrors(
1245
+ scenarioId,
1246
+ fieldName,
1247
+ fixture.value,
1248
+ ),
1249
+ );
552
1250
  }
553
1251
  } else if (fixture.kind === "media") {
554
1252
  const dimensions = getMediaStressFixtureDimensions(fixture.value);
@@ -568,6 +1266,7 @@ function getPerformanceFixtureShapeErrors(
568
1266
  }
569
1267
 
570
1268
  if (!options.requireLargeText && fixture.kind !== "large-text") {
1269
+ errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
571
1270
  return errors;
572
1271
  }
573
1272
 
@@ -578,37 +1277,19 @@ function getPerformanceFixtureShapeErrors(
578
1277
  return errors;
579
1278
  }
580
1279
 
581
- if (typeof fixture.value !== "string") {
582
- errors.push(`${scenarioId} large-text ${fieldName}.value must be a string.`);
583
- return errors;
584
- }
585
-
586
- const minChars = fixture.minChars ?? largeTextStressMinChars;
587
- const minLines = fixture.minLines ?? largeTextStressMinLines;
588
-
589
- if (minChars < largeTextStressMinChars) {
590
- errors.push(
591
- `${scenarioId} large-text ${fieldName}.minChars must be >= ${largeTextStressMinChars}.`,
592
- );
593
- }
594
-
595
- if (minLines < largeTextStressMinLines) {
596
- errors.push(
597
- `${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
598
- );
599
- }
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
+ );
600
1291
 
601
- if (fixture.value.length < minChars) {
602
- errors.push(
603
- `${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
604
- );
605
- }
606
-
607
- if (getTextLineCount(fixture.value) < minLines) {
608
- errors.push(
609
- `${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
610
- );
611
- }
1292
+ errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
612
1293
 
613
1294
  return errors;
614
1295
  }
@@ -650,7 +1331,7 @@ function getStressFixtureErrors(
650
1331
  }
651
1332
 
652
1333
  errors.push(
653
- ...getPerformanceFixtureShapeErrors(scenario.id, "stressFixture", fixture, {
1334
+ ...getPerformanceFixtureShapeErrors(scenario, "stressFixture", fixture, control, {
654
1335
  requireLargeText: isLargeTextWorkloadControl(control),
655
1336
  requireMedia: isMediaImport,
656
1337
  }),
@@ -721,9 +1402,10 @@ function getWorkloadFixtureErrors(
721
1402
 
722
1403
  errors.push(
723
1404
  ...getPerformanceFixtureShapeErrors(
724
- scenario.id,
1405
+ scenario,
725
1406
  "workloadFixture",
726
1407
  fixture,
1408
+ control,
727
1409
  ),
728
1410
  );
729
1411
 
@@ -926,19 +1608,48 @@ function hasDetailHeavyCanvas2DRendererLayer(
926
1608
  );
927
1609
  }
928
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
+
929
1642
  function hasMeasuredGpuAlternativeEvidence(
930
1643
  technique: ToolcraftRendererTechnique,
931
1644
  ): 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
- )
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,
942
1653
  );
943
1654
  }
944
1655
 
@@ -1076,6 +1787,8 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
1076
1787
  errors.push("Custom renderer technique must list performance risks.");
1077
1788
  }
1078
1789
 
1790
+ errors.push(...getRendererMeasuredAlternativeEvidenceErrors(technique));
1791
+
1079
1792
  if (
1080
1793
  technique.productRepresentation === "text" &&
1081
1794
  technique.rendererWorkload !== "text-output" &&
@@ -1128,7 +1841,7 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
1128
1841
  !hasMeasuredGpuAlternativeEvidence(technique)
1129
1842
  ) {
1130
1843
  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.",
1844
+ "Detail-heavy Canvas 2D renderers must include rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison before keeping the pixel work on CPU.",
1132
1845
  );
1133
1846
  }
1134
1847
 
@@ -1141,6 +1854,31 @@ function getPassById(
1141
1854
  return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
1142
1855
  }
1143
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
+
1144
1882
  function hasPipelineReference(value: string): boolean {
1145
1883
  return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
1146
1884
  }
@@ -1404,6 +2142,7 @@ export function validateToolcraftPerformanceCoverage(
1404
2142
  getAllSchemaControls(schema).map((control) => [control.target, control] as const),
1405
2143
  );
1406
2144
 
2145
+ errors.push(...getBrowserCheckPolicyErrors(config));
1407
2146
  errors.push(...getRendererTechniqueErrors(config));
1408
2147
  errors.push(...getRendererPipelineErrors(schema, config));
1409
2148
 
@@ -1431,13 +2170,22 @@ export function validateToolcraftPerformanceCoverage(
1431
2170
  );
1432
2171
  }
1433
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
+
1434
2182
  if (
1435
2183
  config.rendererWorkload === "pixel-output" &&
1436
2184
  !gpuRendererStrategies.has(config.rendererStrategy) &&
1437
2185
  (!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
1438
2186
  ) {
1439
2187
  errors.push(
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.`,
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.`,
1441
2189
  );
1442
2190
  }
1443
2191
 
@@ -1563,7 +2311,9 @@ export function validateToolcraftPerformanceCoverage(
1563
2311
  ),
1564
2312
  );
1565
2313
  const sensitiveTargets = new Set(
1566
- collectToolcraftPerformanceSensitiveControls(schema).map((entry) => entry.target),
2314
+ collectToolcraftPerformanceSensitiveControls(schema)
2315
+ .map((entry) => entry.target)
2316
+ .filter((target) => !isToolcraftRuntimeOwnedTarget(target)),
1567
2317
  );
1568
2318
  const performanceRoleConflicts = collectToolcraftPerformanceRoleConflicts(schema);
1569
2319
 
@@ -1587,24 +2337,44 @@ export function validateToolcraftPerformanceCoverage(
1587
2337
  }
1588
2338
 
1589
2339
  const targetScenarios = scenariosByTarget.get(target) ?? [];
2340
+ const targetControl = controlsByTarget.get(target);
2341
+ const targetRequiresDrag = getSliderDragControlType(targetControl) !== null;
1590
2342
  const hasWorkloadCoverage = targetScenarios.some(
1591
2343
  (scenario) =>
1592
2344
  scenario.workload &&
1593
- (scenario.interaction === "control-drag" ||
1594
- scenario.interaction === "control-change") &&
2345
+ (targetRequiresDrag
2346
+ ? scenario.interaction === "control-drag"
2347
+ : scenario.interaction === "control-drag" ||
2348
+ scenario.interaction === "control-change") &&
1595
2349
  hasMinDefaultMax(scenario.values),
1596
2350
  );
1597
2351
 
1598
2352
  if (!hasWorkloadCoverage) {
1599
- errors.push(`${target} must have min/default/max workload performance coverage.`);
2353
+ errors.push(
2354
+ targetRequiresDrag
2355
+ ? `${target} must have min/default/max workload performance coverage through a real control-drag scenario.`
2356
+ : `${target} must have min/default/max workload performance coverage.`,
2357
+ );
1600
2358
  }
1601
2359
  }
1602
2360
 
1603
2361
  for (const target of getVisiblePerformanceControlTargets(schema)) {
1604
- if (!scenariosByTarget.has(target)) {
2362
+ if (isToolcraftRuntimeOwnedTarget(target)) {
2363
+ continue;
2364
+ }
2365
+
2366
+ const targetScenarios = scenariosByTarget.get(target) ?? [];
2367
+ const targetControl = controlsByTarget.get(target);
2368
+ const sliderControlType = getSliderDragControlType(targetControl);
2369
+
2370
+ if (targetScenarios.length === 0) {
1605
2371
  errors.push(
1606
2372
  `${target} must have a performance scenario because every visible control can affect app responsiveness.`,
1607
2373
  );
2374
+ } else if (sliderControlType && !hasControlDragScenario(targetScenarios)) {
2375
+ errors.push(
2376
+ `${target} is a ${sliderControlType} and must have a control-drag performance scenario proving live canvas/product feedback while dragging.`,
2377
+ );
1608
2378
  }
1609
2379
  }
1610
2380