@pixel-point/toolcraft 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/generate.test.mjs +1 -1
  3. package/templates/runtime/contracts/component-contracts.test.ts +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -150,6 +150,7 @@ const builtInToolcraftControlTypeValues = [
150
150
  "channelMixer",
151
151
  "checkbox",
152
152
  "code",
153
+ "collectionActions",
153
154
  "color",
154
155
  "colorOpacity",
155
156
  "curves",
@@ -385,6 +386,34 @@ function getAcceptanceEvidenceText(entry: ToolcraftComponentAcceptance): string
385
386
  ].join(" ");
386
387
  }
387
388
 
389
+ function getFileDropLifecycleCoverageErrors(
390
+ label: string,
391
+ entry: ToolcraftComponentAcceptance,
392
+ ): string[] {
393
+ const errors: string[] = [];
394
+ const evidenceText = getAcceptanceEvidenceText(entry);
395
+
396
+ if (entry.evidence !== "media-lifecycle") {
397
+ errors.push(
398
+ `${label} fileDrop acceptance evidence must be "media-lifecycle" so upload, clear, and reset behavior cannot be replaced by generic product-output coverage.`,
399
+ );
400
+ }
401
+
402
+ if (
403
+ !/\b(upload|import|drop|drag|browse|choose|select file|source image)\b/i.test(
404
+ evidenceText,
405
+ ) ||
406
+ !/\b(clear|remove|delete|trash)\b/i.test(evidenceText) ||
407
+ !/\b(reset|reset controls|section reset|global reset)\b/i.test(evidenceText)
408
+ ) {
409
+ errors.push(
410
+ `${label} fileDrop acceptance must prove upload/import, clear/remove, and section or global reset remove source media.`,
411
+ );
412
+ }
413
+
414
+ return errors;
415
+ }
416
+
388
417
  const conditionOperatorLabels = [
389
418
  "equals",
390
419
  "notEquals",
@@ -520,9 +549,109 @@ function isCustomToolcraftControl(control: ToolcraftControlSchema): boolean {
520
549
  return !builtInToolcraftControlTypes.has(control.type);
521
550
  }
522
551
 
552
+ const collectionEntityCustomControlRe =
553
+ /\b(collection|repeatable|list|lists|item|items|entry|entries|row|rows|asset|assets|object|objects|color|colors|swatch|swatches|glyph|glyphs|symbol|symbols|point|points|stop|stops|variant|variants|rule|rules|mask|masks|shape|shapes|layer|layers|media|image|images|file|files)\b/i;
554
+ const collectionOperationCustomControlRe =
555
+ /\b(add|adding|delete|deleting|remove|removing|reorder|reordering|order|ordering|sort|sorting|select|selecting|selected|selection|duplicate|duplicating|upload|import|clear|clearing)\b/i;
556
+
557
+ const actionLikeCustomControlRe =
558
+ /\b(add|adding|delete|deleting|remove|removing|duplicate|duplicating|sort|sorting|normalize|normalizing|clear|clearing|reset|shuffle|randomize|randomizing)\b/i;
559
+
560
+ const chromeOnlyCustomControlReasonRe =
561
+ /\b(icon|icons|visual|style|styling|layout|spacing|chrome|button|buttons|compact|custom look|custom ui)\b/i;
562
+ const productInteractionCustomControlReasonRe =
563
+ /\b(runtime|state|canvas|output|export|upload|import|preview|reorder|ordering|sort|drag|resize|handle|threshold|density|mapping|geometry|nested|multi|multiple|per-item|metadata|hit target|validation|selection)\b/i;
564
+ const collectionValueKeyRe =
565
+ /^(items?|entries|rows|assets|objects|colors?|glyphs?|symbols?|points?|stops?|variants?|rules?|masks?|shapes?|layers?|media|images?|files?)$/i;
566
+
567
+ function isRecord(value: unknown): value is Record<string, unknown> {
568
+ return typeof value === "object" && value !== null && !Array.isArray(value);
569
+ }
570
+
571
+ function stringifyUnknownForFitCheck(value: unknown): string {
572
+ if (value == null) {
573
+ return "";
574
+ }
575
+
576
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
577
+ return String(value);
578
+ }
579
+
580
+ if (Array.isArray(value)) {
581
+ return value.map(stringifyUnknownForFitCheck).join(" ");
582
+ }
583
+
584
+ if (isRecord(value)) {
585
+ return Object.entries(value)
586
+ .flatMap(([key, entryValue]) => [key, stringifyUnknownForFitCheck(entryValue)])
587
+ .join(" ");
588
+ }
589
+
590
+ return "";
591
+ }
592
+
593
+ function arrayLooksLikeCollectionValue(value: readonly unknown[]): boolean {
594
+ return (
595
+ value.length === 0 ||
596
+ value.some((item) => isRecord(item) || Array.isArray(item) || typeof item === "string")
597
+ );
598
+ }
599
+
600
+ function hasCollectionValueShape(value: unknown): boolean {
601
+ if (!isRecord(value)) {
602
+ return Array.isArray(value) && arrayLooksLikeCollectionValue(value);
603
+ }
604
+
605
+ return Object.entries(value).some(([key, entryValue]) => {
606
+ if (Array.isArray(entryValue)) {
607
+ return collectionValueKeyRe.test(key) || arrayLooksLikeCollectionValue(entryValue);
608
+ }
609
+
610
+ return collectionValueKeyRe.test(key) && isRecord(entryValue);
611
+ });
612
+ }
613
+
614
+ function getCustomFitCheckSearchText(
615
+ entry: ToolcraftComponentAcceptance,
616
+ control?: ToolcraftControlSchema,
617
+ ): string {
618
+ return [
619
+ control?.type,
620
+ control?.target,
621
+ typeof control?.label === "string" ? control.label : undefined,
622
+ stringifyUnknownForFitCheck(control?.defaultValue),
623
+ entry.id,
624
+ entry.target,
625
+ entry.componentType,
626
+ entry.expectedObservable,
627
+ entry.userAction,
628
+ entry.builtInFitCheck?.whyInsufficient,
629
+ entry.builtInFitCheck?.productObservable,
630
+ ]
631
+ .filter(Boolean)
632
+ .join(" ");
633
+ }
634
+
635
+ function isCollectionLikeCustomControl(
636
+ entry: ToolcraftComponentAcceptance,
637
+ control: ToolcraftControlSchema,
638
+ ): boolean {
639
+ if (hasCollectionValueShape(control.defaultValue)) {
640
+ return true;
641
+ }
642
+
643
+ const searchText = getCustomFitCheckSearchText(entry, control);
644
+
645
+ return (
646
+ collectionEntityCustomControlRe.test(searchText) &&
647
+ collectionOperationCustomControlRe.test(searchText)
648
+ );
649
+ }
650
+
523
651
  function getBuiltInFitCheckErrors(
524
652
  label: string,
525
653
  entry: ToolcraftComponentAcceptance,
654
+ control: ToolcraftControlSchema,
526
655
  ): string[] {
527
656
  const fitCheck = entry.builtInFitCheck;
528
657
 
@@ -574,6 +703,32 @@ function getBuiltInFitCheckErrors(
574
703
  );
575
704
  }
576
705
 
706
+ const searchText = getCustomFitCheckSearchText(entry, control);
707
+
708
+ if (
709
+ isCollectionLikeCustomControl(entry, control) &&
710
+ !checkedBuiltIns.includes("collectionActions")
711
+ ) {
712
+ errors.push(
713
+ `${label} builtInFitCheck.checkedBuiltIns must include collectionActions when the custom control owns a growable, removable, selectable, or reorderable runtime item set.`,
714
+ );
715
+ }
716
+
717
+ if (actionLikeCustomControlRe.test(searchText) && !checkedBuiltIns.includes("actions")) {
718
+ errors.push(
719
+ `${label} builtInFitCheck.checkedBuiltIns must include actions when the custom control exposes local command buttons such as add, remove, delete, duplicate, sort, normalize, or clear.`,
720
+ );
721
+ }
722
+
723
+ if (
724
+ chromeOnlyCustomControlReasonRe.test(fitCheck.whyInsufficient) &&
725
+ !productInteractionCustomControlReasonRe.test(fitCheck.whyInsufficient)
726
+ ) {
727
+ errors.push(
728
+ `${label} builtInFitCheck.whyInsufficient cannot justify a custom control only with icons, layout, styling, or custom buttons; name the product interaction or value model that built-ins cannot express.`,
729
+ );
730
+ }
731
+
577
732
  return errors;
578
733
  }
579
734
 
@@ -714,6 +869,10 @@ function getControlLabelText(control: ToolcraftControlSchema): string {
714
869
  return typeof control.label === "string" ? control.label : "";
715
870
  }
716
871
 
872
+ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
873
+ return typeof control.label === "string" && control.label.trim().length > 0;
874
+ }
875
+
717
876
  const singleCurveSemanticPattern =
718
877
  /\b(acceleration|accel|bend|easing|ease|response|depth|mask|opacity|alpha|motion|velocity|threshold|falloff|remap|remapping)\b|speed\s+profile|mapping\s+curve|curve\s+mapping/i;
719
878
  const rgbCurveSemanticPattern =
@@ -1603,13 +1762,23 @@ function getToolcraftControlDescriptionError({
1603
1762
  }
1604
1763
 
1605
1764
  function isToolcraftColorSectionTitle(sectionTitle: string | undefined): boolean {
1606
- return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1765
+ return /\b(colou?rs?|palette|palettes|shades?|accents?)\b/i.test(
1766
+ sectionTitle ?? "",
1767
+ );
1607
1768
  }
1608
1769
 
1609
1770
  function isToolcraftSequentialColorLabel(label: string): boolean {
1610
1771
  return /^colou?r\s+\d+$/i.test(label.trim());
1611
1772
  }
1612
1773
 
1774
+ function isToolcraftPaletteVariationTarget(target: string): boolean {
1775
+ return (
1776
+ /(?:^|\.)(?:palette|palettes|colou?rs?|shades?|accents?)\b/i.test(
1777
+ target,
1778
+ ) || /(?:^|\.)(?:accent|shade|colou?r)\d+\b/i.test(target)
1779
+ );
1780
+ }
1781
+
1613
1782
  function isToolcraftSimplePaletteDistributionLabel(label: string): boolean {
1614
1783
  return /^(spread|mix|distribution)$/i.test(label.trim());
1615
1784
  }
@@ -1648,6 +1817,76 @@ function isToolcraftObviousColorSectionControlDescription({
1648
1817
  );
1649
1818
  }
1650
1819
 
1820
+ function getToolcraftColorBankLabelErrors({
1821
+ controls,
1822
+ sectionLabel,
1823
+ sectionTitle,
1824
+ }: {
1825
+ controls: readonly [string, ToolcraftControlSchema][];
1826
+ sectionLabel: string;
1827
+ sectionTitle: string | undefined;
1828
+ }): string[] {
1829
+ const colorControls = controls.filter(([, control]) => {
1830
+ if (control.type !== "color" && control.type !== "colorOpacity") {
1831
+ return false;
1832
+ }
1833
+
1834
+ return true;
1835
+ });
1836
+
1837
+ if (colorControls.length < 2) {
1838
+ return [];
1839
+ }
1840
+
1841
+ const loosePrefixes = new Set(
1842
+ colorControls
1843
+ .map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
1844
+ .filter((prefix): prefix is string => Boolean(prefix)),
1845
+ );
1846
+
1847
+ if (loosePrefixes.size !== 1) {
1848
+ return [];
1849
+ }
1850
+
1851
+ const sequentialColorControls = colorControls.filter(([, control]) =>
1852
+ isToolcraftSequentialColorLabel(getControlLabelText(control)),
1853
+ );
1854
+ const isPaletteVariationBank =
1855
+ colorControls.every(([, control]) =>
1856
+ isToolcraftPaletteVariationTarget(control.target),
1857
+ ) ||
1858
+ (isToolcraftColorSectionTitle(sectionTitle) &&
1859
+ sequentialColorControls.length > 0);
1860
+
1861
+ if (!isPaletteVariationBank) {
1862
+ return [];
1863
+ }
1864
+
1865
+ const visibleColorControls = colorControls.filter(([, control]) =>
1866
+ hasVisibleControlLabel(control),
1867
+ );
1868
+ const errors: string[] = [];
1869
+
1870
+ if (
1871
+ visibleColorControls.length > 0 &&
1872
+ visibleColorControls.length < colorControls.length
1873
+ ) {
1874
+ errors.push(
1875
+ `${sectionLabel} mixes labeled and unlabeled color items in one palette variation group. Decide label visibility for the whole group: omit all per-item labels when colors only add variety, or label every item only when each color has a distinct user-facing role.`,
1876
+ );
1877
+ }
1878
+
1879
+ for (const [controlId, control] of sequentialColorControls) {
1880
+ const label = getControlLabelText(control).trim();
1881
+
1882
+ errors.push(
1883
+ `${sectionLabel} / ${controlId} uses visible label "${label}" for a palette variation color. When colors only add variety to one shared palette, set label: false or use collectionActions with unlabeled items. Keep visible labels only when each color edits a distinct user-facing entity such as Fill, Stroke, Background, Connector, or Object color.`,
1884
+ );
1885
+ }
1886
+
1887
+ return errors;
1888
+ }
1889
+
1651
1890
  function getToolcraftControlSectionGroupingErrors(
1652
1891
  schema: ResolvedToolcraftAppSchema,
1653
1892
  ): string[] {
@@ -1737,6 +1976,14 @@ function getToolcraftControlSectionGroupingErrors(
1737
1976
  );
1738
1977
  }
1739
1978
 
1979
+ errors.push(
1980
+ ...getToolcraftColorBankLabelErrors({
1981
+ controls,
1982
+ sectionLabel,
1983
+ sectionTitle,
1984
+ }),
1985
+ );
1986
+
1740
1987
  for (const [controlId, control] of controls) {
1741
1988
  const strictPrefix = getToolcraftStrictTargetPrefix(control.target);
1742
1989
  const loosePrefix = getToolcraftLooseTargetPrefix(control.target);
@@ -2121,7 +2368,7 @@ export function validateToolcraftAcceptanceCoverage(
2121
2368
 
2122
2369
  if (!schemaHasOutputBackgroundToggleControl(controls)) {
2123
2370
  errors.push(
2124
- 'Product apps with Export PNG must expose export.includeBackground inside the required "Background" section as a Switch labeled "Include". PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; video export keeps the background.',
2371
+ 'Product apps with Export PNG must expose export.includeBackground inside the required "Background" section as a Switch labeled "Include". PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; live preview must use shouldIncludeToolcraftPreviewBackground(state); video export keeps the background.',
2125
2372
  );
2126
2373
  }
2127
2374
 
@@ -2798,6 +3045,10 @@ export function validateToolcraftAcceptanceCoverage(
2798
3045
  );
2799
3046
  }
2800
3047
 
3048
+ if (control.type === "fileDrop") {
3049
+ errors.push(...getFileDropLifecycleCoverageErrors(label, entry));
3050
+ }
3051
+
2801
3052
  if (
2802
3053
  isCustomControl &&
2803
3054
  !hasCustomControlCoverage(
@@ -2811,7 +3062,7 @@ export function validateToolcraftAcceptanceCoverage(
2811
3062
  }
2812
3063
 
2813
3064
  if (isCustomControl) {
2814
- errors.push(...getBuiltInFitCheckErrors(label, entry));
3065
+ errors.push(...getBuiltInFitCheckErrors(label, entry, control));
2815
3066
  }
2816
3067
 
2817
3068
  if (
@@ -2841,17 +3092,23 @@ export function validateToolcraftAcceptanceCoverage(
2841
3092
  isOutputBackgroundToggleControl({ control, controlId, sectionTitle })
2842
3093
  ) {
2843
3094
  const evidenceText = getAcceptanceEvidenceText(entry);
2844
-
2845
- if (
2846
- !/\b(png|image)\b/i.test(evidenceText) ||
2847
- !/\b(transparent|transparency|alpha)\b/i.test(evidenceText) ||
2848
- !/\b(preview|canvas|workspace|backing|video)\b/i.test(evidenceText) ||
2849
- !/\b(keep|keeps|preserve|preserves|stay|stays|remain|remains|still|not transparent|non-transparent)\b/i.test(
3095
+ const provesPngTransparency =
3096
+ /\b(png|image)\b/i.test(evidenceText) &&
3097
+ /\b(transparent|transparency|alpha)\b/i.test(evidenceText);
3098
+ const provesPreviewTransparency =
3099
+ /\b(preview|canvas)\b/i.test(evidenceText) &&
3100
+ /\b(transparent|transparency|alpha|hide|hides|hidden|without background|no background|background off)\b/i.test(
2850
3101
  evidenceText,
2851
- )
2852
- ) {
3102
+ );
3103
+ const provesVideoBackground =
3104
+ /\bvideo\b/i.test(evidenceText) &&
3105
+ /\b(keep|keeps|preserve|preserves|stay|stays|remain|remains|still|background)\b/i.test(
3106
+ evidenceText,
3107
+ );
3108
+
3109
+ if (!provesPngTransparency || !provesPreviewTransparency || !provesVideoBackground) {
2853
3110
  errors.push(
2854
- `${label} controls PNG background inclusion and acceptance must prove disabling it makes PNG output transparent while live preview, workspace canvas backing, and video output keep the product background.`,
3111
+ `${label} controls background inclusion and acceptance must prove disabling it makes PNG output transparent, hides the live preview product background, and keeps video output with the product background.`,
2855
3112
  );
2856
3113
  }
2857
3114
  }
@@ -138,6 +138,18 @@ function projectDocsIncludeRendererLayerInventory(): boolean {
138
138
  );
139
139
  }
140
140
 
141
+ function projectDocsIncludeRendererPipelineInventory(): boolean {
142
+ const decisionSources = readProjectDecisionSources();
143
+
144
+ return (
145
+ /Render Pipeline Inventory|rendererPipeline|render pipeline/i.test(decisionSources) &&
146
+ /pass|passes|cacheKey|cache key|invalidat/i.test(decisionSources) &&
147
+ /control-drag|viewport-zoom|viewport-drag|media-import|animation-frame|timeline-playback|interaction/i.test(
148
+ decisionSources,
149
+ )
150
+ );
151
+ }
152
+
141
153
  function projectDocsExplainRendererAlternatives(): boolean {
142
154
  const decisionSources = readProjectDecisionSources();
143
155
 
@@ -198,12 +210,24 @@ function projectDocsIncludeFixedBackgroundDecision(): boolean {
198
210
 
199
211
  function sourceUsesCpuPixelLoop(): boolean {
200
212
  const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
213
+ const cpuPixelMethodCallPattern =
214
+ /(?:\.(?:createImageData|getImageData|putImageData)|\[\s*["'](?:createImageData|getImageData|putImageData)["']\s*\])\s*\(/;
201
215
 
202
216
  return (
203
217
  /new\s+ImageData\s*\(/.test(appSources) ||
204
- /\.createImageData\s*\(/.test(appSources) ||
205
- /\.getImageData\s*\(/.test(appSources) ||
206
- /\.putImageData\s*\(/.test(appSources)
218
+ cpuPixelMethodCallPattern.test(appSources)
219
+ );
220
+ }
221
+
222
+ function appPerformanceHasRenderPipelinePass(kind: string): boolean {
223
+ return (starterPerformance.rendererPipeline?.passes ?? []).some(
224
+ (pass) => pass.kind === kind,
225
+ );
226
+ }
227
+
228
+ function appPerformanceHasInteractionInvalidation(interaction: string): boolean {
229
+ return (starterPerformance.rendererPipeline?.interactionInvalidation ?? []).some(
230
+ (entry) => entry.interaction === interaction,
207
231
  );
208
232
  }
209
233
 
@@ -299,7 +323,7 @@ describe("Toolcraft template app performance coverage", () => {
299
323
  expect(packageJson.scripts?.["verify:ui"]).toBe("pnpm test:browser");
300
324
  expect(packageJson.scripts?.["verify:perf"]).toBe("pnpm test:browser:perf");
301
325
  expect(packageJson.scripts?.["verify:final"]).toBe(
302
- "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser && pnpm test:browser:perf",
326
+ "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser",
303
327
  );
304
328
  });
305
329
 
@@ -345,6 +369,17 @@ describe("Toolcraft template app performance coverage", () => {
345
369
  ).toBe(true);
346
370
  });
347
371
 
372
+ it("requires custom renderer apps to document the render pipeline inventory", () => {
373
+ if (!starterPerformance.usesCustomRenderer) {
374
+ return;
375
+ }
376
+
377
+ expect(
378
+ projectDocsIncludeRendererPipelineInventory(),
379
+ "Custom renderer apps must write a Render Pipeline Inventory in their app spec/plan docs before implementation so expensive passes, cache keys, and invalidation rules are deliberate.",
380
+ ).toBe(true);
381
+ });
382
+
348
383
  it("requires custom renderer apps to mirror the renderer decision in typed performance config", () => {
349
384
  if (!starterPerformance.usesCustomRenderer) {
350
385
  return;
@@ -356,6 +391,17 @@ describe("Toolcraft template app performance coverage", () => {
356
391
  ).toBeDefined();
357
392
  });
358
393
 
394
+ it("requires custom renderer apps to mirror the render pipeline in typed performance config", () => {
395
+ if (!starterPerformance.usesCustomRenderer) {
396
+ return;
397
+ }
398
+
399
+ expect(
400
+ starterPerformance.rendererPipeline,
401
+ "Custom renderer apps must declare rendererPipeline in app-performance.ts; prose specs and plans are not enough.",
402
+ ).toBeDefined();
403
+ });
404
+
359
405
  it("requires custom renderer apps to mirror layer inventory in typed performance config", () => {
360
406
  if (!starterPerformance.usesCustomRenderer) {
361
407
  return;
@@ -453,7 +499,7 @@ describe("Toolcraft template app performance coverage", () => {
453
499
  );
454
500
  });
455
501
 
456
- it("requires pixel-output renderers to use WebGL or WebGPU", () => {
502
+ it("requires pixel-output renderers to use GPU or measured CPU evidence", () => {
457
503
  expect(
458
504
  validateToolcraftPerformanceCoverage(starterSchema, {
459
505
  rendererStrategy: "canvas-2d",
@@ -464,7 +510,7 @@ describe("Toolcraft template app performance coverage", () => {
464
510
  }),
465
511
  ).toEqual(
466
512
  expect.arrayContaining([
467
- 'rendererWorkload "pixel-output" must use rendererStrategy "webgl" or "webgpu", received "canvas-2d".',
513
+ 'rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "canvas-2d". Keeping a CPU renderer requires measured WebGL/WebGPU evidence in rendererTechnique.whyNotAlternativeStrategies or performanceRisks.',
468
514
  ]),
469
515
  );
470
516
  });
@@ -681,6 +727,21 @@ describe("Toolcraft template app performance coverage", () => {
681
727
  sourceUsesGpuRenderer(),
682
728
  "Procedural pixel renderers must contain an actual WebGL/WebGPU code path, not only declare a GPU strategy.",
683
729
  ).toBe(true);
730
+ expect(
731
+ appPerformanceHasRenderPipelinePass("pixel-transform"),
732
+ "Procedural pixel renderers must declare a rendererPipeline pixel-transform pass so caching and invalidation are machine-checkable.",
733
+ ).toBe(true);
734
+ });
735
+
736
+ it("requires custom renderers to declare high-frequency viewport invalidation", () => {
737
+ if (!starterPerformance.usesCustomRenderer) {
738
+ return;
739
+ }
740
+
741
+ expect(
742
+ appPerformanceHasInteractionInvalidation("viewport-zoom"),
743
+ "Custom renderer apps must declare rendererPipeline interactionInvalidation for viewport-zoom so zoom can stay responsive without recomputing expensive passes.",
744
+ ).toBe(true);
684
745
  });
685
746
 
686
747
  it("requires WebGL/WebGPU renderers to keep their pipeline lifecycle outside React render", () => {
@@ -14,7 +14,6 @@ import {
14
14
 
15
15
  import { Button, Field } from "../../primitives";
16
16
  import { ControlFieldLabel } from "../../control-layout";
17
- import { cn } from "../../../lib/utils";
18
17
 
19
18
  export type ActionControlIconName =
20
19
  | "check"
@@ -102,6 +101,7 @@ export function ActionsControl({
102
101
  <Field
103
102
  aria-label={showLabel ? undefined : name}
104
103
  className="h-fit min-w-0 flex-wrap items-center justify-between gap-y-2!"
104
+ data-slot="actions-control"
105
105
  orientation="horizontal"
106
106
  >
107
107
  {showLabel ? (
@@ -110,10 +110,8 @@ export function ActionsControl({
110
110
  </ControlFieldLabel>
111
111
  ) : null}
112
112
  <div
113
- className={cn(
114
- "flex max-w-full shrink-0 flex-wrap items-center justify-start gap-1.5",
115
- showLabel && "ml-auto",
116
- )}
113
+ className="flex max-w-full shrink-0 flex-wrap items-center justify-start gap-1.5"
114
+ data-slot="actions-control-buttons"
117
115
  >
118
116
  {actions.map((action) => {
119
117
  const actionContent = getActionContent(action);
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { MinusIcon, PlusIcon } from "@phosphor-icons/react";
4
+ import type * as React from "react";
5
+
6
+ import { ControlFieldLabel } from "../../control-layout";
7
+ import { Button, Field } from "../../primitives";
8
+
9
+ export type CollectionActionsControlProps = {
10
+ addLabel?: string;
11
+ canAdd?: boolean;
12
+ canRemove?: boolean;
13
+ name: string;
14
+ onAdd?: () => void;
15
+ onRemove?: () => void;
16
+ removeLabel?: string;
17
+ };
18
+
19
+ export function CollectionActionsControl({
20
+ addLabel = "Add item",
21
+ canAdd = true,
22
+ canRemove = true,
23
+ name,
24
+ onAdd,
25
+ onRemove,
26
+ removeLabel = "Remove item",
27
+ }: CollectionActionsControlProps): React.JSX.Element {
28
+ return (
29
+ <Field
30
+ aria-label={name}
31
+ className="min-w-0 items-center justify-between gap-2"
32
+ data-slot="collection-actions-control-header"
33
+ orientation="horizontal"
34
+ >
35
+ <ControlFieldLabel className="flex-1">{name}</ControlFieldLabel>
36
+ <div className="inline-flex shrink-0 items-center gap-1">
37
+ <Button
38
+ aria-label={removeLabel}
39
+ disabled={!canRemove}
40
+ onClick={onRemove}
41
+ size="icon-sm"
42
+ type="button"
43
+ variant="outline"
44
+ >
45
+ <MinusIcon />
46
+ </Button>
47
+ <Button
48
+ aria-label={addLabel}
49
+ disabled={!canAdd}
50
+ onClick={onAdd}
51
+ size="icon-sm"
52
+ type="button"
53
+ variant="outline"
54
+ >
55
+ <PlusIcon />
56
+ </Button>
57
+ </div>
58
+ </Field>
59
+ );
60
+ }
@@ -0,0 +1,4 @@
1
+ "use client";
2
+
3
+ export { CollectionActionsControl } from "./collection-actions-control";
4
+ export type { CollectionActionsControlProps } from "./collection-actions-control";