@pixel-point/toolcraft 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +86 -8
  6. package/templates/runtime/contracts/component-contracts.ts +36 -8
  7. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  11. package/templates/runtime/react/controls-panel.test.tsx +323 -6
  12. package/templates/runtime/react/controls-panel.tsx +349 -24
  13. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  14. package/templates/runtime/react/settings-transfer.ts +28 -2
  15. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  16. package/templates/runtime/react/timeline-panel.tsx +98 -10
  17. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  18. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  19. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  20. package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
  21. package/templates/runtime/schema/define-toolcraft.ts +197 -6
  22. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  23. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  24. package/templates/runtime/schema/runtime-targets.ts +6 -0
  25. package/templates/runtime/schema/types.ts +23 -1
  26. package/templates/runtime/state/canvas-zoom.ts +1 -1
  27. package/templates/runtime/state/create-template-state.test.ts +9 -3
  28. package/templates/runtime/state/reducer.test.ts +135 -2
  29. package/templates/runtime/state/reducer.ts +236 -12
  30. package/templates/runtime/state/types.ts +1 -0
  31. package/templates/starter/AGENTS.md +6 -4
  32. package/templates/starter/docs/toolcraft/README.md +1 -1
  33. package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
  34. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
  35. package/templates/starter/docs/toolcraft/component-rules.md +24 -5
  36. package/templates/starter/docs/toolcraft/performance.md +5 -0
  37. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  38. package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
  39. package/templates/starter/gitignore +36 -0
  40. package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
  41. package/templates/starter/src/app/starter-acceptance.ts +357 -4
  42. package/templates/ui/components/control-layout/index.tsx +4 -4
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  45. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  46. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  47. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -1,4 +1,8 @@
1
1
  import { TOOLCRAFT_COMPONENT_CONTRACTS } from "../contracts/component-contracts";
2
+ import {
3
+ getToolcraftCanvasAspectRatioPreset,
4
+ getToolcraftCanvasAspectRatioPresetBySize,
5
+ } from "./canvas-aspect-ratio-presets";
2
6
  import type {
3
7
  ToolcraftAssemblyCapability,
4
8
  ToolcraftAssemblyCommand,
@@ -22,9 +26,9 @@ import type {
22
26
  } from "./types";
23
27
 
24
28
  const defaultCanvasSize = {
25
- height: 1024,
29
+ height: 1080,
26
30
  unit: "px",
27
- width: 1024,
31
+ width: 1920,
28
32
  } satisfies ToolcraftCanvasSize;
29
33
 
30
34
  type ResolvedCanvas = ResolvedToolcraftAppSchema["canvas"];
@@ -41,6 +45,15 @@ const canvasSizeControlTargets = {
41
45
  height: "canvas.size.height",
42
46
  width: "canvas.size.width",
43
47
  } as const;
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"];
44
57
  const maxAutoInlineControlLabelLength = 18;
45
58
  const settingsTransferTarget = "runtime.settingsTransfer";
46
59
  const runtimeSetupSectionTitle = "Setup";
@@ -185,6 +198,54 @@ function resolveCanvasSizing(
185
198
  return { mode: "intrinsic-media" };
186
199
  }
187
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
+
188
249
  function resolveExport(
189
250
  exportSchema: ToolcraftAppSchema["export"],
190
251
  ): ResolvedExport {
@@ -259,6 +320,10 @@ function createToolcraftAssembly({
259
320
  commands.push("canvas.setSize");
260
321
  }
261
322
 
323
+ if (canvas.renderScale.enabled) {
324
+ capabilities.push("canvas.renderScale");
325
+ }
326
+
262
327
  if (canvas.draggable) {
263
328
  capabilities.push("canvas.draggable");
264
329
  commands.push("canvas.panBy", "canvas.setOffset", "canvas.setViewport");
@@ -553,6 +618,7 @@ function isRuntimeOnlyActionControl(control: ToolcraftControlSchema): boolean {
553
618
  function isSettingsTransferEligibilityControl(control: ToolcraftControlSchema): boolean {
554
619
  return (
555
620
  !isRuntimeOnlyActionControl(control) &&
621
+ control.target !== canvasAspectRatioTarget &&
556
622
  control.target !== canvasSizeControlTargets.width &&
557
623
  control.target !== canvasSizeControlTargets.height
558
624
  );
@@ -699,25 +765,83 @@ function getCanvasSizeLayoutGroups(
699
765
  : undefined;
700
766
  }
701
767
 
768
+ function getGreatestCommonDivisor(left: number, right: number): number {
769
+ let a = Math.abs(Math.round(left));
770
+ let b = Math.abs(Math.round(right));
771
+
772
+ while (b !== 0) {
773
+ const next = b;
774
+ b = a % b;
775
+ a = next;
776
+ }
777
+
778
+ return a || 1;
779
+ }
780
+
781
+ function getCanvasAspectRatioDefaultValue(size: ToolcraftCanvasSize): {
782
+ height: number;
783
+ mode: "custom" | "preset";
784
+ value: string;
785
+ width: number;
786
+ } {
787
+ const divisor = getGreatestCommonDivisor(size.width, size.height);
788
+ const width = Math.max(1, Math.round(size.width / divisor));
789
+ const height = Math.max(1, Math.round(size.height / divisor));
790
+ const value = `${width}:${height}`;
791
+ const preset =
792
+ getToolcraftCanvasAspectRatioPreset(value) ??
793
+ getToolcraftCanvasAspectRatioPresetBySize(size);
794
+
795
+ return {
796
+ height: preset?.ratioHeight ?? height,
797
+ mode: preset ? "preset" : "custom",
798
+ value: preset?.value ?? value,
799
+ width: preset?.ratioWidth ?? width,
800
+ };
801
+ }
802
+
702
803
  function createCanvasSizeSection({
804
+ aspectRatioControl,
805
+ renderScaleControl,
703
806
  sizeControlIds,
704
807
  sizeControls,
705
808
  }: {
809
+ aspectRatioControl: ToolcraftControlSchema;
810
+ renderScaleControl?: ToolcraftControlSchema;
706
811
  sizeControlIds: readonly string[];
707
812
  sizeControls: ToolcraftControlSectionSchema["controls"];
708
813
  }): ToolcraftControlSectionSchema {
709
814
  return {
710
- controls: sizeControls,
815
+ controls: {
816
+ canvasAspectRatio: aspectRatioControl,
817
+ ...sizeControls,
818
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
819
+ },
711
820
  layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
712
821
  title: runtimeSetupSectionTitle,
713
822
  };
714
823
  }
715
824
 
825
+ function createCanvasRenderScaleSection(
826
+ renderScaleControl: ToolcraftControlSchema,
827
+ ): ToolcraftControlSectionSchema {
828
+ return {
829
+ controls: {
830
+ canvasRenderScale: renderScaleControl,
831
+ },
832
+ title: runtimeSetupSectionTitle,
833
+ };
834
+ }
835
+
716
836
  function mergeCanvasSizeControlsIntoSettingsTransferSection({
837
+ aspectRatioControl,
838
+ renderScaleControl,
717
839
  settingsTransferSection,
718
840
  sizeControlIds,
719
841
  sizeControls,
720
842
  }: {
843
+ aspectRatioControl: ToolcraftControlSchema;
844
+ renderScaleControl?: ToolcraftControlSchema;
721
845
  settingsTransferSection: ToolcraftControlSectionSchema;
722
846
  sizeControlIds: readonly string[];
723
847
  sizeControls: ToolcraftControlSectionSchema["controls"];
@@ -728,7 +852,9 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
728
852
  ...settingsTransferSection,
729
853
  controls: {
730
854
  ...settingsTransferSection.controls,
855
+ canvasAspectRatio: aspectRatioControl,
731
856
  ...sizeControls,
857
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
732
858
  },
733
859
  layoutGroups:
734
860
  canvasSizeLayoutGroups.length > 0 || settingsTransferSection.layoutGroups?.length
@@ -740,6 +866,22 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
740
866
  };
741
867
  }
742
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
+
743
885
  function isPrimaryPanelAction(action: ToolcraftControlActionSchema): boolean {
744
886
  return typeof action !== "string" && action.variant !== "outline";
745
887
  }
@@ -1259,14 +1401,46 @@ function normalizePanels({
1259
1401
 
1260
1402
  const controls = { ...panels.controls };
1261
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;
1262
1427
 
1263
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
+
1264
1438
  return {
1265
1439
  ...normalizedPanels,
1266
1440
  controls: normalizeControlsPanelLayout({
1267
1441
  ...controls,
1268
1442
  sections: [
1269
- ...(settingsTransferSection ? [settingsTransferSection] : []),
1443
+ ...(runtimeSetupSection ? [runtimeSetupSection] : []),
1270
1444
  ...controls.sections,
1271
1445
  ],
1272
1446
  }),
@@ -1275,6 +1449,14 @@ function normalizePanels({
1275
1449
 
1276
1450
  const sizeControls: ToolcraftControlSectionSchema["controls"] = {};
1277
1451
  const sizeControlIds: string[] = [];
1452
+ const aspectRatioControl: ToolcraftControlSchema = {
1453
+ defaultValue: getCanvasAspectRatioDefaultValue(canvas.size),
1454
+ label: "Aspect ratio",
1455
+ performanceReason: "Aspect ratio changes output dimensions and renderer workload.",
1456
+ performanceRole: "workload",
1457
+ target: canvasAspectRatioTarget,
1458
+ type: "aspectRatio",
1459
+ };
1278
1460
 
1279
1461
  if (!hasControlTarget(panels, canvasSizeControlTargets.width)) {
1280
1462
  sizeControls.canvasWidth = {
@@ -1300,7 +1482,7 @@ function normalizePanels({
1300
1482
  sizeControlIds.push("canvasHeight");
1301
1483
  }
1302
1484
 
1303
- if (Object.keys(sizeControls).length === 0) {
1485
+ if (Object.keys(sizeControls).length === 0 && !renderScaleControl) {
1304
1486
  return {
1305
1487
  ...normalizedPanels,
1306
1488
  controls: normalizeControlsPanelLayout({
@@ -1315,11 +1497,18 @@ function normalizePanels({
1315
1497
 
1316
1498
  const runtimeSettingsSection = settingsTransferSection
1317
1499
  ? mergeCanvasSizeControlsIntoSettingsTransferSection({
1500
+ aspectRatioControl,
1501
+ renderScaleControl,
1318
1502
  settingsTransferSection,
1319
1503
  sizeControlIds,
1320
1504
  sizeControls,
1321
1505
  })
1322
- : createCanvasSizeSection({ sizeControlIds, sizeControls });
1506
+ : createCanvasSizeSection({
1507
+ aspectRatioControl,
1508
+ renderScaleControl,
1509
+ sizeControlIds,
1510
+ sizeControls,
1511
+ });
1323
1512
 
1324
1513
  return {
1325
1514
  ...normalizedPanels,
@@ -1395,6 +1584,7 @@ function assertPanelPersistenceContract({
1395
1584
  export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAppSchema {
1396
1585
  const canvasEnabled = schema.canvas.enabled;
1397
1586
  const canvasSize = schema.canvas.size;
1587
+ const canvasRenderScale = resolveCanvasRenderScale(schema.canvas.renderScale);
1398
1588
  const canvasSizing = resolveCanvasSizing(schema.canvas);
1399
1589
  const persistence = resolvePersistence(schema.persistence);
1400
1590
  const settingsTransfer = resolveSettingsTransfer({
@@ -1406,6 +1596,7 @@ export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAp
1406
1596
  const canvas = {
1407
1597
  ...schema.canvas,
1408
1598
  draggable: canvasEnabled ? (schema.canvas.draggable ?? true) : false,
1599
+ renderScale: canvasRenderScale,
1409
1600
  size: canvasSize ?? defaultCanvasSize,
1410
1601
  sizeSource: canvasSize ? ("app" as const) : ("runtime-default" as const),
1411
1602
  sizing: canvasSizing,
@@ -51,6 +51,13 @@ describe("getToolcraftControlKeyframeCapability", () => {
51
51
  });
52
52
 
53
53
  it("blocks runtime-owned canvas size targets even when the visual type could otherwise animate", () => {
54
+ expect(
55
+ getToolcraftControlKeyframeCapability(control("slider", "canvas.aspectRatio")),
56
+ ).toEqual({
57
+ capable: false,
58
+ reason: "runtime-owned-target",
59
+ });
60
+
54
61
  expect(
55
62
  getToolcraftControlKeyframeCapability(control("text", "canvas.size.width")),
56
63
  ).toEqual({
@@ -1,4 +1,4 @@
1
- import { getToolcraftCanvasSizeTargetDimension } from "./runtime-targets";
1
+ import { isToolcraftRuntimeOwnedTarget } from "./runtime-targets";
2
2
  import type { ToolcraftControlSchema } from "./types";
3
3
 
4
4
  export type ToolcraftControlKeyframeCapabilityReason =
@@ -30,7 +30,7 @@ const keyframeCapableControlTypes = new Set([
30
30
  export function getToolcraftControlKeyframeCapability(
31
31
  control: ToolcraftControlSchema,
32
32
  ): ToolcraftControlKeyframeCapability {
33
- if (getToolcraftCanvasSizeTargetDimension(control.target)) {
33
+ if (isToolcraftRuntimeOwnedTarget(control.target)) {
34
34
  return {
35
35
  capable: false,
36
36
  reason: "runtime-owned-target",
@@ -1,4 +1,6 @@
1
1
  export const toolcraftRuntimeOwnedTargets = [
2
+ "canvas.aspectRatio",
3
+ "canvas.renderScale",
2
4
  "canvas.size.width",
3
5
  "canvas.size.height",
4
6
  ] as const;
@@ -27,6 +29,10 @@ export function getToolcraftCanvasSizeTargetDimension(
27
29
  }
28
30
  }
29
31
 
32
+ export function isToolcraftCanvasAspectRatioTarget(target: string): boolean {
33
+ return target === "canvas.aspectRatio";
34
+ }
35
+
30
36
  export function isToolcraftReservedTarget(
31
37
  target: string,
32
38
  ): target is ToolcraftReservedTarget {
@@ -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));
@@ -54,8 +54,14 @@ describe("createToolcraftState", () => {
54
54
  const state = createToolcraftState(app);
55
55
 
56
56
  expect(state.values).toMatchObject({
57
- "canvas.size.height": 1024,
58
- "canvas.size.width": 1024,
57
+ "canvas.aspectRatio": {
58
+ height: 9,
59
+ mode: "preset",
60
+ value: "16:9",
61
+ width: 16,
62
+ },
63
+ "canvas.size.height": 1080,
64
+ "canvas.size.width": 1920,
59
65
  });
60
66
  });
61
67
 
@@ -69,7 +75,7 @@ describe("createToolcraftState", () => {
69
75
  const state = createToolcraftState(app);
70
76
 
71
77
  expect(state.canvas.size).toEqual(size);
72
- expect(state.canvas.zoom).toBe(70);
78
+ expect(state.canvas.zoom).toBe(100);
73
79
  });
74
80
 
75
81
  it("preserves seeded canvas width", () => {
@@ -103,13 +103,112 @@ 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(768);
107
+ expect(changed.values["canvas.aspectRatio"]).toEqual({
108
+ height: 6,
109
+ mode: "custom",
110
+ value: "5:6",
111
+ width: 5,
112
+ });
106
113
  expect(changed.values["canvas.size.width"]).toBe(640);
114
+ expect(changed.values["canvas.size.height"]).toBe(768);
107
115
  expect(changed.history.undo.at(-1)?.label).toBe("canvas.size.width");
108
116
  expect(reset.canvas.size.width).toBe(1200);
117
+ expect(reset.canvas.size.height).toBe(768);
109
118
  expect(reset.values["canvas.size.width"]).toBe(1200);
119
+ expect(reset.values["canvas.size.height"]).toBe(768);
110
120
  expect(reset.history.undo.at(-1)?.label).toBe("Reset controls");
111
121
  expect(undone.canvas.size.width).toBe(1200);
122
+ expect(undone.canvas.size.height).toBe(768);
112
123
  expect(redone.canvas.size.width).toBe(640);
124
+ expect(redone.canvas.size.height).toBe(768);
125
+ });
126
+
127
+ it("routes canvas aspect ratio presets through canvas runtime state", () => {
128
+ const state = createState();
129
+
130
+ const changed = toolcraftReducer(state, {
131
+ target: "canvas.aspectRatio",
132
+ type: "controls.setValue",
133
+ value: {
134
+ height: 9,
135
+ mode: "preset",
136
+ value: "16:9",
137
+ width: 16,
138
+ },
139
+ });
140
+
141
+ expect(changed.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
142
+ expect(changed.values["canvas.aspectRatio"]).toEqual({
143
+ height: 9,
144
+ mode: "preset",
145
+ value: "16:9",
146
+ width: 16,
147
+ });
148
+ expect(changed.values["canvas.size.width"]).toBe(1920);
149
+ expect(changed.values["canvas.size.height"]).toBe(1080);
150
+ expect(changed.history.undo.at(-1)?.label).toBe("canvas.aspectRatio");
151
+ });
152
+
153
+ it("turns manual canvas size edits into a custom aspect ratio", () => {
154
+ const state = toolcraftReducer(createState(), {
155
+ target: "canvas.aspectRatio",
156
+ type: "controls.setValue",
157
+ value: {
158
+ height: 9,
159
+ mode: "preset",
160
+ value: "16:9",
161
+ width: 16,
162
+ },
163
+ });
164
+
165
+ const changed = toolcraftReducer(state, {
166
+ target: "canvas.size.height",
167
+ type: "controls.setValue",
168
+ value: "720",
169
+ });
170
+
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
+ });
178
+ expect(changed.values["canvas.size.height"]).toBe(720);
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([]);
113
212
  });
114
213
 
115
214
  it("updates canvas offset without recording history", () => {
@@ -170,11 +269,11 @@ describe("toolcraftReducer", () => {
170
269
  state = toolcraftReducer(state, { type: "canvas.zoomOut" });
171
270
  state = toolcraftReducer(state, { type: "canvas.zoomIn" });
172
271
 
173
- expect(state.canvas.zoom).toBe(60);
272
+ expect(state.canvas.zoom).toBe(90);
174
273
 
175
274
  state = toolcraftReducer(state, { type: "canvas.zoomReset" });
176
275
 
177
- expect(state.canvas.zoom).toBe(70);
276
+ expect(state.canvas.zoom).toBe(100);
178
277
  });
179
278
 
180
279
  it("sets viewport zoom and offset for gesture zoom", () => {
@@ -325,6 +424,40 @@ describe("toolcraftReducer", () => {
325
424
  });
326
425
  });
327
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
+
328
461
  it("adds and selects runtime layers and groups", () => {
329
462
  const withGroup = toolcraftReducer(createState(), {
330
463
  layer: {