@pixel-point/toolcraft 0.0.3 → 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 (47) 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 +86 -8
  6. package/templates/runtime/contracts/component-contracts.ts +36 -8
  7. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  11. package/templates/runtime/react/controls-panel.test.tsx +323 -6
  12. package/templates/runtime/react/controls-panel.tsx +349 -24
  13. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  14. package/templates/runtime/react/settings-transfer.ts +28 -2
  15. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  16. package/templates/runtime/react/timeline-panel.tsx +98 -10
  17. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  18. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  19. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  20. package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
  21. package/templates/runtime/schema/define-toolcraft.ts +197 -6
  22. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  23. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  24. package/templates/runtime/schema/runtime-targets.ts +6 -0
  25. package/templates/runtime/schema/types.ts +23 -1
  26. package/templates/runtime/state/canvas-zoom.ts +1 -1
  27. package/templates/runtime/state/create-template-state.test.ts +9 -3
  28. package/templates/runtime/state/reducer.test.ts +135 -2
  29. package/templates/runtime/state/reducer.ts +236 -12
  30. package/templates/runtime/state/types.ts +1 -0
  31. package/templates/starter/AGENTS.md +6 -4
  32. package/templates/starter/docs/toolcraft/README.md +1 -1
  33. package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
  34. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
  35. package/templates/starter/docs/toolcraft/component-rules.md +24 -5
  36. package/templates/starter/docs/toolcraft/performance.md +5 -0
  37. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  38. package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
  39. package/templates/starter/gitignore +36 -0
  40. package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
  41. package/templates/starter/src/app/starter-acceptance.ts +357 -4
  42. package/templates/ui/components/control-layout/index.tsx +4 -4
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  45. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  46. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  47. 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.3",
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")));
@@ -191,7 +191,7 @@ describe("Toolcraft template component contracts", () => {
191
191
  "When the nearest section title already names the switch context, do not duplicate that title as the visible switch label. Use label false for a visual-only toggle and keep the meaning in target/description.",
192
192
  );
193
193
  expect(switchContract.aiUsageRules).toContain(
194
- "A Switch may share an inline row with one related parameter control when the visible switch label is short enough to fit. Hide the switch label when the section title provides the visible context, such as Include background plus Background color inside Background.",
194
+ 'A Switch may share an inline row with one related parameter control when the visible switch label is short enough to fit. That row uses equal-width columns; never shrink the switch column to intrinsic width. In section-owned rows, use a short visible label such as "Include" instead of repeating the section title, such as "Include background" inside Background.',
195
195
  );
196
196
  expect(checkboxContract.aiUsageRules).toContain(
197
197
  'Checkbox labels name the setting context only; do not prefix labels with "Enable" or "Disable" because the checkbox already communicates enabled/selected state.',
@@ -206,7 +206,7 @@ describe("Toolcraft template component contracts", () => {
206
206
  "Two adjacent Checkbox controls for the same product entity must share one inline row when every visible label fits without truncation. Keep paired labels to short one- or two-word names; the runtime auto-pairs safe adjacent checkboxes by target entity, and generated schemas should stack checkboxes only when any label would truncate.",
207
207
  );
208
208
  expect(checkboxContract.aiUsageRules).toContain(
209
- "A Checkbox may share an inline row with one related parameter control when the visible checkbox label is short enough to fit. Hide the checkbox label when the section title provides the visible context.",
209
+ "A Checkbox may share an inline row with one related parameter control when the visible checkbox label is short enough to fit. That row uses equal-width columns; never shrink the checkbox column to intrinsic width. Hide the checkbox label when the section title provides the visible context.",
210
210
  );
211
211
  });
212
212
 
@@ -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, 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
  );
@@ -493,7 +535,7 @@ describe("Toolcraft template component contracts", () => {
493
535
  "Product-output apps always expose renderer-owned output background color as a schema color target such as appearance.background or scene.background.",
494
536
  );
495
537
  expect(color.aiUsageRules).toContain(
496
- "Pair renderer-owned output background color with export.includeBackground in one Background section. Prefer an inline hidden-label toggle plus color parameter row when the section title supplies the Background context.",
538
+ 'Pair renderer-owned output background color with export.includeBackground in one Background section directly before export settings. Use an equal-width inline row with the export.includeBackground Switch labeled "Include" on the left and the background Color parameter with label false on the right; each control occupies one half of the row.',
497
539
  );
498
540
  expect(color.aiUsageRules).toContain(
499
541
  "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls only PNG alpha; it must not make live preview, workspace canvas backing, or video transparent.",
@@ -502,7 +544,7 @@ describe("Toolcraft template component contracts", () => {
502
544
  "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.",
503
545
  );
504
546
  expect(color.aiUsageRules).toContain(
505
- "Mixed inline rows require visible labels on both controls except for section-title-owned hidden-label toggle plus parameter rows. Color fields in other mixed rows must not be unlabeled.",
547
+ 'Mixed inline rows require visible labels on both controls. The required Background row is the only section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Color fields in other mixed rows must not be unlabeled.',
506
548
  );
507
549
  expect(color.aiUsageRules).toContain(
508
550
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
@@ -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
  );
@@ -1005,12 +1056,30 @@ describe("Toolcraft template component contracts", () => {
1005
1056
  expect(contract.aiUsageRules).toContain(
1006
1057
  "Static or still-output apps include Export PNG as the primary footer action.",
1007
1058
  );
1059
+ expect(contract.aiUsageRules).toContain(
1060
+ 'Every app with Export PNG must expose a separate "Image Export" controls section.',
1061
+ );
1062
+ expect(contract.aiUsageRules).toContain(
1063
+ 'The Image Export section must include "export.image.format" as a Select control with PNG and JPG choices, defaulting to "png".',
1064
+ );
1065
+ expect(contract.aiUsageRules).toContain(
1066
+ 'The Image Export section must include "export.image.resolution" as a Select control with 2K, 4K, and 8K choices, defaulting to "4k".',
1067
+ );
1068
+ expect(contract.aiUsageRules).toContain(
1069
+ "Image Export format and resolution render as one compact two-column inline Select pair, matching the Video Export settings structure.",
1070
+ );
1071
+ expect(contract.aiUsageRules).toContain(
1072
+ "Image Export resolution controls the actual exported image long edge: 2K = 2048px, 4K = 4096px, 8K = 8192px. Pass the selected runtime value to createToolcraftPngExportCanvas resolution and prove decoded image width/height in browser acceptance.",
1073
+ );
1008
1074
  expect(contract.aiUsageRules).toContain(
1009
1075
  "Animated apps include Export Video as the primary footer action and Export PNG as a secondary footer action.",
1010
1076
  );
1011
1077
  expect(contract.aiUsageRules).toContain(
1012
1078
  'Animated apps with Export Video must expose a separate "Video Export" controls section.',
1013
1079
  );
1080
+ expect(contract.aiUsageRules).toContain(
1081
+ 'Animated apps with both Export PNG and Export Video must expose both "Image Export" and "Video Export"; Image Export sits immediately before Video Export.',
1082
+ );
1014
1083
  expect(contract.aiUsageRules).toContain(
1015
1084
  'The Video Export section must include format and resolution controls such as targets "export.video.format" and "export.video.resolution".',
1016
1085
  );
@@ -1048,10 +1117,13 @@ describe("Toolcraft template component contracts", () => {
1048
1117
  "Video export must report frame-based progress through reportProgress during render/encode steps. PNG export should report phase progress for render, blob, and handoff when those phases are asynchronous.",
1049
1118
  );
1050
1119
  expect(contract.aiUsageRules).toContain(
1051
- "Product-output apps must expose user-facing Background color and Include background controls, then pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
1120
+ 'Product-output apps must expose a dedicated "Background" section directly before the first export settings section. With PNG export that first section is Image Export; with video-only export it is Video Export.',
1052
1121
  );
1053
1122
  expect(contract.aiUsageRules).toContain(
1054
- "PNG export must use createToolcraftPngExportCanvas so background transparency and retina sizing are applied consistently without making live preview, workspace canvas backing, or video transparent.",
1123
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
1124
+ );
1125
+ expect(contract.aiUsageRules).toContain(
1126
+ "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently without making live preview, workspace canvas backing, or video transparent.",
1055
1127
  );
1056
1128
  expect(contract.aiUsageRules).toContain(
1057
1129
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
@@ -1080,6 +1152,12 @@ describe("Toolcraft template component contracts", () => {
1080
1152
  expect(contract.aiUsageRules).toContain(
1081
1153
  "In single-layer apps, the runtime shows the uploaded image as the fileDrop preview and provides the clear action.",
1082
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
+ );
1083
1161
  expect(contract.aiUsageRules).toContain(
1084
1162
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
1085
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.",
@@ -204,7 +212,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
204
212
  'Use labels such as "CRT", "Background", "Glow", or "Loop" instead of "Enable CRT" or "Disable background".',
205
213
  "Two adjacent Switch controls for the same product entity must share one inline row when every visible label fits without truncation. Keep paired labels to short one- or two-word names; the runtime auto-pairs safe adjacent switches by target entity, and generated schemas should stack switches only when any label would truncate.",
206
214
  "When the nearest section title already names the switch context, do not duplicate that title as the visible switch label. Use label false for a visual-only toggle and keep the meaning in target/description.",
207
- "A Switch may share an inline row with one related parameter control when the visible switch label is short enough to fit. Hide the switch label when the section title provides the visible context, such as Include background plus Background color inside Background.",
215
+ 'A Switch may share an inline row with one related parameter control when the visible switch label is short enough to fit. That row uses equal-width columns; never shrink the switch column to intrinsic width. In section-owned rows, use a short visible label such as "Include" instead of repeating the section title, such as "Include background" inside Background.',
208
216
  ],
209
217
  },
210
218
  checkbox: {
@@ -239,7 +247,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
239
247
  'Use labels such as "Transparent background", "Guides", or "Loop" instead of "Enable transparent background".',
240
248
  "When the nearest section title already names the checkbox context, do not duplicate that title as the visible checkbox label. Use label false for a visual-only checkbox and keep the meaning in target/description.",
241
249
  "Two adjacent Checkbox controls for the same product entity must share one inline row when every visible label fits without truncation. Keep paired labels to short one- or two-word names; the runtime auto-pairs safe adjacent checkboxes by target entity, and generated schemas should stack checkboxes only when any label would truncate.",
242
- "A Checkbox may share an inline row with one related parameter control when the visible checkbox label is short enough to fit. Hide the checkbox label when the section title provides the visible context.",
250
+ "A Checkbox may share an inline row with one related parameter control when the visible checkbox label is short enough to fit. That row uses equal-width columns; never shrink the checkbox column to intrinsic width. Hide the checkbox label when the section title provides the visible context.",
243
251
  ],
244
252
  },
245
253
  colorOpacity: {
@@ -423,8 +431,14 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
423
431
  "defineToolcraft hoists panelActions into the controls panel sticky footer automatically.",
424
432
  "Product-output apps must always include export in panelActions.",
425
433
  "Static or still-output apps include Export PNG as the primary footer action.",
434
+ 'Every app with Export PNG must expose a separate "Image Export" controls section.',
435
+ 'The Image Export section must include "export.image.format" as a Select control with PNG and JPG choices, defaulting to "png".',
436
+ 'The Image Export section must include "export.image.resolution" as a Select control with 2K, 4K, and 8K choices, defaulting to "4k".',
437
+ "Image Export format and resolution render as one compact two-column inline Select pair, matching the Video Export settings structure.",
438
+ "Image Export resolution controls the actual exported image long edge: 2K = 2048px, 4K = 4096px, 8K = 8192px. Pass the selected runtime value to createToolcraftPngExportCanvas resolution and prove decoded image width/height in browser acceptance.",
426
439
  "Animated apps include Export Video as the primary footer action and Export PNG as a secondary footer action.",
427
440
  'Animated apps with Export Video must expose a separate "Video Export" controls section.',
441
+ 'Animated apps with both Export PNG and Export Video must expose both "Image Export" and "Video Export"; Image Export sits immediately before Video Export.',
428
442
  'The Video Export section must include format and resolution controls such as targets "export.video.format" and "export.video.resolution".',
429
443
  "Use Select controls for Video Export format and resolution; do not use Segmented unless the product has a deliberately tiny fixed output menu and browser tests prove every cell keeps padding.",
430
444
  'Place the Video Export section as the final controls section directly above sticky footer panelActions.',
@@ -437,8 +451,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
437
451
  'Video resolution must control exported dimensions. Use "current" output size by default; "4K" is an export resolution target, not a hardcoded 3840x2160 canvas lock.',
438
452
  "Video export browser coverage must load the exported blob metadata and prove video.duration matches the edited runtime timeline duration; blobSize/blobType checks alone are not enough.",
439
453
  "Video export must report frame-based progress through reportProgress during render/encode steps. PNG export should report phase progress for render, blob, and handoff when those phases are asynchronous.",
440
- "Product-output apps must expose user-facing Background color and Include background controls, then pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
441
- "PNG export must use createToolcraftPngExportCanvas so background transparency and retina sizing are applied consistently without making live preview, workspace canvas backing, or video transparent.",
454
+ 'Product-output apps must expose a dedicated "Background" section directly before the first export settings section. With PNG export that first section is Image Export; with video-only export it is Video Export.',
455
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
456
+ "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently without making live preview, workspace canvas backing, or video transparent.",
442
457
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
443
458
  "Copy PNG can be a secondary action when clipboard output is useful, but copy does not replace export.",
444
459
  "Add Copy PNG as a secondary action only when the prompt/reference includes clipboard output or the product clearly benefits from paste/share workflows.",
@@ -575,10 +590,10 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
575
590
  "Never use generic Color or Colors as a generated section title. If no meaningful color role exists and the colors are just basic colors, use a neutral section title such as Appearance instead of omitting the title.",
576
591
  "Do not split a grouped object section into a separate generated Color section; if the color role is unclear, ask the user before implementation.",
577
592
  "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.",
578
- "Mixed inline rows require visible labels on both controls except for section-title-owned hidden-label toggle plus parameter rows. Color fields in other mixed rows must not be unlabeled.",
593
+ 'Mixed inline rows require visible labels on both controls. The required Background row is the only section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Color fields in other mixed rows must not be unlabeled.',
579
594
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
580
595
  "Product-output apps always expose renderer-owned output background color as a schema color target such as appearance.background or scene.background.",
581
- "Pair renderer-owned output background color with export.includeBackground in one Background section. Prefer an inline hidden-label toggle plus color parameter row when the section title supplies the Background context.",
596
+ 'Pair renderer-owned output background color with export.includeBackground in one Background section directly before export settings. Use an equal-width inline row with the export.includeBackground Switch labeled "Include" on the left and the background Color parameter with label false on the right; each control occupies one half of the row.',
582
597
  "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls only PNG alpha; it must not make live preview, workspace canvas backing, or video transparent.",
583
598
  "Render multiple related color fields in one section with at most two colors per row.",
584
599
  ],
@@ -799,6 +814,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
799
814
  ],
800
815
  layoutConstraints: [
801
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.",
802
818
  ],
803
819
  requiredAcceptance: [
804
820
  "Prove file import changes media state and product output; prove clear removes source material.",
@@ -807,6 +823,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
807
823
  aiUsageRules: [
808
824
  "Use fileDrop for source material uploads in the controls panel, not on the canvas.",
809
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.",
810
828
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
811
829
  ],
812
830
  commands: ["media.delete", "media.import"],
@@ -935,11 +953,18 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
935
953
  "Choose canvas.sizing.mode from product context instead of copying a universal 1024px artboard.",
936
954
  "Use intrinsic-media for single-layer upload/generation apps so imported media natural size becomes canvas.size.",
937
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.",
938
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.",
939
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.",
940
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.",
941
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.",
942
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.",
943
968
  ],
944
969
  capabilities: ["drag", "zoom", "radar", "upload", "editable-size"],
945
970
  commands: [
@@ -986,7 +1011,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
986
1011
  "Do not hand-roll settings import/export through app routes, hidden file inputs, or panelActions.",
987
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.",
988
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.",
989
- "When settings transfer and editable-output canvas sizing are both enabled, the first technical Setup runtime section contains Export Settings, Import Settings, 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.",
990
1015
  "Keep sticky footer panelActions for product delivery actions only, such as Export PNG, Export Video, Copy, Generate, Apply, or Download.",
991
1016
  ],
992
1017
  capabilities: ["settings-import-export"],
@@ -1033,7 +1058,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1033
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.",
1034
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.",
1035
1060
  "Performance matrices must declare rendererWorkload as none, simple-composition, text-output, vector-output, or pixel-output.",
1036
- "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.",
1037
1064
  "Renderer specs must include a Renderer Technique Decision Matrix with sourceRepresentation, productRepresentation, previewRenderer, exportRenderer, rendererWorkload, rendererStrategy, whyNotAlternativeStrategies, fidelityRisks, and performanceRisks.",
1038
1065
  "Custom renderer apps must mirror the Renderer Technique Decision Matrix in typed rendererTechnique config so validation can reject contradictory renderer choices.",
1039
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.",
@@ -1119,6 +1146,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1119
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.",
1120
1147
  "Do not write label-recap descriptions such as Adjusts Opacity, Controls Speed, or Sets Background.",
1121
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.",
1122
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.",
1123
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.",
1124
1152
  "If a source label is unavoidably long, keep the visible label concise and rely on the native title tooltip for the full text.",
@@ -177,7 +177,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
177
177
  currentConstraint:
178
178
  "Product-output apps expose final output delivery through sticky footer panelActions.",
179
179
  desiredBehavior:
180
- "Static products include Export PNG; animated products include Export Video and Export PNG. Copy can be secondary, but it does not replace export. Product apps expose Background and Include background controls; standard export helpers own runtime PNG transparency and retina dimensions, while live preview, workspace canvas backing, and video keep the background.",
180
+ 'Static products include Export PNG plus an "Image Export" section for format and 2K/4K/8K resolution; animated products include Export Video and Export PNG plus "Video Export" settings. Copy can be secondary, but it does not replace export. Product apps expose a required "Background" section directly before export settings, with a Switch labeled "Include" and a background color control with label false in one equal-width row. Standard export helpers own runtime PNG transparency and selected image dimensions or retina fallback, while live preview, workspace canvas backing, and video keep the background.',
181
181
  enforcement: ["acceptance-validator", "performance-validator", "browser-helper", "starter-agents"],
182
182
  id: "output-export-required",
183
183
  level: "invariant",
@@ -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",
@@ -4,6 +4,7 @@ import { defineToolcraft } from "../schema/define-toolcraft";
4
4
  import type { ToolcraftState } from "../state/types";
5
5
  import {
6
6
  createToolcraftPngExportCanvas,
7
+ getToolcraftImageExportSize,
7
8
  getToolcraftRetinaExportPixelRatio,
8
9
  getToolcraftRetinaExportSize,
9
10
  shouldIncludeToolcraftExportBackground,
@@ -105,6 +106,54 @@ describe("Toolcraft export helpers", () => {
105
106
  });
106
107
  });
107
108
 
109
+ it("resolves image export resolution presets from canvas aspect ratio", () => {
110
+ const state = createState();
111
+
112
+ expect(getToolcraftImageExportSize({ resolution: "2k", state })).toEqual({
113
+ height: 1024,
114
+ pixelRatio: 10.24,
115
+ width: 2048,
116
+ });
117
+ expect(getToolcraftImageExportSize({ resolution: "4k", state })).toEqual({
118
+ height: 2048,
119
+ pixelRatio: 20.48,
120
+ width: 4096,
121
+ });
122
+ expect(getToolcraftImageExportSize({ resolution: "8k", state })).toEqual({
123
+ height: 4096,
124
+ pixelRatio: 40.96,
125
+ width: 8192,
126
+ });
127
+ });
128
+
129
+ it("preserves portrait aspect ratio for image export resolution presets", () => {
130
+ const state = createState();
131
+ state.canvas.size = { height: 200, unit: "px", width: 100 };
132
+
133
+ expect(getToolcraftImageExportSize({ resolution: "4k", state })).toEqual({
134
+ height: 4096,
135
+ pixelRatio: 20.48,
136
+ width: 2048,
137
+ });
138
+ });
139
+
140
+ it("falls back to retina sizing for current or unknown image export resolution", () => {
141
+ const state = createState();
142
+
143
+ expect(getToolcraftImageExportSize({ devicePixelRatio: 2, resolution: "current", state }))
144
+ .toEqual({
145
+ height: 200,
146
+ pixelRatio: 2,
147
+ width: 400,
148
+ });
149
+ expect(getToolcraftImageExportSize({ devicePixelRatio: 2, resolution: "source", state }))
150
+ .toEqual({
151
+ height: 200,
152
+ pixelRatio: 2,
153
+ width: 400,
154
+ });
155
+ });
156
+
108
157
  it("creates a transparent retina png canvas when png background is disabled", () => {
109
158
  const schema = defineToolcraft({
110
159
  canvas: { enabled: true },
@@ -159,6 +208,22 @@ describe("Toolcraft export helpers", () => {
159
208
  expect(context.fillRect).toHaveBeenCalledWith(0, 0, 400, 200);
160
209
  });
161
210
 
211
+ it("creates a png canvas at the selected image export resolution", () => {
212
+ const state = createState();
213
+ const { canvas, context } = createMockCanvas();
214
+
215
+ createToolcraftPngExportCanvas({
216
+ canvasFactory: () => canvas,
217
+ render: vi.fn(),
218
+ resolution: "4k",
219
+ state,
220
+ });
221
+
222
+ expect(canvas.width).toBe(4096);
223
+ expect(canvas.height).toBe(2048);
224
+ expect(context.scale).toHaveBeenCalledWith(20.48, 20.48);
225
+ });
226
+
162
227
  it("allows runtime controls to disable the png background", () => {
163
228
  const state = createState();
164
229
  const { canvas, context } = createMockCanvas();
@@ -3,6 +3,8 @@ import type { ToolcraftState } from "../state/types";
3
3
 
4
4
  export type ToolcraftExportFormat = "png" | "video";
5
5
 
6
+ export type ToolcraftImageExportResolution = "current" | "2k" | "4k" | "8k";
7
+
6
8
  export type ToolcraftRetinaExportSize = {
7
9
  height: number;
8
10
  pixelRatio: number;
@@ -19,6 +21,10 @@ export type ToolcraftExportSizeOptions = {
19
21
  state: ToolcraftState;
20
22
  };
21
23
 
24
+ export type ToolcraftImageExportSizeOptions = ToolcraftExportSizeOptions & {
25
+ resolution?: ToolcraftImageExportResolution | string;
26
+ };
27
+
22
28
  export type ToolcraftPngRenderContext = {
23
29
  canvas: HTMLCanvasElement;
24
30
  context: CanvasRenderingContext2D;
@@ -36,9 +42,19 @@ export type ToolcraftPngExportCanvasOptions = {
36
42
  devicePixelRatio?: number;
37
43
  includeBackground?: boolean;
38
44
  render: (context: ToolcraftPngRenderContext) => void;
45
+ resolution?: ToolcraftImageExportResolution | string;
39
46
  state: ToolcraftState;
40
47
  };
41
48
 
49
+ const toolcraftImageExportLongEdges: Record<
50
+ Exclude<ToolcraftImageExportResolution, "current">,
51
+ number
52
+ > = {
53
+ "2k": 2048,
54
+ "4k": 4096,
55
+ "8k": 8192,
56
+ };
57
+
42
58
  export function getToolcraftRetinaExportPixelRatio(devicePixelRatio?: number): number {
43
59
  const globalPixelRatio = (globalThis as typeof globalThis & { devicePixelRatio?: number })
44
60
  .devicePixelRatio;
@@ -67,6 +83,41 @@ export function getToolcraftRetinaExportSize({
67
83
  };
68
84
  }
69
85
 
86
+ export function getToolcraftImageExportSize({
87
+ devicePixelRatio,
88
+ resolution,
89
+ state,
90
+ }: ToolcraftImageExportSizeOptions): ToolcraftRetinaExportSize {
91
+ const normalizedResolution = String(resolution ?? "current").toLowerCase();
92
+ const targetLongEdge =
93
+ toolcraftImageExportLongEdges[
94
+ normalizedResolution as Exclude<ToolcraftImageExportResolution, "current">
95
+ ];
96
+
97
+ if (!targetLongEdge) {
98
+ return getToolcraftRetinaExportSize({ devicePixelRatio, state });
99
+ }
100
+
101
+ const cssWidth = Math.max(1, state.canvas.size.width);
102
+ const cssHeight = Math.max(1, state.canvas.size.height);
103
+ const dominantSize = Math.max(cssWidth, cssHeight);
104
+ const pixelRatio = targetLongEdge / dominantSize;
105
+
106
+ if (cssWidth >= cssHeight) {
107
+ return {
108
+ height: Math.max(1, Math.round(cssHeight * pixelRatio)),
109
+ pixelRatio,
110
+ width: targetLongEdge,
111
+ };
112
+ }
113
+
114
+ return {
115
+ height: targetLongEdge,
116
+ pixelRatio,
117
+ width: Math.max(1, Math.round(cssWidth * pixelRatio)),
118
+ };
119
+ }
120
+
70
121
  export function shouldIncludeToolcraftExportBackground({
71
122
  format,
72
123
  schema,
@@ -84,11 +135,13 @@ export function createToolcraftPngExportCanvas({
84
135
  devicePixelRatio,
85
136
  includeBackground: includeBackgroundOverride,
86
137
  render,
138
+ resolution,
87
139
  state,
88
140
  }: ToolcraftPngExportCanvasOptions): HTMLCanvasElement {
89
141
  const canvas = canvasFactory();
90
- const { height, pixelRatio, width } = getToolcraftRetinaExportSize({
142
+ const { height, pixelRatio, width } = getToolcraftImageExportSize({
91
143
  devicePixelRatio,
144
+ resolution,
92
145
  state,
93
146
  });
94
147
  const includeBackground =