@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
@@ -103,13 +103,68 @@ describe("toolcraftReducer", () => {
103
103
  const redone = toolcraftReducer(undone, { type: "history.redo" });
104
104
 
105
105
  expect(changed.canvas.size.width).toBe(640);
106
+ expect(changed.canvas.size.height).toBe(410);
106
107
  expect(changed.values["canvas.size.width"]).toBe(640);
108
+ expect(changed.values["canvas.size.height"]).toBe(410);
107
109
  expect(changed.history.undo.at(-1)?.label).toBe("canvas.size.width");
108
110
  expect(reset.canvas.size.width).toBe(1200);
111
+ expect(reset.canvas.size.height).toBe(768);
109
112
  expect(reset.values["canvas.size.width"]).toBe(1200);
113
+ expect(reset.values["canvas.size.height"]).toBe(768);
110
114
  expect(reset.history.undo.at(-1)?.label).toBe("Reset controls");
111
115
  expect(undone.canvas.size.width).toBe(1200);
116
+ expect(undone.canvas.size.height).toBe(768);
112
117
  expect(redone.canvas.size.width).toBe(640);
118
+ expect(redone.canvas.size.height).toBe(410);
119
+ });
120
+
121
+ it("routes canvas aspect ratio presets through canvas runtime state", () => {
122
+ const state = createState();
123
+
124
+ const changed = toolcraftReducer(state, {
125
+ target: "canvas.aspectRatio",
126
+ type: "controls.setValue",
127
+ value: {
128
+ height: 9,
129
+ mode: "preset",
130
+ value: "16:9",
131
+ width: 16,
132
+ },
133
+ });
134
+
135
+ expect(changed.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
136
+ expect(changed.values["canvas.aspectRatio"]).toEqual({
137
+ height: 9,
138
+ mode: "preset",
139
+ value: "16:9",
140
+ width: 16,
141
+ });
142
+ expect(changed.values["canvas.size.width"]).toBe(1920);
143
+ expect(changed.values["canvas.size.height"]).toBe(1080);
144
+ expect(changed.history.undo.at(-1)?.label).toBe("canvas.aspectRatio");
145
+ });
146
+
147
+ it("keeps canvas size edits locked to the current aspect ratio", () => {
148
+ const state = toolcraftReducer(createState(), {
149
+ target: "canvas.aspectRatio",
150
+ type: "controls.setValue",
151
+ value: {
152
+ height: 9,
153
+ mode: "preset",
154
+ value: "16:9",
155
+ width: 16,
156
+ },
157
+ });
158
+
159
+ const changed = toolcraftReducer(state, {
160
+ target: "canvas.size.height",
161
+ type: "controls.setValue",
162
+ value: "720",
163
+ });
164
+
165
+ expect(changed.canvas.size).toEqual({ height: 720, unit: "px", width: 1280 });
166
+ expect(changed.values["canvas.size.height"]).toBe(720);
167
+ expect(changed.values["canvas.size.width"]).toBe(1280);
113
168
  });
114
169
 
115
170
  it("updates canvas offset without recording history", () => {
@@ -1,4 +1,8 @@
1
- import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
1
+ import {
2
+ getToolcraftCanvasSizeTargetDimension,
3
+ isToolcraftCanvasAspectRatioTarget,
4
+ } from "../schema/runtime-targets";
5
+ import { getToolcraftCanvasAspectRatioPreset } from "../schema/canvas-aspect-ratio-presets";
2
6
  import {
3
7
  clampToolcraftCanvasZoom,
4
8
  toolcraftCanvasZoomDefault,
@@ -18,6 +22,16 @@ import type {
18
22
 
19
23
  const minTimelineDurationSeconds = 1;
20
24
  const maxTimelineDurationSeconds = 60;
25
+ const canvasAspectRatioTarget = "canvas.aspectRatio";
26
+ const canvasSizeWidthTarget = "canvas.size.width";
27
+ const canvasSizeHeightTarget = "canvas.size.height";
28
+
29
+ type CanvasAspectRatioValue = {
30
+ height: number;
31
+ mode: "custom" | "preset";
32
+ value: string;
33
+ width: number;
34
+ };
21
35
 
22
36
  function asCanvasSizeDimension(value: unknown): number | null {
23
37
  const numberValue =
@@ -34,6 +48,137 @@ function asCanvasSizeDimension(value: unknown): number | null {
34
48
  return Math.max(1, Math.round(numberValue));
35
49
  }
36
50
 
51
+ function getGreatestCommonDivisor(left: number, right: number): number {
52
+ let a = Math.abs(Math.round(left));
53
+ let b = Math.abs(Math.round(right));
54
+
55
+ while (b !== 0) {
56
+ const next = b;
57
+ b = a % b;
58
+ a = next;
59
+ }
60
+
61
+ return a || 1;
62
+ }
63
+
64
+ function getCanvasAspectRatioFromSize(
65
+ size: ToolcraftState["canvas"]["size"],
66
+ ): CanvasAspectRatioValue {
67
+ const divisor = getGreatestCommonDivisor(size.width, size.height);
68
+ const width = Math.max(1, Math.round(size.width / divisor));
69
+ const height = Math.max(1, Math.round(size.height / divisor));
70
+ const value = `${width}:${height}`;
71
+
72
+ return {
73
+ height,
74
+ mode: "custom",
75
+ value,
76
+ width,
77
+ };
78
+ }
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ function parseCanvasAspectRatioString(value: string): CanvasAspectRatioValue | null {
85
+ const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/u.exec(value);
86
+
87
+ if (!match) {
88
+ return null;
89
+ }
90
+
91
+ const width = asCanvasSizeDimension(match[1]);
92
+ const height = asCanvasSizeDimension(match[2]);
93
+
94
+ if (width === null || height === null) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ height,
100
+ mode: getToolcraftCanvasAspectRatioPreset(`${width}:${height}`)
101
+ ? "preset"
102
+ : "custom",
103
+ value: `${width}:${height}`,
104
+ width,
105
+ };
106
+ }
107
+
108
+ function normalizeCanvasAspectRatioValue(
109
+ value: unknown,
110
+ fallbackSize: ToolcraftState["canvas"]["size"],
111
+ ): CanvasAspectRatioValue {
112
+ if (typeof value === "string") {
113
+ return parseCanvasAspectRatioString(value) ?? getCanvasAspectRatioFromSize(fallbackSize);
114
+ }
115
+
116
+ if (isRecord(value)) {
117
+ const width = asCanvasSizeDimension(value.width);
118
+ const height = asCanvasSizeDimension(value.height);
119
+
120
+ if (width !== null && height !== null) {
121
+ const rawValue = typeof value.value === "string" ? value.value : `${width}:${height}`;
122
+ const mode = value.mode === "preset" ? "preset" : "custom";
123
+
124
+ return {
125
+ height,
126
+ mode,
127
+ value: mode === "preset" ? rawValue : `${width}:${height}`,
128
+ width,
129
+ };
130
+ }
131
+ }
132
+
133
+ return getCanvasAspectRatioFromSize(fallbackSize);
134
+ }
135
+
136
+ function applyCanvasAspectRatioToSize({
137
+ anchor,
138
+ ratio,
139
+ size,
140
+ value,
141
+ }: {
142
+ anchor: "height" | "width";
143
+ ratio: CanvasAspectRatioValue;
144
+ size: ToolcraftState["canvas"]["size"];
145
+ value: number;
146
+ }): ToolcraftState["canvas"]["size"] {
147
+ if (anchor === "width") {
148
+ return {
149
+ ...size,
150
+ height: Math.max(1, Math.round((value * ratio.height) / ratio.width)),
151
+ width: value,
152
+ };
153
+ }
154
+
155
+ return {
156
+ ...size,
157
+ height: value,
158
+ width: Math.max(1, Math.round((value * ratio.width) / ratio.height)),
159
+ };
160
+ }
161
+
162
+ function getCanvasAspectRatioPresetSize(
163
+ ratio: CanvasAspectRatioValue,
164
+ ): ToolcraftState["canvas"]["size"] | null {
165
+ if (ratio.mode !== "preset") {
166
+ return null;
167
+ }
168
+
169
+ const preset = getToolcraftCanvasAspectRatioPreset(ratio.value);
170
+
171
+ if (!preset) {
172
+ return null;
173
+ }
174
+
175
+ return {
176
+ height: preset.height,
177
+ unit: "px",
178
+ width: preset.width,
179
+ };
180
+ }
181
+
37
182
  function getResetCanvasSize(
38
183
  state: ToolcraftState,
39
184
  ): ToolcraftState["canvas"]["size"] | null {
@@ -408,6 +553,45 @@ export function toolcraftReducer(
408
553
  case "controls.setValue": {
409
554
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(command.target);
410
555
 
556
+ if (isToolcraftCanvasAspectRatioTarget(command.target)) {
557
+ const ratio = normalizeCanvasAspectRatioValue(command.value, state.canvas.size);
558
+ const size =
559
+ getCanvasAspectRatioPresetSize(ratio) ??
560
+ applyCanvasAspectRatioToSize({
561
+ anchor: "width",
562
+ ratio,
563
+ size: state.canvas.size,
564
+ value: state.canvas.size.width,
565
+ });
566
+
567
+ if (
568
+ state.canvas.size.width === size.width &&
569
+ state.canvas.size.height === size.height &&
570
+ Object.is(state.values[command.target], ratio)
571
+ ) {
572
+ return state;
573
+ }
574
+
575
+ return commitStatePatch(state, {
576
+ after: {
577
+ [canvasAspectRatioTarget]: ratio,
578
+ "canvas.size": size,
579
+ [canvasSizeWidthTarget]: size.width,
580
+ [canvasSizeHeightTarget]: size.height,
581
+ },
582
+ before: {
583
+ [canvasAspectRatioTarget]: state.values[command.target],
584
+ "canvas.size": state.canvas.size,
585
+ [canvasSizeWidthTarget]: state.values[canvasSizeWidthTarget],
586
+ [canvasSizeHeightTarget]: state.values[canvasSizeHeightTarget],
587
+ },
588
+ label: command.label ?? command.target,
589
+ }, {
590
+ group: command.historyGroup,
591
+ mode: command.history,
592
+ });
593
+ }
594
+
411
595
  if (canvasSizeDimension) {
412
596
  const dimensionValue = asCanvasSizeDimension(command.value);
413
597
 
@@ -415,26 +599,47 @@ export function toolcraftReducer(
415
599
  return state;
416
600
  }
417
601
 
602
+ const hasAspectRatioLock =
603
+ canvasAspectRatioTarget in state.values ||
604
+ canvasAspectRatioTarget in state.defaults;
605
+ const size = hasAspectRatioLock
606
+ ? applyCanvasAspectRatioToSize({
607
+ anchor: canvasSizeDimension,
608
+ ratio: normalizeCanvasAspectRatioValue(
609
+ state.values[canvasAspectRatioTarget],
610
+ state.canvas.size,
611
+ ),
612
+ size: state.canvas.size,
613
+ value: dimensionValue,
614
+ })
615
+ : {
616
+ ...state.canvas.size,
617
+ [canvasSizeDimension]: dimensionValue,
618
+ };
619
+ const targetValue = size[canvasSizeDimension];
620
+ const otherTarget =
621
+ canvasSizeDimension === "width" ? canvasSizeHeightTarget : canvasSizeWidthTarget;
622
+ const otherValue = canvasSizeDimension === "width" ? size.height : size.width;
623
+
418
624
  if (
419
- state.canvas.size[canvasSizeDimension] === dimensionValue &&
420
- state.values[command.target] === dimensionValue
625
+ state.canvas.size.width === size.width &&
626
+ state.canvas.size.height === size.height &&
627
+ state.values[command.target] === targetValue &&
628
+ state.values[otherTarget] === otherValue
421
629
  ) {
422
630
  return state;
423
631
  }
424
632
 
425
- const size = {
426
- ...state.canvas.size,
427
- [canvasSizeDimension]: dimensionValue,
428
- };
429
-
430
633
  return commitStatePatch(state, {
431
634
  after: {
432
635
  "canvas.size": size,
433
- [command.target]: dimensionValue,
636
+ [command.target]: targetValue,
637
+ [otherTarget]: otherValue,
434
638
  },
435
639
  before: {
436
640
  "canvas.size": state.canvas.size,
437
641
  [command.target]: state.values[command.target],
642
+ [otherTarget]: state.values[otherTarget],
438
643
  },
439
644
  label: command.label ?? command.target,
440
645
  }, {
@@ -23,8 +23,8 @@ Then follow `workflow.md` to choose the required contract docs and verification
23
23
  9. Animated preview renderers suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions, then resume without changing user playback state.
24
24
  10. If a Figma URL is provided, inspect the Figma file through MCP and rebuild from its structure; never implement from a screenshot or by eye.
25
25
  11. Choose an explicit persistence policy; use schema `persistence` for user-edited app settings that should survive reload, and test real reload restoration when localStorage is enabled.
26
- 12. Use schema `settingsTransfer: "auto"` for complex apps that need import/export of control settings; never implement settings import/export through `panelActions` or route-local file inputs. After adding, removing, or reorganizing controls, sections, timeline, or layers, recalculate settings-transfer eligibility. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. Visible `Canvas width` and `Canvas height` inputs are owned by `editable-output` canvas sizing, not by settings transfer. 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.
27
- 13. Product apps expose `Background` color and `Include background` controls and wire them into PNG export; live preview, workspace canvas backing, and video export keep the background.
26
+ 12. Use schema `settingsTransfer: "auto"` for complex apps that need import/export of control settings; never implement settings import/export through `panelActions` or route-local file inputs. After adding, removing, or reorganizing controls, sections, timeline, or layers, recalculate settings-transfer eligibility. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. Visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are owned by `editable-output` canvas sizing, not by settings transfer. Runtime aspect presets apply canonical canvas sizes, with `16:9` equal to `1920x1080`. 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.
27
+ 13. Product apps expose a required `Background` section directly before export settings. It contains a Switch labeled `Include` and a background color control with `label: false` in one equal-width inline row; PNG export wires those runtime values into the standard export helper while live preview, workspace canvas backing, and video export keep the background. Every app with `Export PNG` exposes `Image Export` with `export.image.format` and `export.image.resolution` as two `select` controls in one compact two-column inline row, and passes the selected resolution to `createToolcraftPngExportCanvas({ resolution })` so 2K/4K/8K change actual PNG dimensions. Animated apps with both PNG and video export place `Image Export` immediately before `Video Export`.
28
28
  14. Keep `docs/toolcraft/agent-worklog.md` current with a decision trail, product decisions, evidence, verification, and risks.
29
29
  15. Prove every visible entity through acceptance, browser, and performance coverage.
30
30
  16. Workload performance scenarios must declare `stressFixture`; browser perf tests must use `getToolcraftPerformanceStressValue(appPerformance, scenarioId)` so heavy-case tests cannot use toy values.
@@ -176,7 +176,9 @@ The app is complete only when:
176
176
  - reset returns schema controls to `defaultValue`;
177
177
  - sticky footer export actions operate on final product output at `state.canvas.size`;
178
178
  - still products expose Export PNG; animated products expose Export Video plus Export PNG;
179
- - PNG export uses `Background` and `Include background` runtime controls with the standard export helper, while live preview, workspace canvas backing, and video keep background;
179
+ - PNG export uses the required `Background` section with `Include` plus unlabeled background color runtime controls, while live preview, workspace canvas backing, and video keep background;
180
+ - every PNG export includes `Image Export` format/resolution `select` controls, and passes `export.image.resolution` into `createToolcraftPngExportCanvas`;
181
+ - animated products with both PNG and video export place `Image Export` immediately before `Video Export`;
180
182
  - all export paths use retina output dimensions from the standard export helper;
181
183
  - layers are absent for single-layer apps and fully working when enabled;
182
184
  - timeline is absent, playback, keyframes, or custom reference timeline according to product behavior;
@@ -58,7 +58,7 @@ Each row should name:
58
58
 
59
59
  The test gate rejects rows without matching automated and browser test names.
60
60
 
61
- `fixed-output` canvas sizing must be deliberate. Its runtime acceptance row must explain why width and height are non-editable. A default size from the prompt should use `editable-output`, which keeps the runtime Canvas width and Canvas height controls.
61
+ `fixed-output` canvas sizing must be deliberate. Its runtime acceptance row must explain why width, height, and aspect ratio are non-editable. A default size from the prompt should use `editable-output`, which keeps the runtime Aspect ratio, Canvas width, and Canvas height controls.
62
62
 
63
63
  When localStorage persistence is enabled, add a runtime acceptance row that proves reload behavior. The browser test must change a real user-facing setting, wait for persistence to write, call a real page reload, and verify the restored control value or product output. Importing a settings JSON file is not persistence coverage.
64
64
 
@@ -136,6 +136,8 @@ Valid acceptance evidence includes:
136
136
 
137
137
  Product apps must include output delivery acceptance. Still-output apps need `Export PNG` evidence. Animated apps need both `Export Video` evidence and `Export PNG` evidence. Clipboard copy can be tested as an additional behavior, but it cannot replace export coverage.
138
138
 
139
+ Every app with `Export PNG` must exercise the separate `Image Export` section: choose at least two `export.image.format` values, choose at least two `export.image.resolution` values, export the image, and decode the result to prove file type and actual pixel dimensions changed. Animated apps with both `Export PNG` and `Export Video` still need this image-export coverage; `Video Export` does not replace it.
140
+
139
141
  Async Export, Download, Copy, Generate, or Apply acceptance must prove the sticky footer top accent indicator is visible while the returned `onPanelAction` Promise is pending, advances when `reportProgress(0..1)` is called, and hides after it settles. Video export acceptance must prove frame-based progress updates during render/encode instead of only toggling a pending state.
140
142
 
141
143
  Animated app acceptance must also exercise the separate `Video Export` section: choose at least two `export.video.format` values, choose at least two `export.video.resolution` values, verify unsupported MIME/container choices fall back safely, and assert exported video bytes, dimensions, MIME/container, and duration match runtime timeline state. The duration assertion must load the exported blob as a video, wait for metadata, and compare `video.duration` with the edited timeline duration; `blobSize > 0`, `blobType`, WebM parser fallback, or assigning the expected duration when metadata is missing are not enough.
@@ -138,7 +138,7 @@ Every product app exposes output background controls:
138
138
 
139
139
  Preview, PNG export, and video export read the background color runtime value. PNG export passes the include-background runtime value to the export helper. Turning `export.includeBackground` off makes only PNG output transparent; live preview, workspace canvas backing, and video output keep the background.
140
140
 
141
- Keep those controls together in one `Background` section. When the section title supplies the full context, prefer an inline row with hidden-label `export.includeBackground` on the left and the background color parameter on the right; do not repeat `Background` as both the section title and switch label.
141
+ Keep those controls together in one required `Background` section directly before the first export settings section. With PNG export that first settings section is `Image Export`; with video-only export it is `Video Export`. Use an equal-width inline row with `export.includeBackground` on the left and the background color parameter on the right; each control occupies half the row. The switch label is `Include`; the color control uses `label: false` because the section title already supplies the background context.
142
142
 
143
143
  Every product app needs output delivery in sticky footer `panelActions`. Still-output apps expose `Export PNG`. Animated apps expose `Export Video` and `Export PNG`. Clipboard copy is optional and never replaces export. If an odd number of footer actions leaves one action alone in the final row, that final action spans the full row.
144
144
 
@@ -146,12 +146,19 @@ Async product actions such as Export, Download, Copy, Generate, or Apply must re
146
146
 
147
147
  For complex apps, use schema `settingsTransfer: "auto"` or `true` for settings import/export. Recalculate settings-transfer eligibility after adding, removing, or reorganizing controls, sections, timeline, or layers. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. Do not put Import Settings or Export Settings in sticky footer `panelActions`; runtime inserts the technical `Setup` settings-transfer section first without a visible section heading.
148
148
 
149
- If the app also uses `editable-output` canvas sizing, that first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Canvas width`, and `Canvas height` in that order. Do not split the canvas size fields and settings-transfer actions into app-authored sections.
149
+ If the app also uses `editable-output` canvas sizing, that first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split the canvas size fields and settings-transfer actions into app-authored sections.
150
150
 
151
151
  If a controls panel shows only `Export Settings` and `Import Settings` in the first runtime section, check the canvas sizing decision. Product-output apps usually need `editable-output`; intrinsic media and explicitly fixed output are the cases where visible canvas size inputs are absent.
152
152
 
153
153
  For user-edited settings that should survive reload, use schema `persistence` with a stable app-specific key. When localStorage persistence is enabled, acceptance must prove a user setting restores after a real browser reload. Do not use settings import/export as a workaround for broken persistence.
154
154
 
155
+ Every app with `Export PNG` must include a separate `Image Export` controls section with:
156
+
157
+ - `export.image.format` as `select`, defaulting to `png`, with `png` and `jpg` baseline options;
158
+ - `export.image.resolution` as `select`, defaulting to `4k`, with `2k`, `4k`, and `8k` baseline options.
159
+
160
+ `Image Export` `Format` and `Resolution` are one compact workflow pair: render them in a two-column inline row by default. For still-output apps, place `Image Export` directly above sticky footer export buttons. For animated apps with both image and video export, place `Image Export` immediately before `Video Export`.
161
+
155
162
  Animated apps with `Export Video` must include a separate `Video Export` controls section with at least:
156
163
 
157
164
  - `export.video.format` as `select`, defaulting to `mp4`, with `mp4` and `webm` baseline options;
@@ -159,7 +166,7 @@ Animated apps with `Export Video` must include a separate `Video Export` control
159
166
 
160
167
  Place `Video Export` as the final authored controls section directly above sticky footer export buttons. Treat `Format` and `Resolution` as a compact semantic pair and put them in one two-column inline row by default. Use vertical rows only when the compact row would clip labels or selected values, and record that fallback reason in the worklog.
161
168
 
162
- Use standard export helpers. `createToolcraftPngExportCanvas` applies retina sizing and accepts `includeBackground` for runtime PNG transparency. Do not rely on static `export.png.background` alone when the UI exposes background controls. Video export keeps background and still uses `getToolcraftRetinaExportSize`.
169
+ Use standard export helpers. `createToolcraftPngExportCanvas` accepts `includeBackground` for runtime PNG transparency and `resolution` for image-export output size. Pass the selected `export.image.resolution` into the PNG helper so 2K/4K/8K produce actual 2048/4096/8192px long-edge PNGs. Do not rely on static `export.png.background` alone when the UI exposes background controls. Video export keeps background and still uses `getToolcraftRetinaExportSize`.
163
170
 
164
171
  Video export must choose the actual MIME/container with `MediaRecorder.isTypeSupported(...)` or an explicit encoder/transcoder capability check. `MOV` and `ProRes` are allowed only when the app provides a custom encoder/transcoder and proves it with acceptance plus performance coverage. Treat `4K` as an export resolution target, not a hardcoded canvas lock. Offline rendered-frame export must encode or mux frame timestamps from runtime timeline time; real-time `canvas.captureStream()` plus `MediaRecorder` records wall-clock export time and is not enough when renderer work can be slower than playback. Browser acceptance must load the exported blob as a video, wait for metadata, and compare `video.duration` with the edited timeline duration; `blobSize > 0`, `blobType`, parser fallback, or assigning the expected duration in `catch` is not enough.
165
172
 
@@ -124,9 +124,9 @@ Use `colorOpacity` when one product entity owns both color and opacity, such as
124
124
 
125
125
  When one short numeric/text field and one plain `color` field configure the same entity, they can share a two-column inline row. Example: `Mask size` and `Color` belong in the same `Mask` row instead of two stacked rows. Do not put `colorOpacity` in inline rows.
126
126
 
127
- Mixed inline rows require label parity: every field in that row has a visible label. The exception is a section-title-owned toggle + parameter row: a hidden-label `switch` or `checkbox` may sit beside one related parameter when the section title supplies the visible context. Color fields in other mixed rows must not be unlabeled.
127
+ Mixed inline rows require label parity: every field in that row has a visible label. The required `Background` section row is the only section-title-owned exception: use the switch label `Include` beside the background color parameter with `label: false`. Color fields in other mixed rows must not be unlabeled.
128
128
 
129
- Renderer-owned output background is a base product control. Use a schema `color` target such as `appearance.background` or `scene.background`, add an `export.includeBackground` control for PNG transparency, and make preview/export read those runtime values. Keep them in one `Background` section. Prefer one inline row with hidden-label `export.includeBackground` on the left and `appearance.background` on the right when no other fit rule is violated. `export.includeBackground` controls only PNG alpha; it must not make live preview, workspace canvas backing, or video output transparent. Do not hardcode a configurable background in CSS, Canvas `fillStyle`, or WebGL clear color.
129
+ Renderer-owned output background is a base product control. Use a schema `color` target such as `appearance.background` or `scene.background`, add an `export.includeBackground` control for PNG transparency, and make preview/export read those runtime values. Keep them in one required `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`. Use one equal-width inline row with `export.includeBackground` on the left and `appearance.background` on the right when no other fit rule is violated. The switch label is `Include`, not `Include background`; the background color control uses `label: false`. Each control occupies one half of the row; do not shrink the toggle column to intrinsic width. `export.includeBackground` controls only PNG alpha; it must not make live preview, workspace canvas backing, or video output transparent. Do not hardcode a configurable background in CSS, Canvas `fillStyle`, or WebGL clear color.
130
130
 
131
131
  ## File Upload
132
132
 
@@ -215,11 +215,11 @@ For compound controls such as `fontPicker`, `description` must not enumerate own
215
215
 
216
216
  If a source label is unavoidably long, keep the visible label concise and rely on native `title` for the full text.
217
217
 
218
- Switch and checkbox labels name the setting context, not the action. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead of `Enable CRT` or `Disable guides`. If the section title already names the setting context, do not repeat that title as the visible toggle label; use `label: false` and keep the meaning in `target` and `description`.
218
+ Switch and checkbox labels name the setting context, not the action. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead of `Enable CRT` or `Disable guides`. If the section title already names the setting context, do not repeat that title as the visible toggle label; use a short contextual label such as `Include` or, only for icon-only visual toggles, `label: false` with the meaning in `target` and `description`.
219
219
 
220
220
  Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when every visible label fits without truncation. Use short one- or two-word labels such as `Snap X` and `Snap Y`, or `Glow` and `Loop`. The runtime auto-pairs safe adjacent toggles by target entity; use explicit layout groups only when pairing a toggle with a non-toggle parameter. If either label would truncate in half-width, remove the inline group and let the toggles stack.
221
221
 
222
- A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and the controls edit the same entity. Example: `Loop` plus `Duration`, or hidden-label `export.includeBackground` plus `appearance.background` inside `Background`. If the section title already names the toggle context, hide the toggle label instead of repeating the title.
222
+ A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and the controls edit the same entity. This row is always equal-width: each control occupies one half. Example: `Loop` plus `Duration`, or `Include` plus unlabeled background color inside the required `Background` section. If the section title already names the toggle context, shorten the label instead of repeating the title.
223
223
 
224
224
  ## Layers
225
225
 
@@ -267,7 +267,7 @@ Use schema `settingsTransfer` for settings import/export. Do not add Import Sett
267
267
 
268
268
  Recalculate settings-transfer eligibility after adding, removing, or reorganizing controls, sections, timeline, or layers. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. If the threshold is reached, use `settingsTransfer: "auto"` / `true` or document a product-specific opt-out through `runtime.settingsTransfer` acceptance evidence.
269
269
 
270
- When settings transfer and editable-output canvas sizing are both enabled, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate app-authored sections, rename the controls, or rebuild the block by hand.
270
+ When settings transfer and editable-output canvas sizing are both enabled, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate app-authored sections, rename the controls, or rebuild the block by hand.
271
271
 
272
272
  If only `Export Settings` and `Import Settings` appear in that section, the schema is not using `editable-output` canvas sizing or already owns `canvas.size.width` / `canvas.size.height` controls. For product-output apps, prefer fixing the canvas sizing decision over adding hand-built size fields.
273
273
 
@@ -277,6 +277,13 @@ Still-output product apps include one primary `Export PNG` action.
277
277
 
278
278
  Animated product apps include `Export Video` as the primary action and `Export PNG` as the secondary action.
279
279
 
280
+ Every product app with `Export PNG` includes a separate `Image Export` section. That section must contain:
281
+
282
+ - `export.image.format` as a `select`, with default value `png` and baseline options `png` and `jpg`;
283
+ - `export.image.resolution` as a `select`, with default value `4k` and baseline options `2k`, `4k`, and `8k`.
284
+
285
+ Place `Image Export` directly above sticky footer export buttons for still-output apps. For animated apps with both PNG and video export, place `Image Export` immediately before `Video Export`. `Format` and `Resolution` are one compact workflow pair: render them in a two-column inline row by default. Do not use `segmented` for this pair; it must visually match the Video Export dropdown structure.
286
+
280
287
  Animated product apps with `Export Video` include a separate `Video Export` section. That section must contain:
281
288
 
282
289
  - `export.video.format` as a `select`, with default value `mp4` and baseline options `mp4` and `webm`;
@@ -45,9 +45,47 @@ export: {
45
45
  - `appearance.background` or `scene.background` as a `color` control;
46
46
  - `export.includeBackground` as a boolean/options control.
47
47
 
48
- PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, state, render })`, where `background` and `includeBackground` come from runtime state. `includeBackground` controls only PNG alpha; live preview, workspace canvas backing, and video export keep the product background. Video export always includes the product background, uses `getToolcraftRetinaExportSize`, and must prove exported metadata duration matches the runtime timeline duration.
48
+ PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, resolution, state, render })`, where `background`, `includeBackground`, and `resolution` come from runtime state. `includeBackground` controls only PNG alpha; live preview, workspace canvas backing, and video export keep the product background. For every app with `Export PNG`, `resolution` comes from `export.image.resolution`: `2k`, `4k`, and `8k` render actual 2048/4096/8192px long-edge PNGs. `current` or omitted resolution falls back to retina sizing. Video export always includes the product background, uses `getToolcraftRetinaExportSize`, and must prove exported metadata duration matches the runtime timeline duration.
49
49
 
50
- Animated apps with `Export Video` also expose a separate `Video Export` controls section. Do not mix video export settings into renderer/effect sections. Place this section as the final authored controls section directly above sticky footer export buttons. `Format` and `Resolution` are a compact semantic pair, so use an inline two-column layout by default; stack them only when labels or selected values would clip.
50
+ Every app with `Export PNG` exposes a separate `Image Export` controls section. For still-output apps it sits directly above sticky footer actions. For animated apps with both `Export PNG` and `Export Video`, it sits immediately before `Video Export`:
51
+
52
+ ```ts
53
+ {
54
+ title: "Image Export",
55
+ controls: {
56
+ imageFormat: {
57
+ defaultValue: "png",
58
+ label: "Format",
59
+ options: [
60
+ { label: "PNG", value: "png" },
61
+ { label: "JPG", value: "jpg" },
62
+ ],
63
+ target: "export.image.format",
64
+ type: "select",
65
+ },
66
+ imageResolution: {
67
+ defaultValue: "4k",
68
+ label: "Resolution",
69
+ options: [
70
+ { label: "2K", value: "2k" },
71
+ { label: "4K", value: "4k" },
72
+ { label: "8K", value: "8k" },
73
+ ],
74
+ target: "export.image.resolution",
75
+ type: "select",
76
+ },
77
+ },
78
+ layoutGroups: [
79
+ {
80
+ layout: "inline",
81
+ columns: 2,
82
+ controls: ["imageFormat", "imageResolution"],
83
+ },
84
+ ],
85
+ }
86
+ ```
87
+
88
+ Animated apps with `Export Video` also expose a separate `Video Export` controls section. Do not mix video export settings into renderer/effect sections. Place this section after `Image Export` as the final authored controls section directly above sticky footer export buttons. `Format` and `Resolution` are a compact semantic pair, so use an inline two-column layout by default; stack them only when labels or selected values would clip.
51
89
 
52
90
  ```ts
53
91
  {
@@ -96,9 +134,9 @@ Choose sizing from product context:
96
134
 
97
135
  For product output, export, copy, download, shader rendering, procedural rendering, or no single intrinsic source image, use `editable-output` unless the product explicitly needs `fixed-output`.
98
136
 
99
- A prompt-provided base/default size is only the initial `canvas.size`. It must not remove the runtime Canvas width and Canvas height controls. Use `fixed-output` only when the reference or product explicitly locks dimensions, and add runtime acceptance with `canvasSizingCoverage: "fixed-output-size"`.
137
+ A prompt-provided base/default size is only the initial `canvas.size`. It must not remove the runtime Aspect ratio, Canvas width, and Canvas height controls. Aspect presets use canonical output sizes (`16:9` is `1920x1080`; the other presets are derived around a 1080px short edge or matching portrait long edge). Use `fixed-output` only when the reference or product explicitly locks dimensions, and add runtime acceptance with `canvasSizingCoverage: "fixed-output-size"`.
100
138
 
101
- Resolved `canvas.size` exists for every canvas app, but visible `Canvas width` and `Canvas height` inputs are mandatory only for `editable-output` sizing. They do not depend on `settingsTransfer`: when settings transfer is off, the runtime prepends a technical `Setup` canvas size section without a visible heading; when settings transfer is on, the controls merge into the first technical `Setup` runtime settings section without a visible heading. Do not hand-build a duplicate size selector.
139
+ Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are mandatory only for `editable-output` sizing. They do not depend on `settingsTransfer`: when settings transfer is off, the runtime prepends a technical `Setup` canvas size section without a visible heading; when settings transfer is on, the controls merge into the first technical `Setup` runtime settings section without a visible heading. Do not hand-build a duplicate size selector.
102
140
 
103
141
  ## Panels
104
142
 
@@ -203,9 +241,9 @@ Control labels are judged with their nearest visible context. Short property lab
203
241
 
204
242
  If a target prefix has to be split across sections, the spec must name the workflow reason. Otherwise the acceptance validator treats the split as a sectioning error.
205
243
 
206
- Switch and checkbox labels name the setting context only. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead. If the nearest section title already names the context, do not duplicate it as the visible toggle label. Use `label: false` for a visual-only toggle and keep the product meaning in `target` and `description`.
244
+ Switch and checkbox labels name the setting context only. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead. If the nearest section title already names the context, do not duplicate it as the visible toggle label. Use a short contextual label such as `Include` or, only for icon-only visual toggles, `label: false` with the product meaning in `target` and `description`.
207
245
 
208
- Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color controls show visible field labels in mixed sections that contain any non-color control. Omit visible color labels only in color-only sections or in a section-title-owned toggle + parameter row, such as hidden `export.includeBackground` plus `appearance.background` inside `Background`. Mixed inline rows require visible labels on every field except this hidden-label toggle + parameter pattern. Color fields in other mixed rows must not be unlabeled. Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when both visible labels fit without truncation; the runtime auto-pairs safe adjacent toggles by target entity, and schemas should stack them only when either label is too long. A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and both controls edit the same entity; hide the toggle label when the section title already supplies the context. Schema `slider` and `rangeSlider` controls always stay stacked at full width; the only built-in exception is the paired letter-spacing and line-height footer sliders inside `fontPicker`. Do not place sibling controls for case, color, opacity, size, weight, letter spacing, or line height when the same text entity already uses `fontPicker`.
246
+ Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color controls show visible field labels in mixed sections that contain any non-color control. Omit visible color labels only in color-only sections. Mixed inline rows require visible labels on every field, except the required Background row where the color control uses `label: false` because the section title owns the context. Color fields in other mixed rows must not be unlabeled. Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when both visible labels fit without truncation; the runtime auto-pairs safe adjacent toggles by target entity, and schemas should stack them only when either label is too long. A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and both controls edit the same entity; shorten the toggle label when the section title already supplies context. Toggle plus parameter rows are equal-width two-column rows: each control occupies one half, never intrinsic toggle width plus remaining space. The required Background row uses `Include` plus unlabeled background color. Schema `slider` and `rangeSlider` controls always stay stacked at full width; the only built-in exception is the paired letter-spacing and line-height footer sliders inside `fontPicker`. Do not place sibling controls for case, color, opacity, size, weight, letter spacing, or line height when the same text entity already uses `fontPicker`.
209
247
 
210
248
  `rangeSlider` is always a full-width two-thumb control. Do not include it in `layoutGroups`. Its `defaultValue` must start with different lower and upper values, such as `[20, 80]`, so the two handles do not collapse into one apparent slider. Manual range labels accept built-in separators such as slash, hyphen, spaces, and dashes.
211
249
 
@@ -258,7 +296,7 @@ When enabled, the runtime inserts a technical `Setup` settings-transfer section
258
296
 
259
297
  After adding, removing, or reorganizing controls, sections, timeline, or layers, recalculate settings-transfer eligibility. If the threshold is reached, use `"auto"` / `true` or add an explicit `runtime.settingsTransfer` opt-out acceptance row with product evidence.
260
298
 
261
- When settings transfer is enabled and the canvas uses `editable-output` sizing, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate sections or recreate them manually.
299
+ When settings transfer is enabled and the canvas uses `editable-output` sizing, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate sections or recreate them manually.
262
300
 
263
301
  A settings-transfer section with only `Export Settings` and `Import Settings` means the canvas is not `editable-output` or the app already declares its own `canvas.size.width` / `canvas.size.height` controls. For product-output apps, treat that as a schema decision to review.
264
302