@pixel-point/toolcraft 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +59 -2
  6. package/templates/runtime/contracts/component-contracts.ts +23 -2
  7. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  8. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  9. package/templates/runtime/react/controls-panel.test.tsx +269 -0
  10. package/templates/runtime/react/controls-panel.tsx +133 -24
  11. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  12. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  13. package/templates/runtime/react/timeline-panel.tsx +98 -10
  14. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  15. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  16. package/templates/runtime/schema/define-toolcraft.test.ts +77 -1
  17. package/templates/runtime/schema/define-toolcraft.ts +138 -5
  18. package/templates/runtime/schema/runtime-targets.ts +1 -0
  19. package/templates/runtime/schema/types.ts +23 -1
  20. package/templates/runtime/state/canvas-zoom.ts +1 -1
  21. package/templates/runtime/state/create-template-state.test.ts +6 -6
  22. package/templates/runtime/state/reducer.test.ts +86 -8
  23. package/templates/runtime/state/reducer.ts +41 -22
  24. package/templates/runtime/state/types.ts +1 -0
  25. package/templates/starter/AGENTS.md +2 -2
  26. package/templates/starter/docs/toolcraft/README.md +1 -1
  27. package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
  28. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  29. package/templates/starter/docs/toolcraft/component-rules.md +13 -1
  30. package/templates/starter/docs/toolcraft/performance.md +5 -0
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  32. package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
  33. package/templates/starter/gitignore +36 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  35. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  36. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  37. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  38. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  39. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -26,9 +26,9 @@ import type {
26
26
  } from "./types";
27
27
 
28
28
  const defaultCanvasSize = {
29
- height: 1024,
29
+ height: 1080,
30
30
  unit: "px",
31
- width: 1024,
31
+ width: 1920,
32
32
  } satisfies ToolcraftCanvasSize;
33
33
 
34
34
  type ResolvedCanvas = ResolvedToolcraftAppSchema["canvas"];
@@ -46,6 +46,14 @@ const canvasSizeControlTargets = {
46
46
  width: "canvas.size.width",
47
47
  } as const;
48
48
  const canvasAspectRatioTarget = "canvas.aspectRatio";
49
+ const canvasRenderScaleTarget = "canvas.renderScale";
50
+ const defaultCanvasRenderScale = {
51
+ defaultValue: 2,
52
+ enabled: false,
53
+ max: 2,
54
+ min: 1,
55
+ step: 0.25,
56
+ } satisfies ResolvedToolcraftAppSchema["canvas"]["renderScale"];
49
57
  const maxAutoInlineControlLabelLength = 18;
50
58
  const settingsTransferTarget = "runtime.settingsTransfer";
51
59
  const runtimeSetupSectionTitle = "Setup";
@@ -190,6 +198,54 @@ function resolveCanvasSizing(
190
198
  return { mode: "intrinsic-media" };
191
199
  }
192
200
 
201
+ function clampCanvasRenderScale(value: number | undefined, fallback: number): number {
202
+ if (typeof value !== "number" || !Number.isFinite(value)) {
203
+ return fallback;
204
+ }
205
+
206
+ return Math.max(1, Math.min(2, value));
207
+ }
208
+
209
+ function resolveCanvasRenderScale(
210
+ renderScale: ToolcraftAppSchema["canvas"]["renderScale"],
211
+ ): ResolvedToolcraftAppSchema["canvas"]["renderScale"] {
212
+ if (renderScale === true) {
213
+ return {
214
+ ...defaultCanvasRenderScale,
215
+ enabled: true,
216
+ };
217
+ }
218
+
219
+ if (!renderScale) {
220
+ return defaultCanvasRenderScale;
221
+ }
222
+
223
+ const min = clampCanvasRenderScale(renderScale.min, defaultCanvasRenderScale.min);
224
+ const max = Math.max(
225
+ min,
226
+ clampCanvasRenderScale(renderScale.max, defaultCanvasRenderScale.max),
227
+ );
228
+ const step =
229
+ typeof renderScale.step === "number" && Number.isFinite(renderScale.step)
230
+ ? Math.max(0.01, Math.min(1, renderScale.step))
231
+ : defaultCanvasRenderScale.step;
232
+ const defaultValue = Math.max(
233
+ min,
234
+ Math.min(
235
+ max,
236
+ clampCanvasRenderScale(renderScale.defaultValue, defaultCanvasRenderScale.defaultValue),
237
+ ),
238
+ );
239
+
240
+ return {
241
+ defaultValue,
242
+ enabled: renderScale.enabled ?? true,
243
+ max,
244
+ min,
245
+ step,
246
+ };
247
+ }
248
+
193
249
  function resolveExport(
194
250
  exportSchema: ToolcraftAppSchema["export"],
195
251
  ): ResolvedExport {
@@ -264,6 +320,10 @@ function createToolcraftAssembly({
264
320
  commands.push("canvas.setSize");
265
321
  }
266
322
 
323
+ if (canvas.renderScale.enabled) {
324
+ capabilities.push("canvas.renderScale");
325
+ }
326
+
267
327
  if (canvas.draggable) {
268
328
  capabilities.push("canvas.draggable");
269
329
  commands.push("canvas.panBy", "canvas.setOffset", "canvas.setViewport");
@@ -742,10 +802,12 @@ function getCanvasAspectRatioDefaultValue(size: ToolcraftCanvasSize): {
742
802
 
743
803
  function createCanvasSizeSection({
744
804
  aspectRatioControl,
805
+ renderScaleControl,
745
806
  sizeControlIds,
746
807
  sizeControls,
747
808
  }: {
748
809
  aspectRatioControl: ToolcraftControlSchema;
810
+ renderScaleControl?: ToolcraftControlSchema;
749
811
  sizeControlIds: readonly string[];
750
812
  sizeControls: ToolcraftControlSectionSchema["controls"];
751
813
  }): ToolcraftControlSectionSchema {
@@ -753,19 +815,33 @@ function createCanvasSizeSection({
753
815
  controls: {
754
816
  canvasAspectRatio: aspectRatioControl,
755
817
  ...sizeControls,
818
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
756
819
  },
757
820
  layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
758
821
  title: runtimeSetupSectionTitle,
759
822
  };
760
823
  }
761
824
 
825
+ function createCanvasRenderScaleSection(
826
+ renderScaleControl: ToolcraftControlSchema,
827
+ ): ToolcraftControlSectionSchema {
828
+ return {
829
+ controls: {
830
+ canvasRenderScale: renderScaleControl,
831
+ },
832
+ title: runtimeSetupSectionTitle,
833
+ };
834
+ }
835
+
762
836
  function mergeCanvasSizeControlsIntoSettingsTransferSection({
763
837
  aspectRatioControl,
838
+ renderScaleControl,
764
839
  settingsTransferSection,
765
840
  sizeControlIds,
766
841
  sizeControls,
767
842
  }: {
768
843
  aspectRatioControl: ToolcraftControlSchema;
844
+ renderScaleControl?: ToolcraftControlSchema;
769
845
  settingsTransferSection: ToolcraftControlSectionSchema;
770
846
  sizeControlIds: readonly string[];
771
847
  sizeControls: ToolcraftControlSectionSchema["controls"];
@@ -778,6 +854,7 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
778
854
  ...settingsTransferSection.controls,
779
855
  canvasAspectRatio: aspectRatioControl,
780
856
  ...sizeControls,
857
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
781
858
  },
782
859
  layoutGroups:
783
860
  canvasSizeLayoutGroups.length > 0 || settingsTransferSection.layoutGroups?.length
@@ -789,6 +866,22 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
789
866
  };
790
867
  }
791
868
 
869
+ function mergeCanvasRenderScaleIntoSettingsTransferSection({
870
+ renderScaleControl,
871
+ settingsTransferSection,
872
+ }: {
873
+ renderScaleControl: ToolcraftControlSchema;
874
+ settingsTransferSection: ToolcraftControlSectionSchema;
875
+ }): ToolcraftControlSectionSchema {
876
+ return {
877
+ ...settingsTransferSection,
878
+ controls: {
879
+ ...settingsTransferSection.controls,
880
+ canvasRenderScale: renderScaleControl,
881
+ },
882
+ };
883
+ }
884
+
792
885
  function isPrimaryPanelAction(action: ToolcraftControlActionSchema): boolean {
793
886
  return typeof action !== "string" && action.variant !== "outline";
794
887
  }
@@ -1308,14 +1401,46 @@ function normalizePanels({
1308
1401
 
1309
1402
  const controls = { ...panels.controls };
1310
1403
  const settingsTransferSection = createSettingsTransferSection(settingsTransfer);
1404
+ const renderScaleControl: ToolcraftControlSchema | undefined =
1405
+ canvas.renderScale.enabled && !hasControlTarget(panels, canvasRenderScaleTarget)
1406
+ ? {
1407
+ defaultValue: canvas.renderScale.defaultValue,
1408
+ description:
1409
+ "Increases raster canvas backing resolution without changing the visible output size.",
1410
+ label: "Resolution scale",
1411
+ markerCount:
1412
+ Math.floor(
1413
+ (canvas.renderScale.max - canvas.renderScale.min) / canvas.renderScale.step,
1414
+ ) + 1,
1415
+ max: canvas.renderScale.max,
1416
+ min: canvas.renderScale.min,
1417
+ performanceReason:
1418
+ "Resolution scale changes raster, Canvas, WebGL, or WebGPU backing pixels.",
1419
+ performanceRole: "workload",
1420
+ step: canvas.renderScale.step,
1421
+ target: canvasRenderScaleTarget,
1422
+ type: "slider",
1423
+ unit: "x",
1424
+ variant: "discrete",
1425
+ }
1426
+ : undefined;
1311
1427
 
1312
1428
  if (!canvas.enabled || canvas.sizing.mode !== "editable-output") {
1429
+ const runtimeSetupSection =
1430
+ settingsTransferSection && renderScaleControl
1431
+ ? mergeCanvasRenderScaleIntoSettingsTransferSection({
1432
+ renderScaleControl,
1433
+ settingsTransferSection,
1434
+ })
1435
+ : (settingsTransferSection ??
1436
+ (renderScaleControl ? createCanvasRenderScaleSection(renderScaleControl) : null));
1437
+
1313
1438
  return {
1314
1439
  ...normalizedPanels,
1315
1440
  controls: normalizeControlsPanelLayout({
1316
1441
  ...controls,
1317
1442
  sections: [
1318
- ...(settingsTransferSection ? [settingsTransferSection] : []),
1443
+ ...(runtimeSetupSection ? [runtimeSetupSection] : []),
1319
1444
  ...controls.sections,
1320
1445
  ],
1321
1446
  }),
@@ -1357,7 +1482,7 @@ function normalizePanels({
1357
1482
  sizeControlIds.push("canvasHeight");
1358
1483
  }
1359
1484
 
1360
- if (Object.keys(sizeControls).length === 0) {
1485
+ if (Object.keys(sizeControls).length === 0 && !renderScaleControl) {
1361
1486
  return {
1362
1487
  ...normalizedPanels,
1363
1488
  controls: normalizeControlsPanelLayout({
@@ -1373,11 +1498,17 @@ function normalizePanels({
1373
1498
  const runtimeSettingsSection = settingsTransferSection
1374
1499
  ? mergeCanvasSizeControlsIntoSettingsTransferSection({
1375
1500
  aspectRatioControl,
1501
+ renderScaleControl,
1376
1502
  settingsTransferSection,
1377
1503
  sizeControlIds,
1378
1504
  sizeControls,
1379
1505
  })
1380
- : createCanvasSizeSection({ aspectRatioControl, sizeControlIds, sizeControls });
1506
+ : createCanvasSizeSection({
1507
+ aspectRatioControl,
1508
+ renderScaleControl,
1509
+ sizeControlIds,
1510
+ sizeControls,
1511
+ });
1381
1512
 
1382
1513
  return {
1383
1514
  ...normalizedPanels,
@@ -1453,6 +1584,7 @@ function assertPanelPersistenceContract({
1453
1584
  export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAppSchema {
1454
1585
  const canvasEnabled = schema.canvas.enabled;
1455
1586
  const canvasSize = schema.canvas.size;
1587
+ const canvasRenderScale = resolveCanvasRenderScale(schema.canvas.renderScale);
1456
1588
  const canvasSizing = resolveCanvasSizing(schema.canvas);
1457
1589
  const persistence = resolvePersistence(schema.persistence);
1458
1590
  const settingsTransfer = resolveSettingsTransfer({
@@ -1464,6 +1596,7 @@ export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAp
1464
1596
  const canvas = {
1465
1597
  ...schema.canvas,
1466
1598
  draggable: canvasEnabled ? (schema.canvas.draggable ?? true) : false,
1599
+ renderScale: canvasRenderScale,
1467
1600
  size: canvasSize ?? defaultCanvasSize,
1468
1601
  sizeSource: canvasSize ? ("app" as const) : ("runtime-default" as const),
1469
1602
  sizing: canvasSizing,
@@ -1,5 +1,6 @@
1
1
  export const toolcraftRuntimeOwnedTargets = [
2
2
  "canvas.aspectRatio",
3
+ "canvas.renderScale",
3
4
  "canvas.size.width",
4
5
  "canvas.size.height",
5
6
  ] as const;
@@ -21,6 +21,24 @@ export type ToolcraftCanvasSizingSchema = {
21
21
  mode: ToolcraftCanvasSizingMode;
22
22
  };
23
23
 
24
+ export type ToolcraftCanvasRenderScaleSchema =
25
+ | boolean
26
+ | {
27
+ defaultValue?: number;
28
+ enabled?: boolean;
29
+ max?: number;
30
+ min?: number;
31
+ step?: number;
32
+ };
33
+
34
+ export type ResolvedToolcraftCanvasRenderScaleSchema = {
35
+ defaultValue: number;
36
+ enabled: boolean;
37
+ max: number;
38
+ min: number;
39
+ step: number;
40
+ };
41
+
24
42
  export type ToolcraftPngExportBackground = "include" | "transparent";
25
43
 
26
44
  export type ToolcraftPngExportSchema = {
@@ -45,6 +63,7 @@ export type ToolcraftAssemblyComponentId =
45
63
  export type ToolcraftAssemblyCapability =
46
64
  | "canvas.draggable"
47
65
  | "canvas.editableSize"
66
+ | "canvas.renderScale"
48
67
  | "canvas.upload"
49
68
  | "controls.defaults"
50
69
  | "controls.panel"
@@ -141,6 +160,7 @@ export type ToolcraftAssemblyContract = {
141
160
  export type ToolcraftCanvasSchema = {
142
161
  draggable?: boolean;
143
162
  enabled: boolean;
163
+ renderScale?: ToolcraftCanvasRenderScaleSchema;
144
164
  size?: ToolcraftCanvasSize;
145
165
  sizing?: ToolcraftCanvasSizingSchema;
146
166
  upload?: boolean;
@@ -300,6 +320,7 @@ export type ToolcraftControlSchema = {
300
320
  markerCount?: number;
301
321
  max?: number;
302
322
  min?: number;
323
+ multiple?: boolean;
303
324
  orderRole?: ToolcraftControlOrderRole;
304
325
  performanceReason?: string;
305
326
  performanceRole?: ToolcraftControlPerformanceRole;
@@ -358,7 +379,8 @@ export type ToolcraftAppSchema = {
358
379
 
359
380
  export type ResolvedToolcraftAppSchema = {
360
381
  assembly: ToolcraftAssemblyContract;
361
- canvas: Required<ToolcraftCanvasSchema> & {
382
+ canvas: Omit<Required<ToolcraftCanvasSchema>, "renderScale"> & {
383
+ renderScale: ResolvedToolcraftCanvasRenderScaleSchema;
362
384
  size: ToolcraftCanvasSize;
363
385
  sizeSource: ToolcraftCanvasSizeSource;
364
386
  };
@@ -1,7 +1,7 @@
1
1
  export const toolcraftCanvasZoomMin = 25;
2
2
  export const toolcraftCanvasZoomMax = 400;
3
3
  export const toolcraftCanvasZoomStep = 10;
4
- export const toolcraftCanvasZoomDefault = 70;
4
+ export const toolcraftCanvasZoomDefault = 100;
5
5
 
6
6
  export function clampToolcraftCanvasZoom(zoom: number): number {
7
7
  return Math.min(toolcraftCanvasZoomMax, Math.max(toolcraftCanvasZoomMin, zoom));
@@ -55,13 +55,13 @@ describe("createToolcraftState", () => {
55
55
 
56
56
  expect(state.values).toMatchObject({
57
57
  "canvas.aspectRatio": {
58
- height: 1,
58
+ height: 9,
59
59
  mode: "preset",
60
- value: "1:1",
61
- width: 1,
60
+ value: "16:9",
61
+ width: 16,
62
62
  },
63
- "canvas.size.height": 1024,
64
- "canvas.size.width": 1024,
63
+ "canvas.size.height": 1080,
64
+ "canvas.size.width": 1920,
65
65
  });
66
66
  });
67
67
 
@@ -75,7 +75,7 @@ describe("createToolcraftState", () => {
75
75
  const state = createToolcraftState(app);
76
76
 
77
77
  expect(state.canvas.size).toEqual(size);
78
- expect(state.canvas.zoom).toBe(70);
78
+ expect(state.canvas.zoom).toBe(100);
79
79
  });
80
80
 
81
81
  it("preserves seeded canvas width", () => {
@@ -103,9 +103,15 @@ 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
+ expect(changed.canvas.size.height).toBe(768);
107
+ expect(changed.values["canvas.aspectRatio"]).toEqual({
108
+ height: 6,
109
+ mode: "custom",
110
+ value: "5:6",
111
+ width: 5,
112
+ });
107
113
  expect(changed.values["canvas.size.width"]).toBe(640);
108
- expect(changed.values["canvas.size.height"]).toBe(410);
114
+ expect(changed.values["canvas.size.height"]).toBe(768);
109
115
  expect(changed.history.undo.at(-1)?.label).toBe("canvas.size.width");
110
116
  expect(reset.canvas.size.width).toBe(1200);
111
117
  expect(reset.canvas.size.height).toBe(768);
@@ -115,7 +121,7 @@ describe("toolcraftReducer", () => {
115
121
  expect(undone.canvas.size.width).toBe(1200);
116
122
  expect(undone.canvas.size.height).toBe(768);
117
123
  expect(redone.canvas.size.width).toBe(640);
118
- expect(redone.canvas.size.height).toBe(410);
124
+ expect(redone.canvas.size.height).toBe(768);
119
125
  });
120
126
 
121
127
  it("routes canvas aspect ratio presets through canvas runtime state", () => {
@@ -144,7 +150,7 @@ describe("toolcraftReducer", () => {
144
150
  expect(changed.history.undo.at(-1)?.label).toBe("canvas.aspectRatio");
145
151
  });
146
152
 
147
- it("keeps canvas size edits locked to the current aspect ratio", () => {
153
+ it("turns manual canvas size edits into a custom aspect ratio", () => {
148
154
  const state = toolcraftReducer(createState(), {
149
155
  target: "canvas.aspectRatio",
150
156
  type: "controls.setValue",
@@ -162,9 +168,47 @@ describe("toolcraftReducer", () => {
162
168
  value: "720",
163
169
  });
164
170
 
165
- expect(changed.canvas.size).toEqual({ height: 720, unit: "px", width: 1280 });
171
+ expect(changed.canvas.size).toEqual({ height: 720, unit: "px", width: 1920 });
172
+ expect(changed.values["canvas.aspectRatio"]).toEqual({
173
+ height: 3,
174
+ mode: "custom",
175
+ value: "8:3",
176
+ width: 8,
177
+ });
166
178
  expect(changed.values["canvas.size.height"]).toBe(720);
167
- expect(changed.values["canvas.size.width"]).toBe(1280);
179
+ expect(changed.values["canvas.size.width"]).toBe(1920);
180
+ });
181
+
182
+ it("keeps repeated canvas size values as no-op without changing aspect ratio mode", () => {
183
+ const app = defineToolcraft({
184
+ canvas: {
185
+ enabled: true,
186
+ size: { height: 1080, unit: "px", width: 1920 },
187
+ sizing: { mode: "editable-output" },
188
+ },
189
+ panels: {
190
+ controls: {
191
+ sections: [],
192
+ title: "Controls",
193
+ },
194
+ },
195
+ });
196
+ const state = createToolcraftState(app);
197
+
198
+ const unchanged = toolcraftReducer(state, {
199
+ target: "canvas.size.width",
200
+ type: "controls.setValue",
201
+ value: "1920",
202
+ });
203
+
204
+ expect(unchanged).toBe(state);
205
+ expect(unchanged.values["canvas.aspectRatio"]).toEqual({
206
+ height: 9,
207
+ mode: "preset",
208
+ value: "16:9",
209
+ width: 16,
210
+ });
211
+ expect(unchanged.history.undo).toEqual([]);
168
212
  });
169
213
 
170
214
  it("updates canvas offset without recording history", () => {
@@ -225,11 +269,11 @@ describe("toolcraftReducer", () => {
225
269
  state = toolcraftReducer(state, { type: "canvas.zoomOut" });
226
270
  state = toolcraftReducer(state, { type: "canvas.zoomIn" });
227
271
 
228
- expect(state.canvas.zoom).toBe(60);
272
+ expect(state.canvas.zoom).toBe(90);
229
273
 
230
274
  state = toolcraftReducer(state, { type: "canvas.zoomReset" });
231
275
 
232
- expect(state.canvas.zoom).toBe(70);
276
+ expect(state.canvas.zoom).toBe(100);
233
277
  });
234
278
 
235
279
  it("sets viewport zoom and offset for gesture zoom", () => {
@@ -380,6 +424,40 @@ describe("toolcraftReducer", () => {
380
424
  });
381
425
  });
382
426
 
427
+ it("appends single-layer media when import is explicitly non-replacing", () => {
428
+ const state = createState();
429
+ const first = toolcraftReducer(state, {
430
+ asset: {
431
+ dataUrl: "data:image/png;base64,first",
432
+ fileName: "first.png",
433
+ mimeType: "image/png",
434
+ position: { x: 0, y: 0 },
435
+ size: state.canvas.size,
436
+ },
437
+ replaceExisting: false,
438
+ type: "media.import",
439
+ });
440
+ const second = toolcraftReducer(first, {
441
+ asset: {
442
+ dataUrl: "data:image/png;base64,second",
443
+ fileName: "second.png",
444
+ mimeType: "image/png",
445
+ position: { x: 0, y: 0 },
446
+ size: state.canvas.size,
447
+ },
448
+ replaceExisting: false,
449
+ type: "media.import",
450
+ });
451
+
452
+ expect(second.layers.map((layer) => layer.id)).toEqual(["layer-1", "layer-2"]);
453
+ expect(second.mediaAssets.map((asset) => asset.id)).toEqual(["media-1", "media-2"]);
454
+ expect(second.mediaAssets.map((asset) => asset.fileName)).toEqual([
455
+ "first.png",
456
+ "second.png",
457
+ ]);
458
+ expect(second.selectedLayerId).toBe("layer-2");
459
+ });
460
+
383
461
  it("adds and selects runtime layers and groups", () => {
384
462
  const withGroup = toolcraftReducer(createState(), {
385
463
  layer: {
@@ -133,6 +133,22 @@ function normalizeCanvasAspectRatioValue(
133
133
  return getCanvasAspectRatioFromSize(fallbackSize);
134
134
  }
135
135
 
136
+ function canvasAspectRatioValuesEqual(
137
+ first: unknown,
138
+ second: CanvasAspectRatioValue,
139
+ ): boolean {
140
+ if (!isRecord(first)) {
141
+ return false;
142
+ }
143
+
144
+ return (
145
+ first.height === second.height &&
146
+ first.mode === second.mode &&
147
+ first.value === second.value &&
148
+ first.width === second.width
149
+ );
150
+ }
151
+
136
152
  function applyCanvasAspectRatioToSize({
137
153
  anchor,
138
154
  ratio,
@@ -567,7 +583,7 @@ export function toolcraftReducer(
567
583
  if (
568
584
  state.canvas.size.width === size.width &&
569
585
  state.canvas.size.height === size.height &&
570
- Object.is(state.values[command.target], ratio)
586
+ canvasAspectRatioValuesEqual(state.values[command.target], ratio)
571
587
  ) {
572
588
  return state;
573
589
  }
@@ -599,44 +615,42 @@ export function toolcraftReducer(
599
615
  return state;
600
616
  }
601
617
 
602
- const hasAspectRatioLock =
618
+ const hasAspectRatioControl =
603
619
  canvasAspectRatioTarget in state.values ||
604
620
  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
- };
621
+ const size = {
622
+ ...state.canvas.size,
623
+ [canvasSizeDimension]: dimensionValue,
624
+ };
625
+ const aspectRatio = getCanvasAspectRatioFromSize(size);
619
626
  const targetValue = size[canvasSizeDimension];
620
627
  const otherTarget =
621
628
  canvasSizeDimension === "width" ? canvasSizeHeightTarget : canvasSizeWidthTarget;
622
629
  const otherValue = canvasSizeDimension === "width" ? size.height : size.width;
623
630
 
624
- if (
631
+ const sizeUnchanged =
625
632
  state.canvas.size.width === size.width &&
626
633
  state.canvas.size.height === size.height &&
627
634
  state.values[command.target] === targetValue &&
628
- state.values[otherTarget] === otherValue
629
- ) {
635
+ state.values[otherTarget] === otherValue;
636
+
637
+ if (sizeUnchanged) {
630
638
  return state;
631
639
  }
632
640
 
633
641
  return commitStatePatch(state, {
634
642
  after: {
643
+ ...(hasAspectRatioControl
644
+ ? { [canvasAspectRatioTarget]: aspectRatio }
645
+ : {}),
635
646
  "canvas.size": size,
636
647
  [command.target]: targetValue,
637
648
  [otherTarget]: otherValue,
638
649
  },
639
650
  before: {
651
+ ...(hasAspectRatioControl
652
+ ? { [canvasAspectRatioTarget]: state.values[canvasAspectRatioTarget] }
653
+ : {}),
640
654
  "canvas.size": state.canvas.size,
641
655
  [command.target]: state.values[command.target],
642
656
  [otherTarget]: state.values[otherTarget],
@@ -1014,13 +1028,18 @@ export function toolcraftReducer(
1014
1028
  };
1015
1029
 
1016
1030
  case "media.import": {
1017
- const shouldReplaceSingleLayerMedia = !state.schema.panels.layers;
1031
+ const shouldReplaceSingleLayerMedia =
1032
+ !state.schema.panels.layers && command.replaceExisting !== false;
1018
1033
  const shouldResizeCanvas =
1019
1034
  state.schema.canvas.sizing.mode === "intrinsic-media";
1020
1035
  const layerId =
1021
- command.asset.layerId ?? getSingleLayerImportId(state) ?? getNextLayerId(state);
1036
+ command.asset.layerId ??
1037
+ (shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
1038
+ getNextLayerId(state);
1022
1039
  const mediaId =
1023
- command.asset.id ?? getSingleMediaImportId(state) ?? getNextMediaId(state);
1040
+ command.asset.id ??
1041
+ (shouldReplaceSingleLayerMedia ? getSingleMediaImportId(state) : undefined) ??
1042
+ getNextMediaId(state);
1024
1043
  const layer = {
1025
1044
  displayName: command.asset.layerName ?? getImportedLayerName(command.asset.fileName),
1026
1045
  id: layerId,
@@ -39,6 +39,7 @@ export type ToolcraftCommand =
39
39
  layerId?: string;
40
40
  layerName?: string;
41
41
  };
42
+ replaceExisting?: boolean;
42
43
  type: "media.import";
43
44
  }
44
45
  | { mediaId: string; type: "media.delete" }
@@ -23,7 +23,7 @@ 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 `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.
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`; manual Canvas width/height edits keep the typed dimension, keep the other dimension unchanged, switch Aspect ratio to Custom, and show the reduced current ratio in custom ratio inputs; when no explicit product size is provided, the runtime default canvas size is also `1920x1080`. Non-vector raster, Canvas 2D, WebGL, and WebGPU previews set `canvas.renderScale: true`; the first technical runtime section then appends `Resolution scale` after canvas sizing so backing pixels can increase up to 2x without changing CSS/output size. Performance fixes must preserve the selected render scale and keep canvas preview responsive to sliders/high-frequency controls at that scale; diagnose the bottleneck before reducing quality. Do not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping `canvas.renderScale` below the user's chosen value. 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, for raster outputs, `Resolution scale` in that order and renders without a visible section heading.
27
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.
@@ -146,7 +146,7 @@ Do not rerun `pnpm install` after every edit. Run it after fresh export, depende
146
146
 
147
147
  Do not run the full browser performance suite for Tier 0-2 edits unless a performance checkpoint trigger applies.
148
148
 
149
- Run a full performance checkpoint with `pnpm verify:perf` 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, or when the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
149
+ Run a full performance checkpoint with `pnpm verify:perf` 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, or when the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
150
150
 
151
151
  Fast feature loops may defer full performance only when none of the checkpoint triggers above apply. Record the deferred check and reason in the verification note or worklog.
152
152
 
@@ -29,7 +29,7 @@ Every implementation pass must choose a verification tier before editing. Use th
29
29
  | Tier 3 | Renderer, canvas, timeline, layers, upload, export, zoom, heavy controls, or a performance checkpoint trigger | `pnpm verify:quick` + targeted browser/perf scenarios |
30
30
  | Tier 4 | Final delivery, fresh export, runtime/template/contract changes, broad renderer/product rewrites | `pnpm verify:final` |
31
31
 
32
- Run `pnpm verify:perf` when a performance checkpoint is triggered: first working app version, renderer/canvas/animation/export/timeline/layers changes, a fix for previously broken functionality, any performance optimization, or a user request to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
32
+ Run `pnpm verify:perf` when a performance checkpoint is triggered: first working app version, renderer/canvas/animation/export/timeline/layers changes, adding/enabling `canvas.renderScale` or the `Resolution scale` retina slider, a fix for previously broken functionality, any performance optimization, or a user request to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
33
33
 
34
34
  Fresh folders or dependency changes need `pnpm install` before verification. Final delivery still starts the local app after the gate:
35
35
 
@@ -16,7 +16,7 @@ Every visible product entity must prove it works. A control is not accepted beca
16
16
 
17
17
  `pnpm verify:final` must pass before final delivery. Incremental edits use the verification tier classifier from `assembly-workflow.md`: run targeted browser acceptance for the changed entity, and add `pnpm verify:perf` whenever a performance checkpoint is triggered.
18
18
 
19
- A performance checkpoint is triggered by the first working app version, renderer/canvas/animation/export/timeline/layers changes, a fix for previously broken functionality, any performance optimization, or a user request to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
19
+ A performance checkpoint is triggered by the first working app version, renderer/canvas/animation/export/timeline/layers changes, adding/enabling `canvas.renderScale` or the `Resolution scale` retina slider, a fix for previously broken functionality, any performance optimization, or a user request to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
20
20
 
21
21
  ## Product Readiness
22
22