@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.
- package/package.json +1 -1
- package/scripts/prepare-pack.mjs +5 -0
- package/src/generate.mjs +13 -0
- package/src/generate.test.mjs +6 -0
- package/templates/runtime/contracts/component-contracts.test.ts +59 -2
- package/templates/runtime/contracts/component-contracts.ts +23 -2
- package/templates/runtime/contracts/decision-contracts.ts +1 -1
- package/templates/runtime/react/canvas-shell.test.tsx +7 -7
- package/templates/runtime/react/controls-panel.test.tsx +269 -0
- package/templates/runtime/react/controls-panel.tsx +133 -24
- package/templates/runtime/react/settings-transfer.test.ts +3 -3
- package/templates/runtime/react/timeline-panel.test.tsx +69 -0
- package/templates/runtime/react/timeline-panel.tsx +98 -10
- package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
- package/templates/runtime/schema/define-toolcraft.test.ts +77 -1
- package/templates/runtime/schema/define-toolcraft.ts +138 -5
- package/templates/runtime/schema/runtime-targets.ts +1 -0
- package/templates/runtime/schema/types.ts +23 -1
- package/templates/runtime/state/canvas-zoom.ts +1 -1
- package/templates/runtime/state/create-template-state.test.ts +6 -6
- package/templates/runtime/state/reducer.test.ts +86 -8
- package/templates/runtime/state/reducer.ts +41 -22
- package/templates/runtime/state/types.ts +1 -0
- package/templates/starter/AGENTS.md +2 -2
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
- package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
- package/templates/starter/docs/toolcraft/component-rules.md +13 -1
- package/templates/starter/docs/toolcraft/performance.md +5 -0
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
- package/templates/starter/gitignore +36 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
- package/templates/starter/src/app/starter-acceptance.ts +67 -1
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
- package/templates/ui/components/controls/slider/slider-value.ts +48 -5
- 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:
|
|
29
|
+
height: 1080,
|
|
30
30
|
unit: "px",
|
|
31
|
-
width:
|
|
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
|
-
...(
|
|
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({
|
|
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,
|
|
@@ -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 =
|
|
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:
|
|
58
|
+
height: 9,
|
|
59
59
|
mode: "preset",
|
|
60
|
-
value: "
|
|
61
|
-
width:
|
|
60
|
+
value: "16:9",
|
|
61
|
+
width: 16,
|
|
62
62
|
},
|
|
63
|
-
"canvas.size.height":
|
|
64
|
-
"canvas.size.width":
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
618
|
+
const hasAspectRatioControl =
|
|
603
619
|
canvasAspectRatioTarget in state.values ||
|
|
604
620
|
canvasAspectRatioTarget in state.defaults;
|
|
605
|
-
const size =
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ??
|
|
1036
|
+
command.asset.layerId ??
|
|
1037
|
+
(shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
|
|
1038
|
+
getNextLayerId(state);
|
|
1022
1039
|
const mediaId =
|
|
1023
|
-
command.asset.id ??
|
|
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,
|
|
@@ -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`,
|
|
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
|
|