@pixel-point/toolcraft 0.0.2 → 0.0.4

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 (38) hide show
  1. package/README.md +15 -2
  2. package/package.json +6 -1
  3. package/src/cli.mjs +226 -17
  4. package/src/cli.test.mjs +127 -2
  5. package/templates/runtime/contracts/component-contracts.test.ts +28 -7
  6. package/templates/runtime/contracts/component-contracts.ts +14 -7
  7. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/controls-panel.test.tsx +54 -6
  11. package/templates/runtime/react/controls-panel.tsx +216 -0
  12. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  13. package/templates/runtime/react/settings-transfer.ts +28 -2
  14. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  15. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  16. package/templates/runtime/schema/define-toolcraft.ts +60 -2
  17. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  18. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  19. package/templates/runtime/schema/runtime-targets.ts +5 -0
  20. package/templates/runtime/state/create-template-state.test.ts +6 -0
  21. package/templates/runtime/state/reducer.test.ts +55 -0
  22. package/templates/runtime/state/reducer.ts +214 -9
  23. package/templates/starter/AGENTS.md +5 -3
  24. package/templates/starter/docs/toolcraft/acceptance-testing.md +3 -1
  25. package/templates/starter/docs/toolcraft/assembly-workflow.md +10 -3
  26. package/templates/starter/docs/toolcraft/component-rules.md +12 -5
  27. package/templates/starter/docs/toolcraft/schema-reference.md +45 -7
  28. package/templates/starter/scripts/check-ai-skills.mjs +39 -4
  29. package/templates/starter/src/app/starter-acceptance.test.ts +623 -21
  30. package/templates/starter/src/app/starter-acceptance.ts +290 -3
  31. package/templates/ui/components/control-layout/index.tsx +4 -4
  32. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  33. package/toolcraft-skills/brainstorming/SKILL.md +28 -0
  34. package/toolcraft-skills/browser/SKILL.md +25 -0
  35. package/toolcraft-skills/figma/SKILL.md +24 -0
  36. package/toolcraft-skills/figma-implement-design/SKILL.md +24 -0
  37. package/toolcraft-skills/systematic-debugging/SKILL.md +24 -0
  38. package/toolcraft-skills/writing-plans/SKILL.md +26 -0
@@ -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
 
@@ -392,7 +392,7 @@ describe("Toolcraft template component contracts", () => {
392
392
  "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
393
  );
394
394
  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.",
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.",
396
396
  );
397
397
  });
398
398
 
@@ -493,7 +493,7 @@ describe("Toolcraft template component contracts", () => {
493
493
  "Product-output apps always expose renderer-owned output background color as a schema color target such as appearance.background or scene.background.",
494
494
  );
495
495
  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.",
496
+ '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
497
  );
498
498
  expect(color.aiUsageRules).toContain(
499
499
  "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 +502,7 @@ describe("Toolcraft template component contracts", () => {
502
502
  "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.",
503
503
  );
504
504
  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.",
505
+ '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
506
  );
507
507
  expect(color.aiUsageRules).toContain(
508
508
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
@@ -1005,12 +1005,30 @@ describe("Toolcraft template component contracts", () => {
1005
1005
  expect(contract.aiUsageRules).toContain(
1006
1006
  "Static or still-output apps include Export PNG as the primary footer action.",
1007
1007
  );
1008
+ expect(contract.aiUsageRules).toContain(
1009
+ 'Every app with Export PNG must expose a separate "Image Export" controls section.',
1010
+ );
1011
+ expect(contract.aiUsageRules).toContain(
1012
+ 'The Image Export section must include "export.image.format" as a Select control with PNG and JPG choices, defaulting to "png".',
1013
+ );
1014
+ expect(contract.aiUsageRules).toContain(
1015
+ 'The Image Export section must include "export.image.resolution" as a Select control with 2K, 4K, and 8K choices, defaulting to "4k".',
1016
+ );
1017
+ expect(contract.aiUsageRules).toContain(
1018
+ "Image Export format and resolution render as one compact two-column inline Select pair, matching the Video Export settings structure.",
1019
+ );
1020
+ expect(contract.aiUsageRules).toContain(
1021
+ "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.",
1022
+ );
1008
1023
  expect(contract.aiUsageRules).toContain(
1009
1024
  "Animated apps include Export Video as the primary footer action and Export PNG as a secondary footer action.",
1010
1025
  );
1011
1026
  expect(contract.aiUsageRules).toContain(
1012
1027
  'Animated apps with Export Video must expose a separate "Video Export" controls section.',
1013
1028
  );
1029
+ expect(contract.aiUsageRules).toContain(
1030
+ 'Animated apps with both Export PNG and Export Video must expose both "Image Export" and "Video Export"; Image Export sits immediately before Video Export.',
1031
+ );
1014
1032
  expect(contract.aiUsageRules).toContain(
1015
1033
  'The Video Export section must include format and resolution controls such as targets "export.video.format" and "export.video.resolution".',
1016
1034
  );
@@ -1048,10 +1066,13 @@ describe("Toolcraft template component contracts", () => {
1048
1066
  "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
1067
  );
1050
1068
  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.",
1069
+ '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.',
1070
+ );
1071
+ expect(contract.aiUsageRules).toContain(
1072
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
1052
1073
  );
1053
1074
  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.",
1075
+ "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
1076
  );
1056
1077
  expect(contract.aiUsageRules).toContain(
1057
1078
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
@@ -204,7 +204,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
204
204
  'Use labels such as "CRT", "Background", "Glow", or "Loop" instead of "Enable CRT" or "Disable background".',
205
205
  "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
206
  "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.",
207
+ '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
208
  ],
209
209
  },
210
210
  checkbox: {
@@ -239,7 +239,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
239
239
  'Use labels such as "Transparent background", "Guides", or "Loop" instead of "Enable transparent background".',
240
240
  "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
241
  "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.",
242
+ "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
243
  ],
244
244
  },
245
245
  colorOpacity: {
@@ -423,8 +423,14 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
423
423
  "defineToolcraft hoists panelActions into the controls panel sticky footer automatically.",
424
424
  "Product-output apps must always include export in panelActions.",
425
425
  "Static or still-output apps include Export PNG as the primary footer action.",
426
+ 'Every app with Export PNG must expose a separate "Image Export" controls section.',
427
+ 'The Image Export section must include "export.image.format" as a Select control with PNG and JPG choices, defaulting to "png".',
428
+ 'The Image Export section must include "export.image.resolution" as a Select control with 2K, 4K, and 8K choices, defaulting to "4k".',
429
+ "Image Export format and resolution render as one compact two-column inline Select pair, matching the Video Export settings structure.",
430
+ "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
431
  "Animated apps include Export Video as the primary footer action and Export PNG as a secondary footer action.",
427
432
  'Animated apps with Export Video must expose a separate "Video Export" controls section.',
433
+ '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
434
  'The Video Export section must include format and resolution controls such as targets "export.video.format" and "export.video.resolution".',
429
435
  "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
436
  'Place the Video Export section as the final controls section directly above sticky footer panelActions.',
@@ -437,8 +443,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
437
443
  '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
444
  "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
445
  "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.",
446
+ '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.',
447
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
448
+ "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
449
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
443
450
  "Copy PNG can be a secondary action when clipboard output is useful, but copy does not replace export.",
444
451
  "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 +582,10 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
575
582
  "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
583
  "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
584
  "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.",
585
+ '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
586
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
580
587
  "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.",
588
+ '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
589
  "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
590
  "Render multiple related color fields in one section with at most two colors per row.",
584
591
  ],
@@ -986,7 +993,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
986
993
  "Do not hand-roll settings import/export through app routes, hidden file inputs, or panelActions.",
987
994
  "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
995
  "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.",
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.",
990
997
  "Keep sticky footer panelActions for product delivery actions only, such as Export PNG, Export Video, Copy, Generate, Apply, or Download.",
991
998
  ],
992
999
  capabilities: ["settings-import-export"],
@@ -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",
@@ -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 =
@@ -384,7 +384,7 @@ describe("ControlsPanel", () => {
384
384
  expect(inlineGroup?.textContent).toContain("Snap Y");
385
385
  });
386
386
 
387
- it("renders hidden-label toggle plus parameter rows inline without duplicating section labels", () => {
387
+ it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
388
388
  const schema = defineToolcraft({
389
389
  canvas: { enabled: false },
390
390
  panels: {
@@ -396,7 +396,7 @@ describe("ControlsPanel", () => {
396
396
  defaultValue: true,
397
397
  description:
398
398
  "Controls PNG background transparency while preview and video keep the background.",
399
- label: false,
399
+ label: "Include",
400
400
  target: "export.includeBackground",
401
401
  type: "switch",
402
402
  },
@@ -428,12 +428,15 @@ describe("ControlsPanel", () => {
428
428
  );
429
429
 
430
430
  expect(screen.getAllByText("Background")).toHaveLength(1);
431
- expect(screen.getByRole("switch", { name: "includeBackground" })).toBeTruthy();
431
+ expect(screen.getByText("Include")).toBeTruthy();
432
+ expect(screen.getByRole("switch")).toBeTruthy();
432
433
  expect(toggleParameterGroup).toBeTruthy();
433
- expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
434
+ expect(toggleParameterGroup?.getAttribute("style")).toContain(
435
+ "repeat(2, minmax(0, 1fr))",
436
+ );
434
437
  });
435
438
 
436
- it("renders short visible toggle plus parameter rows as compact toggle-parameter rows", () => {
439
+ it("renders short visible toggle plus parameter rows as equal-width toggle-parameter rows", () => {
437
440
  const schema = defineToolcraft({
438
441
  canvas: { enabled: false },
439
442
  panels: {
@@ -477,7 +480,9 @@ describe("ControlsPanel", () => {
477
480
  expect(screen.getByText("Loop")).toBeTruthy();
478
481
  expect(screen.getByText("Duration")).toBeTruthy();
479
482
  expect(toggleParameterGroup).toBeTruthy();
480
- expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
483
+ expect(toggleParameterGroup?.getAttribute("style")).toContain(
484
+ "repeat(2, minmax(0, 1fr))",
485
+ );
481
486
  });
482
487
 
483
488
  it("renders compact select pairs inline with stacked full-width fields", () => {
@@ -3085,6 +3090,42 @@ describe("ControlsPanel", () => {
3085
3090
  expect(screen.getByTestId("canvas-size").textContent).toBe("320,180");
3086
3091
  });
3087
3092
 
3093
+ it("renders editable-output aspect ratio controls with the current canvas preset", () => {
3094
+ const schema = defineToolcraft({
3095
+ canvas: {
3096
+ enabled: true,
3097
+ size: { height: 1080, unit: "px", width: 1440 },
3098
+ sizing: { mode: "editable-output" },
3099
+ },
3100
+ panels: {
3101
+ controls: {
3102
+ sections: [],
3103
+ title: "Controls",
3104
+ },
3105
+ },
3106
+ });
3107
+ const { container } = renderControlsPanelWithSchema(schema);
3108
+
3109
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1440,1080");
3110
+
3111
+ const aspectRatioControl = container.querySelector<HTMLElement>(
3112
+ '[data-slot="canvas-aspect-ratio-control"]',
3113
+ );
3114
+
3115
+ expect(screen.getByText("Aspect ratio")).toBeTruthy();
3116
+ expect(aspectRatioControl?.textContent).toContain("4:3");
3117
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3118
+
3119
+ expect(values["canvas.aspectRatio"]).toEqual({
3120
+ height: 3,
3121
+ mode: "preset",
3122
+ value: "4:3",
3123
+ width: 4,
3124
+ });
3125
+ expect(values["canvas.size.width"]).toBe(1440);
3126
+ expect(values["canvas.size.height"]).toBe(1080);
3127
+ });
3128
+
3088
3129
  it("applies empty text input values while typing and reset restores defaults", () => {
3089
3130
  renderControlsPanel();
3090
3131
 
@@ -3391,11 +3432,18 @@ describe("ControlsPanel", () => {
3391
3432
  expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pb-5");
3392
3433
  expect(firstInlineGroup?.textContent).toContain("Canvas width");
3393
3434
  expect(firstInlineGroup?.textContent).toContain("Canvas height");
3435
+ expect(firstSection?.textContent).toContain("Aspect ratio");
3394
3436
  expect(firstSection?.textContent).toContain("Canvas width");
3395
3437
  expect(firstSection?.textContent).toContain("Canvas height");
3396
3438
  expect(firstSection?.textContent).toContain("Export Settings");
3397
3439
  expect(firstSection?.textContent).toContain("Import Settings");
3398
3440
  expect((firstSection?.textContent ?? "").indexOf("Export Settings")).toBeLessThan(
3441
+ (firstSection?.textContent ?? "").indexOf("Import Settings"),
3442
+ );
3443
+ expect((firstSection?.textContent ?? "").indexOf("Import Settings")).toBeLessThan(
3444
+ (firstSection?.textContent ?? "").indexOf("Aspect ratio"),
3445
+ );
3446
+ expect((firstSection?.textContent ?? "").indexOf("Aspect ratio")).toBeLessThan(
3399
3447
  (firstSection?.textContent ?? "").indexOf("Canvas width"),
3400
3448
  );
3401
3449
  expect(scrollContent?.textContent).toContain("Import Settings");