@pixel-point/toolcraft 0.0.4 → 0.0.6

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 (39) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +59 -2
  6. package/templates/runtime/contracts/component-contracts.ts +23 -2
  7. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  8. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  9. package/templates/runtime/react/controls-panel.test.tsx +269 -0
  10. package/templates/runtime/react/controls-panel.tsx +133 -24
  11. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  12. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  13. package/templates/runtime/react/timeline-panel.tsx +98 -10
  14. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  15. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  16. package/templates/runtime/schema/define-toolcraft.test.ts +77 -1
  17. package/templates/runtime/schema/define-toolcraft.ts +138 -5
  18. package/templates/runtime/schema/runtime-targets.ts +1 -0
  19. package/templates/runtime/schema/types.ts +23 -1
  20. package/templates/runtime/state/canvas-zoom.ts +1 -1
  21. package/templates/runtime/state/create-template-state.test.ts +6 -6
  22. package/templates/runtime/state/reducer.test.ts +86 -8
  23. package/templates/runtime/state/reducer.ts +41 -22
  24. package/templates/runtime/state/types.ts +1 -0
  25. package/templates/starter/AGENTS.md +2 -2
  26. package/templates/starter/docs/toolcraft/README.md +1 -1
  27. package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
  28. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  29. package/templates/starter/docs/toolcraft/component-rules.md +13 -1
  30. package/templates/starter/docs/toolcraft/performance.md +5 -0
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  32. package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
  33. package/templates/starter/gitignore +36 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  35. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  36. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  37. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  38. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  39. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixel-point/toolcraft",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import fs from "node:fs/promises";
3
4
 
4
5
  import { copyDirectory, removeDirectory } from "../src/copy-recursive.mjs";
5
6
 
@@ -27,3 +28,7 @@ await removeDirectory(templatesRoot);
27
28
  for (const source of sources) {
28
29
  await copyDirectory(source.from, source.to);
29
30
  }
31
+
32
+ const starterGitignorePath = path.join(templatesRoot, "starter/.gitignore");
33
+ const starterPackGitignorePath = path.join(templatesRoot, "starter/gitignore");
34
+ await fs.copyFile(starterGitignorePath, starterPackGitignorePath);
package/src/generate.mjs CHANGED
@@ -88,6 +88,18 @@ async function renameGeneratedAppFiles(targetDir) {
88
88
  }
89
89
  }
90
90
 
91
+ async function restoreGeneratedGitignore(targetDir) {
92
+ const npmSafeGitignorePath = path.join(targetDir, "gitignore");
93
+ const gitignorePath = path.join(targetDir, ".gitignore");
94
+
95
+ if (!(await pathExists(npmSafeGitignorePath))) {
96
+ return;
97
+ }
98
+
99
+ await fs.rm(gitignorePath, { force: true });
100
+ await fs.rename(npmSafeGitignorePath, gitignorePath);
101
+ }
102
+
91
103
  async function removeToolcraftTestFiles(toolcraftRoot) {
92
104
  async function visit(currentDir) {
93
105
  const entries = await fs.readdir(currentDir, { withFileTypes: true });
@@ -181,6 +193,7 @@ export async function generateToolcraft(options = {}) {
181
193
  await ensureWritableTargetDirectory(targetDir, { force: options.force });
182
194
 
183
195
  await copyDirectory(sourcePaths.starterDir, targetDir);
196
+ await restoreGeneratedGitignore(targetDir);
184
197
  await renameGeneratedAppFiles(targetDir);
185
198
 
186
199
  const toolcraftRoot = path.join(targetDir, "src/toolcraft");
@@ -116,6 +116,12 @@ describe("generateToolcraft", () => {
116
116
  assert.ok(await fs.stat(path.join(targetDir, "e2e/performance-helpers.ts")));
117
117
  assert.ok(await fs.stat(path.join(targetDir, "e2e/product-observable-helpers.ts")));
118
118
  assert.ok(await fs.stat(path.join(targetDir, "e2e/canvas-handle-helpers.ts")));
119
+ const gitignoreSource = await fs.readFile(path.join(targetDir, ".gitignore"), "utf8");
120
+ assert.match(gitignoreSource, /node_modules/);
121
+ assert.match(gitignoreSource, /dist/);
122
+ assert.match(gitignoreSource, /playwright-report/);
123
+ assert.match(gitignoreSource, /\.env\.\*/);
124
+ await assert.rejects(() => fs.stat(path.join(targetDir, "gitignore")), /ENOENT/);
119
125
  assert.ok(await fs.stat(path.join(targetDir, "scripts/check-ai-skills.mjs")));
120
126
  assert.ok(await fs.stat(path.join(targetDir, "scripts/toolcraft-port.mjs")));
121
127
  assert.ok(await fs.stat(path.join(targetDir, "scripts/toolcraft-port.test.mjs")));
@@ -345,6 +345,24 @@ describe("Toolcraft template component contracts", () => {
345
345
  expect(contract.aiUsageRules).toContain(
346
346
  "The runtime Canvas width and Canvas height block uses the technical Setup section and renders without a visible section heading; do not add a separate Canvas section label above these fields.",
347
347
  );
348
+ expect(contract.aiUsageRules).toContain(
349
+ "When the user manually edits Canvas width or Canvas height, the runtime keeps the typed dimension, keeps the other dimension unchanged, switches Aspect ratio to Custom, and shows the reduced current ratio in the custom ratio inputs.",
350
+ );
351
+ expect(contract.aiUsageRules).toContain(
352
+ "Aspect ratio presets are the only interaction that may resize both canvas dimensions from a preset; manual size inputs are exact output dimensions.",
353
+ );
354
+ expect(contract.aiUsageRules).toContain(
355
+ "For non-vector raster, Canvas 2D, WebGL, and WebGPU previews, set canvas.renderScale: true so the runtime adds Resolution scale after canvas sizing. The scale changes backing pixels from 1x to 2x without changing visible canvas size, and adding/enabling it requires a full pnpm verify:perf checkpoint.",
356
+ );
357
+ expect(contract.aiUsageRules).toContain(
358
+ "After enabling canvas.renderScale, verify that canvas preview stays responsive while dragging sliders and other high-frequency controls at the selected scale.",
359
+ );
360
+ expect(contract.aiUsageRules).toContain(
361
+ "Performance fixes for canvas.renderScale must preserve the selected visual quality; do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp canvas.renderScale below the user's chosen value to pass budgets.",
362
+ );
363
+ expect(contract.aiUsageRules).toContain(
364
+ "Do not enable canvas.renderScale for DOM/SVG/vector-native previews; preserve vector fidelity through native vector rendering instead of raster supersampling.",
365
+ );
348
366
  });
349
367
 
350
368
  it("documents persistence as a runtime-owned policy instead of ad hoc localStorage", () => {
@@ -392,7 +410,7 @@ describe("Toolcraft template component contracts", () => {
392
410
  "A settings-transfer section with only Export Settings and Import Settings means canvas sizing is not editable-output or canvas size controls already exist elsewhere.",
393
411
  );
394
412
  expect(contract.aiUsageRules).toContain(
395
- "When settings transfer and editable-output canvas sizing are both enabled, the first technical Setup runtime section contains Export Settings, Import Settings, Aspect ratio, Canvas width, and Canvas height in that order and renders without a visible section heading.",
413
+ "When settings transfer and editable-output canvas sizing are both enabled, the first technical Setup runtime section contains Export Settings, Import Settings, Aspect ratio, Canvas width, Canvas height, and optional Resolution scale in that order and renders without a visible section heading.",
396
414
  );
397
415
  });
398
416
 
@@ -415,6 +433,21 @@ describe("Toolcraft template component contracts", () => {
415
433
  expect(slider.aiUsageRules).toContain(
416
434
  "Large or precision stepped ranges such as speed, FPS, rate, duration, density, size, and intensity stay visually continuous even when they declare step.",
417
435
  );
436
+ expect(slider.aiUsageRules).toContain(
437
+ "Use slider unit only for measurement or scale suffixes such as %, px, °, x, s, ms, fps, rows/cols, or similar domain units.",
438
+ );
439
+ expect(slider.aiUsageRules).toContain(
440
+ "Do not use unit for repeated entity nouns already named by the section or label, such as Letters + letters, Shape Density / Count + shapes, Words + words, Symbols + symbols, Items + items, Particles + particles, or Layers + layers.",
441
+ );
442
+ expect(slider.aiUsageRules).toContain(
443
+ "When the value needs an entity noun to make sense, improve the label or section title instead of appending that noun as the value unit.",
444
+ );
445
+ expect(slider.aiUsageRules).toContain(
446
+ "Compact symbol/CSS units render tight, such as 70%, 24px, 1.2x, and 8s; word units render with a space, such as 5 cols, when they are truly needed.",
447
+ );
448
+ expect(slider.aiUsageRules).toContain(
449
+ "Slider valueLabel is editable only when it contains a numeric value; textual state labels such as Normal are display-only and must not expose hover or click editing affordances.",
450
+ );
418
451
  expect(slider.aiUsageRules).toContain(
419
452
  "Schema sliders render stacked at full width; do not put sliders in two-column inline layout groups.",
420
453
  );
@@ -451,6 +484,15 @@ describe("Toolcraft template component contracts", () => {
451
484
  expect(rangeSlider.aiUsageRules).toContain(
452
485
  "Large or precision stepped ranges such as speed, FPS, rate, duration, density, size, and intensity stay visually continuous even when they declare step.",
453
486
  );
487
+ expect(rangeSlider.aiUsageRules).toContain(
488
+ "Use rangeSlider unit only for measurement or scale suffixes; do not use it for repeated entity nouns already named by the section or label.",
489
+ );
490
+ expect(rangeSlider.aiUsageRules).toContain(
491
+ "When a range label needs an entity noun to make sense, improve the label or section title instead of appending that noun as the value unit.",
492
+ );
493
+ expect(rangeSlider.aiUsageRules).toContain(
494
+ "Compact symbol/CSS units render tight, such as 20% – 80% or 12px – 48px; word units render with a space when truly needed.",
495
+ );
454
496
  expect(rangeSlider.aiUsageRules).toContain(
455
497
  "RangeSlider is always a full-width two-thumb control; never place it in an inline two-column layout group with another slider or range slider.",
456
498
  );
@@ -686,7 +728,13 @@ describe("Toolcraft template component contracts", () => {
686
728
  "Performance matrices must declare rendererWorkload as none, simple-composition, text-output, vector-output, or pixel-output.",
687
729
  );
688
730
  expect(contract.aiUsageRules).toContain(
689
- "A full performance checkpoint must run with pnpm verify:perf when the first working app version exists, renderer/canvas/animation/export/timeline/layers change, a bug that previously broke functionality is fixed, any performance optimization lands, or the user requests performance, lag, jank, animation speed, or drag/zoom stabilization work.",
731
+ "A full performance checkpoint must run with pnpm verify:perf when the first working app version exists, renderer/canvas/animation/export/timeline/layers change, canvas.renderScale or the Resolution scale retina slider is added/enabled, a bug that previously broke functionality is fixed, any performance optimization lands, or the user requests performance, lag, jank, animation speed, or drag/zoom stabilization work.",
732
+ );
733
+ expect(contract.aiUsageRules).toContain(
734
+ "Performance fixes must preserve selected output and preview quality; do not reduce image quality, selected renderScale, export resolution, source media fidelity, or canvas backing pixels as the hidden way to pass budgets.",
735
+ );
736
+ expect(contract.aiUsageRules).toContain(
737
+ "When canvas or slider interactions lag, diagnose where the slowdown comes from before changing output quality: renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, async render cancellation, or animation scheduling.",
690
738
  );
691
739
  expect(contract.aiUsageRules).toContain(
692
740
  "Renderer specs must include a Renderer Technique Decision Matrix with sourceRepresentation, productRepresentation, previewRenderer, exportRenderer, rendererWorkload, rendererStrategy, whyNotAlternativeStrategies, fidelityRisks, and performanceRisks.",
@@ -849,6 +897,9 @@ describe("Toolcraft template component contracts", () => {
849
897
  expect(contract.aiUsageRules).toContain(
850
898
  "If there is no useful product-specific explanation, omit control.description; the runtime should not show a help tooltip for that label.",
851
899
  );
900
+ expect(contract.aiUsageRules).toContain(
901
+ "Do not add control.description to sequential colors such as Color 1, Color 2, or simple palette controls such as Spread when the section title already names the color or palette context.",
902
+ );
852
903
  expect(contract.aiUsageRules).toContain(
853
904
  "For compound controls such as FontPicker, do not use control.description to enumerate the control's owned fields. FontPicker descriptions must not recap font family, weight, size, case, color, opacity, letter spacing, or line height; use description only for non-obvious product scope or omit it.",
854
905
  );
@@ -1101,6 +1152,12 @@ describe("Toolcraft template component contracts", () => {
1101
1152
  expect(contract.aiUsageRules).toContain(
1102
1153
  "In single-layer apps, the runtime shows the uploaded image as the fileDrop preview and provides the clear action.",
1103
1154
  );
1155
+ expect(contract.aiUsageRules).toContain(
1156
+ "Use fileDrop with multiple: true when the app needs several uploaded images as one source set; do not build a custom thumbnail uploader for this.",
1157
+ );
1158
+ expect(contract.aiUsageRules).toContain(
1159
+ "When multiple uploaded images are present, the runtime appends media, shows a four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
1160
+ );
1104
1161
  expect(contract.aiUsageRules).toContain(
1105
1162
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
1106
1163
  );
@@ -49,6 +49,11 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
49
49
  'Small semantic integer domains such as rows, cols, gaps, jitter, counts, levels, bands, passes, points, tiles, and segments must use variant: "discrete".',
50
50
  'Finite animation step domains such as flip depth, character count, glyph steps, and frame steps must use variant: "discrete" when the marker count stays within the Toolcraft visual budget.',
51
51
  "Large or precision stepped ranges such as speed, FPS, rate, duration, density, size, and intensity stay visually continuous even when they declare step.",
52
+ "Use slider unit only for measurement or scale suffixes such as %, px, °, x, s, ms, fps, rows/cols, or similar domain units.",
53
+ "Do not use unit for repeated entity nouns already named by the section or label, such as Letters + letters, Shape Density / Count + shapes, Words + words, Symbols + symbols, Items + items, Particles + particles, or Layers + layers.",
54
+ "When the value needs an entity noun to make sense, improve the label or section title instead of appending that noun as the value unit.",
55
+ "Compact symbol/CSS units render tight, such as 70%, 24px, 1.2x, and 8s; word units render with a space, such as 5 cols, when they are truly needed.",
56
+ "Slider valueLabel is editable only when it contains a numeric value; textual state labels such as Normal are display-only and must not expose hover or click editing affordances.",
52
57
  "Schema sliders render stacked at full width; do not put sliders in two-column inline layout groups.",
53
58
  "The fontPicker component is the only built-in exception with two internal footer sliders for letter spacing and line height.",
54
59
  "For a small named option set, prefer Select or Segmented instead of forcing a discrete Slider.",
@@ -94,6 +99,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
94
99
  'Small semantic integer domains such as rows, cols, gaps, jitter, counts, levels, bands, passes, points, tiles, and segments must use variant: "discrete".',
95
100
  'Finite animation step domains such as flip depth, character count, glyph steps, and frame steps must use variant: "discrete" when the marker count stays within the Toolcraft visual budget.',
96
101
  "Large or precision stepped ranges such as speed, FPS, rate, duration, density, size, and intensity stay visually continuous even when they declare step.",
102
+ "Use rangeSlider unit only for measurement or scale suffixes; do not use it for repeated entity nouns already named by the section or label.",
103
+ "When a range label needs an entity noun to make sense, improve the label or section title instead of appending that noun as the value unit.",
104
+ "Compact symbol/CSS units render tight, such as 20% – 80% or 12px – 48px; word units render with a space when truly needed.",
97
105
  "RangeSlider is always a full-width two-thumb control; never place it in an inline two-column layout group with another slider or range slider.",
98
106
  "RangeSlider defaultValue must start with different lower and upper values so the two-thumb control does not collapse into a single-value slider.",
99
107
  "Manual range value editing accepts common separators such as slash, hyphen, spaces, and dashes; do not create custom parsers for RangeSlider labels.",
@@ -806,6 +814,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
806
814
  ],
807
815
  layoutConstraints: [
808
816
  "FileDrop lives in the controls panel; single-layer apps use its preview and clear behavior.",
817
+ "When fileDrop has multiple: true and more than one image is present, the runtime renders a four-column thumbnail grid with the add-more tile last.",
809
818
  ],
810
819
  requiredAcceptance: [
811
820
  "Prove file import changes media state and product output; prove clear removes source material.",
@@ -814,6 +823,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
814
823
  aiUsageRules: [
815
824
  "Use fileDrop for source material uploads in the controls panel, not on the canvas.",
816
825
  "In single-layer apps, the runtime shows the uploaded image as the fileDrop preview and provides the clear action.",
826
+ "Use fileDrop with multiple: true when the app needs several uploaded images as one source set; do not build a custom thumbnail uploader for this.",
827
+ "When multiple uploaded images are present, the runtime appends media, shows a four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
817
828
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
818
829
  ],
819
830
  commands: ["media.delete", "media.import"],
@@ -942,11 +953,18 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
942
953
  "Choose canvas.sizing.mode from product context instead of copying a universal 1024px artboard.",
943
954
  "Use intrinsic-media for single-layer upload/generation apps so imported media natural size becomes canvas.size.",
944
955
  "Use editable-output by default for generated, exportable, shader, poster, badge, wall, banner, thumbnail, and product-output apps where users should see or edit width and height.",
956
+ "When no explicit product size is provided, the runtime default canvas is 16:9 at 1920x1080; do not reset a new product-output app to 1:1 unless the product meaning requires it.",
945
957
  "A user-provided base/default size is not a reason to remove size controls; model it as canvas.size plus editable-output unless the prompt or reference explicitly locks output dimensions.",
946
958
  "Use fixed-output only when the product output size must not be user-editable, and prove that lock with canvasSizingCoverage fixed-output-size acceptance.",
947
959
  "Resolved canvas.size exists for every canvas app, but visible Canvas width and Canvas height controls are mandatory only for editable-output sizing and do not depend on settingsTransfer.",
948
960
  "If canvas.size is provided without an explicit sizing mode, defineToolcraft treats it as editable-output and adds Canvas width and Canvas height controls.",
949
961
  "The runtime Canvas width and Canvas height block uses the technical Setup section and renders without a visible section heading; do not add a separate Canvas section label above these fields.",
962
+ "When the user manually edits Canvas width or Canvas height, the runtime keeps the typed dimension, keeps the other dimension unchanged, switches Aspect ratio to Custom, and shows the reduced current ratio in the custom ratio inputs.",
963
+ "Aspect ratio presets are the only interaction that may resize both canvas dimensions from a preset; manual size inputs are exact output dimensions.",
964
+ "For non-vector raster, Canvas 2D, WebGL, and WebGPU previews, set canvas.renderScale: true so the runtime adds Resolution scale after canvas sizing. The scale changes backing pixels from 1x to 2x without changing visible canvas size, and adding/enabling it requires a full pnpm verify:perf checkpoint.",
965
+ "After enabling canvas.renderScale, verify that canvas preview stays responsive while dragging sliders and other high-frequency controls at the selected scale.",
966
+ "Performance fixes for canvas.renderScale must preserve the selected visual quality; do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp canvas.renderScale below the user's chosen value to pass budgets.",
967
+ "Do not enable canvas.renderScale for DOM/SVG/vector-native previews; preserve vector fidelity through native vector rendering instead of raster supersampling.",
950
968
  ],
951
969
  capabilities: ["drag", "zoom", "radar", "upload", "editable-size"],
952
970
  commands: [
@@ -993,7 +1011,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
993
1011
  "Do not hand-roll settings import/export through app routes, hidden file inputs, or panelActions.",
994
1012
  "Settings transfer appears as the first technical Setup controls-panel section when enabled and renders without a visible section heading; it imports and exports control values, canvas size, and timeline state.",
995
1013
  "A settings-transfer section with only Export Settings and Import Settings means canvas sizing is not editable-output or canvas size controls already exist elsewhere.",
996
- "When settings transfer and editable-output canvas sizing are both enabled, the first technical Setup runtime section contains Export Settings, Import Settings, Aspect ratio, Canvas width, and Canvas height in that order and renders without a visible section heading.",
1014
+ "When settings transfer and editable-output canvas sizing are both enabled, the first technical Setup runtime section contains Export Settings, Import Settings, Aspect ratio, Canvas width, Canvas height, and optional Resolution scale in that order and renders without a visible section heading.",
997
1015
  "Keep sticky footer panelActions for product delivery actions only, such as Export PNG, Export Video, Copy, Generate, Apply, or Download.",
998
1016
  ],
999
1017
  capabilities: ["settings-import-export"],
@@ -1040,7 +1058,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1040
1058
  "Expensive renderers must cache decoded media, source pixels, glyph atlases, gradients, and other reusable inputs by media id, canvas size, and stable control keys.",
1041
1059
  "Slider drags and high-frequency controls must debounce or coalesce preview work, cancel stale async renders, and avoid re-decoding media on every control change.",
1042
1060
  "Performance matrices must declare rendererWorkload as none, simple-composition, text-output, vector-output, or pixel-output.",
1043
- "A full performance checkpoint must run with pnpm verify:perf when the first working app version exists, renderer/canvas/animation/export/timeline/layers change, a bug that previously broke functionality is fixed, any performance optimization lands, or the user requests performance, lag, jank, animation speed, or drag/zoom stabilization work.",
1061
+ "A full performance checkpoint must run with pnpm verify:perf when the first working app version exists, renderer/canvas/animation/export/timeline/layers change, canvas.renderScale or the Resolution scale retina slider is added/enabled, a bug that previously broke functionality is fixed, any performance optimization lands, or the user requests performance, lag, jank, animation speed, or drag/zoom stabilization work.",
1062
+ "Performance fixes must preserve selected output and preview quality; do not reduce image quality, selected renderScale, export resolution, source media fidelity, or canvas backing pixels as the hidden way to pass budgets.",
1063
+ "When canvas or slider interactions lag, diagnose where the slowdown comes from before changing output quality: renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, async render cancellation, or animation scheduling.",
1044
1064
  "Renderer specs must include a Renderer Technique Decision Matrix with sourceRepresentation, productRepresentation, previewRenderer, exportRenderer, rendererWorkload, rendererStrategy, whyNotAlternativeStrategies, fidelityRisks, and performanceRisks.",
1045
1065
  "Custom renderer apps must mirror the Renderer Technique Decision Matrix in typed rendererTechnique config so validation can reject contradictory renderer choices.",
1046
1066
  "Custom renderer specs must include a Renderer Layer Inventory and mirror it in typed rendererTechnique.layers so dense raster backgrounds cannot silently rasterize semantic foreground output.",
@@ -1126,6 +1146,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1126
1146
  "Use control.description for the short help tooltip shown beside visible labels. It must describe the product behavior or output affected by the control, not restate the label.",
1127
1147
  "Do not write label-recap descriptions such as Adjusts Opacity, Controls Speed, or Sets Background.",
1128
1148
  "If there is no useful product-specific explanation, omit control.description; the runtime should not show a help tooltip for that label.",
1149
+ "Do not add control.description to sequential colors such as Color 1, Color 2, or simple palette controls such as Spread when the section title already names the color or palette context.",
1129
1150
  "For compound controls such as FontPicker, do not use control.description to enumerate the control's owned fields. FontPicker descriptions must not recap font family, weight, size, case, color, opacity, letter spacing, or line height; use description only for non-obvious product scope or omit it.",
1130
1151
  "The runtime renders a filled Phosphor question icon beside each visible ControlFieldLabel; generated apps must not hand-build their own help icon beside built-in labels.",
1131
1152
  "If a source label is unavoidably long, keep the visible label concise and rely on the native title tooltip for the full text.",
@@ -237,7 +237,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
237
237
  currentConstraint:
238
238
  "Performance coverage currently asks every visible non-action control for a performance scenario.",
239
239
  desiredBehavior:
240
- "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required when the first working version of an app exists, when renderer/canvas/animation/export/timeline/layers change, after fixing a bug that previously broke functionality, after any performance optimization, and whenever the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom. Browser performance tests read budgets from typed performance config and run sequentially for stable measurements.",
240
+ "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required when the first working version of an app exists, when renderer/canvas/animation/export/timeline/layers change, when canvas.renderScale or the Resolution scale retina slider is added/enabled, after fixing a bug that previously broke functionality, after any performance optimization, and whenever the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance tests read budgets from typed performance config and run sequentially for stable measurements.",
241
241
  enforcement: ["performance-validator", "browser-helper", "starter-agents"],
242
242
  id: "performance-coverage-levels",
243
243
  level: "invariant",
@@ -226,7 +226,7 @@ describe("CanvasShell", () => {
226
226
  await waitFor(() => {
227
227
  expect(screen.getByTestId("canvas-offset").textContent).toBe("-8,12");
228
228
  });
229
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
229
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
230
230
  });
231
231
 
232
232
  it("zooms the canvas world from a trackpad pinch around the pointer", async () => {
@@ -243,7 +243,7 @@ describe("CanvasShell", () => {
243
243
  const world = canvas.querySelector("[data-toolcraft-canvas-world]") as HTMLElement;
244
244
  mockCanvasRect(canvas);
245
245
 
246
- expect(world.style.transform).toBe("translate(-50%, -50%) translate(0px, 0px) scale(0.7)");
246
+ expect(world.style.transform).toBe("translate(-50%, -50%) translate(0px, 0px) scale(1)");
247
247
 
248
248
  const wheelEvent = new WheelEvent("wheel", {
249
249
  bubbles: true,
@@ -257,10 +257,10 @@ describe("CanvasShell", () => {
257
257
  expect(canvas.dispatchEvent(wheelEvent)).toBe(false);
258
258
  expect(wheelEvent.defaultPrevented).toBe(true);
259
259
  await waitFor(() => {
260
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("120");
260
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("150");
261
261
  });
262
- expect(world.style.transform).toContain("translate(-142.857142857142");
263
- expect(world.style.transform).toContain("scale(1.2)");
262
+ expect(world.style.transform).toContain("translate(-100px");
263
+ expect(world.style.transform).toContain("scale(1.5)");
264
264
  });
265
265
 
266
266
  it("prevents panel pinch gestures without moving the canvas viewport", async () => {
@@ -294,7 +294,7 @@ describe("CanvasShell", () => {
294
294
 
295
295
  expect(overlay.dispatchEvent(wheelEvent)).toBe(false);
296
296
  expect(wheelEvent.defaultPrevented).toBe(true);
297
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
297
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
298
298
  expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0");
299
299
  expect(overlay.closest("[data-toolcraft-canvas-world]")).toBeNull();
300
300
 
@@ -306,7 +306,7 @@ describe("CanvasShell", () => {
306
306
 
307
307
  expect(overlay.dispatchEvent(scrollEvent)).toBe(true);
308
308
  expect(scrollEvent.defaultPrevented).toBe(false);
309
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
309
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
310
310
  expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0");
311
311
  });
312
312
 
@@ -436,6 +436,101 @@ describe("ControlsPanel", () => {
436
436
  );
437
437
  });
438
438
 
439
+ it("suppresses obvious help icons in sequential color sections", () => {
440
+ const schema = defineToolcraft({
441
+ canvas: { enabled: false },
442
+ panels: {
443
+ controls: {
444
+ sections: [
445
+ {
446
+ controls: {
447
+ color1: {
448
+ defaultValue: { hex: "#DFFF1A" },
449
+ description: "Sets the first bead color.",
450
+ label: "Color 1",
451
+ target: "beads.color1",
452
+ type: "color",
453
+ },
454
+ color2: {
455
+ defaultValue: { hex: "#8CFF3A" },
456
+ description: "Sets the second bead color.",
457
+ label: "Color 2",
458
+ target: "beads.color2",
459
+ type: "color",
460
+ },
461
+ color3: {
462
+ defaultValue: { hex: "#F4FF5A" },
463
+ description: "Sets the third bead color.",
464
+ label: "Color 3",
465
+ target: "beads.color3",
466
+ type: "color",
467
+ },
468
+ color4: {
469
+ defaultValue: { hex: "#B8FF2E" },
470
+ description: "Sets the fourth bead color.",
471
+ label: "Color 4",
472
+ target: "beads.color4",
473
+ type: "color",
474
+ },
475
+ color5: {
476
+ defaultValue: { hex: "#ECFF68" },
477
+ description: "Sets the fifth bead color.",
478
+ label: "Color 5",
479
+ target: "beads.color5",
480
+ type: "color",
481
+ },
482
+ colorSpread: {
483
+ defaultValue: 34,
484
+ description:
485
+ "Controls how often beads use colors 2-5 instead of Color 1.",
486
+ label: "Spread",
487
+ max: 100,
488
+ min: 0,
489
+ target: "beads.colorSpread",
490
+ type: "slider",
491
+ unit: "%",
492
+ },
493
+ },
494
+ layoutGroups: [
495
+ {
496
+ columns: 2,
497
+ controls: ["color1", "color2"],
498
+ layout: "inline",
499
+ },
500
+ {
501
+ columns: 2,
502
+ controls: ["color3", "color4"],
503
+ layout: "inline",
504
+ },
505
+ ],
506
+ title: "Bead Colors",
507
+ },
508
+ {
509
+ controls: {
510
+ includeBackground: {
511
+ defaultValue: true,
512
+ description:
513
+ "Controls PNG background transparency while preview and video keep the background.",
514
+ label: "Include",
515
+ target: "export.includeBackground",
516
+ type: "switch",
517
+ },
518
+ },
519
+ title: "Background",
520
+ },
521
+ ],
522
+ title: "Controls",
523
+ },
524
+ },
525
+ });
526
+
527
+ renderControlsPanelWithSchema(schema);
528
+
529
+ expect(screen.queryByRole("button", { name: "Color 5 help" })).toBeNull();
530
+ expect(screen.queryByRole("button", { name: "Spread help" })).toBeNull();
531
+ expect(screen.getByRole("button", { name: "Include help" })).toBeTruthy();
532
+ });
533
+
439
534
  it("renders short visible toggle plus parameter rows as equal-width toggle-parameter rows", () => {
440
535
  const schema = defineToolcraft({
441
536
  canvas: { enabled: false },
@@ -1534,6 +1629,46 @@ describe("ControlsPanel", () => {
1534
1629
  container.querySelector('[data-slot="slider"][data-variant="discrete"]'),
1535
1630
  ).toBeNull();
1536
1631
  expect(container.querySelectorAll('[data-slot="slider-marker"]')).toHaveLength(0);
1632
+ expect(container.textContent).toContain("118 cols/s");
1633
+ });
1634
+
1635
+ it("keeps textual slider value labels non-editable", () => {
1636
+ const schema = defineToolcraft({
1637
+ canvas: { enabled: true },
1638
+ panels: {
1639
+ controls: {
1640
+ sections: [
1641
+ {
1642
+ controls: {
1643
+ letterSpacing: {
1644
+ defaultValue: 2,
1645
+ label: "Letter spacing",
1646
+ max: 4,
1647
+ min: 0,
1648
+ step: 1,
1649
+ target: "text.letterSpacing",
1650
+ type: "slider",
1651
+ valueLabel: "Normal",
1652
+ },
1653
+ },
1654
+ title: "Text",
1655
+ },
1656
+ ],
1657
+ title: "Generation Controls",
1658
+ },
1659
+ },
1660
+ });
1661
+
1662
+ renderControlsPanelWithSchema(schema);
1663
+
1664
+ const valueLabel = screen
1665
+ .getAllByText("Normal")
1666
+ .find((node) => node.getAttribute("aria-hidden") !== "true");
1667
+
1668
+ expect(screen.queryByRole("button", { name: "Edit Letter spacing value" })).toBeNull();
1669
+ expect(valueLabel).toBeTruthy();
1670
+ expect(valueLabel?.className).toContain("cursor-default");
1671
+ expect(valueLabel?.className).not.toContain("cursor-text");
1537
1672
  });
1538
1673
 
1539
1674
  it("keeps schema sliders stacked even when a layout group asks for an inline row", () => {
@@ -1589,6 +1724,8 @@ describe("ControlsPanel", () => {
1589
1724
  expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
1590
1725
  expect(fpsSlider).toBeTruthy();
1591
1726
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1727
+ expect(container.textContent).toContain("17 fps");
1728
+ expect(container.textContent).toContain("3.7x");
1592
1729
  });
1593
1730
 
1594
1731
  it("passes schema disabled state into slider controls", () => {
@@ -2283,6 +2420,76 @@ describe("ControlsPanel", () => {
2283
2420
  expect(screen.getByTestId("media-count").textContent).toBe("0");
2284
2421
  });
2285
2422
 
2423
+ it("renders multi-image file uploads as an add-last thumbnail grid", () => {
2424
+ const schema = defineToolcraft({
2425
+ canvas: { enabled: true, upload: true },
2426
+ panels: {
2427
+ controls: {
2428
+ sections: [
2429
+ {
2430
+ controls: {
2431
+ source: {
2432
+ accept: "PNG, SVG",
2433
+ label: "Images",
2434
+ multiple: true,
2435
+ target: "input.sources",
2436
+ type: "fileDrop",
2437
+ },
2438
+ },
2439
+ layout: "standalone",
2440
+ title: "Input",
2441
+ },
2442
+ ],
2443
+ title: "Generation Controls",
2444
+ },
2445
+ },
2446
+ });
2447
+
2448
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2449
+ layers: [
2450
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2451
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2452
+ ],
2453
+ mediaAssets: [
2454
+ {
2455
+ dataUrl:
2456
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23777'/%3E%3C/svg%3E",
2457
+ fileName: "one.svg",
2458
+ id: "media-1",
2459
+ layerId: "layer-1",
2460
+ mimeType: "image/svg+xml",
2461
+ position: { x: 0, y: 0 },
2462
+ size: { height: 96, unit: "px", width: 96 },
2463
+ },
2464
+ {
2465
+ dataUrl:
2466
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23999'/%3E%3C/svg%3E",
2467
+ fileName: "two.svg",
2468
+ id: "media-2",
2469
+ layerId: "layer-2",
2470
+ mimeType: "image/svg+xml",
2471
+ position: { x: 0, y: 0 },
2472
+ size: { height: 96, unit: "px", width: 96 },
2473
+ },
2474
+ ],
2475
+ selectedLayerId: "layer-2",
2476
+ });
2477
+
2478
+ expect(screen.getByRole("button", { name: "Add image files" })).toBeTruthy();
2479
+ const previewGrid = container.querySelector('[data-slot="file-upload-preview-grid"]');
2480
+ expect(previewGrid?.className).toContain("grid-cols-4");
2481
+ expect(previewGrid?.lastElementChild?.getAttribute("data-slot")).toBe(
2482
+ "file-upload-add-preview",
2483
+ );
2484
+ expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2485
+ expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2486
+
2487
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2488
+
2489
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2490
+ expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2491
+ });
2492
+
2286
2493
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2287
2494
  const schema = defineToolcraft({
2288
2495
  canvas: { enabled: true, upload: true },
@@ -3126,6 +3333,68 @@ describe("ControlsPanel", () => {
3126
3333
  expect(values["canvas.size.height"]).toBe(1080);
3127
3334
  });
3128
3335
 
3336
+ it("switches editable-output aspect ratio to custom after manual size edits", () => {
3337
+ const schema = defineToolcraft({
3338
+ canvas: {
3339
+ enabled: true,
3340
+ size: { height: 1080, unit: "px", width: 1920 },
3341
+ sizing: { mode: "editable-output" },
3342
+ },
3343
+ panels: {
3344
+ controls: {
3345
+ sections: [],
3346
+ title: "Controls",
3347
+ },
3348
+ },
3349
+ });
3350
+
3351
+ renderControlsPanelWithSchema(schema);
3352
+
3353
+ const widthInput = screen.getByDisplayValue("1920") as HTMLInputElement;
3354
+
3355
+ fireEvent.change(widthInput, { target: { value: "1600" } });
3356
+ fireEvent.blur(widthInput);
3357
+
3358
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1600,1080");
3359
+ expect(screen.getAllByText("Custom...").length).toBeGreaterThan(0);
3360
+ expect(screen.getByDisplayValue("40")).toBeTruthy();
3361
+ expect(screen.getByDisplayValue("27")).toBeTruthy();
3362
+ expect(JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")).toMatchObject({
3363
+ "canvas.aspectRatio": {
3364
+ height: 27,
3365
+ mode: "custom",
3366
+ value: "40:27",
3367
+ width: 40,
3368
+ },
3369
+ "canvas.size.height": 1080,
3370
+ "canvas.size.width": 1600,
3371
+ });
3372
+ });
3373
+
3374
+ it("renders raster render scale as the last technical setup control", () => {
3375
+ const schema = defineToolcraft({
3376
+ canvas: {
3377
+ enabled: true,
3378
+ renderScale: true,
3379
+ size: { height: 1080, unit: "px", width: 1920 },
3380
+ sizing: { mode: "editable-output" },
3381
+ },
3382
+ panels: {
3383
+ controls: {
3384
+ sections: [],
3385
+ title: "Controls",
3386
+ },
3387
+ },
3388
+ });
3389
+ renderControlsPanelWithSchema(schema);
3390
+
3391
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3392
+
3393
+ expect(screen.getByText("Resolution scale")).toBeTruthy();
3394
+ expect(screen.getByText("2x")).toBeTruthy();
3395
+ expect(values["canvas.renderScale"]).toBe(2);
3396
+ });
3397
+
3129
3398
  it("applies empty text input values while typing and reset restores defaults", () => {
3130
3399
  renderControlsPanel();
3131
3400