@pixel-point/toolcraft 0.0.4 → 0.0.7
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 +7 -1
- package/templates/runtime/contracts/component-contracts.test.ts +96 -3
- package/templates/runtime/contracts/component-contracts.ts +75 -3
- package/templates/runtime/contracts/decision-contracts.test.ts +3 -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 +455 -1
- package/templates/runtime/react/controls-panel.tsx +515 -30
- 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 +78 -1
- package/templates/runtime/schema/define-toolcraft.ts +145 -6
- package/templates/runtime/schema/runtime-targets.ts +1 -0
- package/templates/runtime/schema/types.ts +46 -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 +139 -8
- package/templates/runtime/state/reducer.ts +88 -22
- package/templates/runtime/state/types.ts +3 -0
- package/templates/starter/AGENTS.md +8 -8
- package/templates/starter/docs/toolcraft/README.md +4 -3
- package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
- package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
- package/templates/starter/docs/toolcraft/component-rules.md +17 -1
- package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
- package/templates/starter/docs/toolcraft/performance.md +8 -7
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
- package/templates/starter/docs/toolcraft/workflow.md +5 -8
- package/templates/starter/gitignore +36 -0
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
- package/templates/starter/src/app/starter-acceptance.ts +67 -1
- package/templates/starter/src/app/starter-performance.test.ts +1 -1
- package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
- package/templates/ui/components/controls/collection-actions/index.ts +4 -0
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
- package/templates/ui/components/controls/index.ts +8 -0
- 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
- package/templates/ui/index.ts +1 -0
|
@@ -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"
|
|
@@ -76,6 +95,7 @@ export type ToolcraftAssemblyCommand =
|
|
|
76
95
|
| "canvas.zoomReset"
|
|
77
96
|
| "controls.apply"
|
|
78
97
|
| "controls.reset"
|
|
98
|
+
| "controls.resetTargets"
|
|
79
99
|
| "controls.setValue"
|
|
80
100
|
| "history.redo"
|
|
81
101
|
| "history.undo"
|
|
@@ -141,6 +161,7 @@ export type ToolcraftAssemblyContract = {
|
|
|
141
161
|
export type ToolcraftCanvasSchema = {
|
|
142
162
|
draggable?: boolean;
|
|
143
163
|
enabled: boolean;
|
|
164
|
+
renderScale?: ToolcraftCanvasRenderScaleSchema;
|
|
144
165
|
size?: ToolcraftCanvasSize;
|
|
145
166
|
sizing?: ToolcraftCanvasSizingSchema;
|
|
146
167
|
upload?: boolean;
|
|
@@ -272,6 +293,20 @@ export type ToolcraftColorOpacityValueSchema = {
|
|
|
272
293
|
opacity?: number;
|
|
273
294
|
};
|
|
274
295
|
|
|
296
|
+
export type ToolcraftCollectionItemControlSchema = {
|
|
297
|
+
commitMode?: "content" | "setting";
|
|
298
|
+
defaultValue?: unknown;
|
|
299
|
+
label?: boolean | string;
|
|
300
|
+
markerCount?: number;
|
|
301
|
+
max?: number;
|
|
302
|
+
min?: number;
|
|
303
|
+
options?: readonly { label: string; value: string }[];
|
|
304
|
+
step?: number;
|
|
305
|
+
type: string;
|
|
306
|
+
unit?: string;
|
|
307
|
+
variant?: string;
|
|
308
|
+
};
|
|
309
|
+
|
|
275
310
|
export type ToolcraftFontPickerValueSchema = {
|
|
276
311
|
color?: string;
|
|
277
312
|
fontId: string;
|
|
@@ -288,22 +323,31 @@ export type ToolcraftCurveInterpolation = "monotone" | "smooth";
|
|
|
288
323
|
export type ToolcraftControlSchema = {
|
|
289
324
|
accept?: string;
|
|
290
325
|
actions?: readonly (ToolcraftActionSchema | string)[];
|
|
326
|
+
addLabel?: string;
|
|
291
327
|
commitMode?: "content" | "setting";
|
|
292
328
|
defaultValue?: unknown;
|
|
293
329
|
description?: string;
|
|
294
330
|
disabled?: boolean;
|
|
295
331
|
disabledWhen?: ToolcraftControlDisabledConditionSchema;
|
|
332
|
+
hardMaxItems?: number;
|
|
296
333
|
interpolation?: ToolcraftCurveInterpolation;
|
|
297
334
|
items?: readonly ToolcraftImagePickerItemSchema[];
|
|
335
|
+
itemControl?: ToolcraftCollectionItemControlSchema;
|
|
336
|
+
itemDefaultValue?: unknown;
|
|
337
|
+
itemLabel?: string;
|
|
298
338
|
keyframeable?: boolean;
|
|
299
339
|
label?: boolean | string;
|
|
300
340
|
markerCount?: number;
|
|
301
341
|
max?: number;
|
|
302
342
|
min?: number;
|
|
343
|
+
minItems?: number;
|
|
344
|
+
multiple?: boolean;
|
|
303
345
|
orderRole?: ToolcraftControlOrderRole;
|
|
304
346
|
performanceReason?: string;
|
|
305
347
|
performanceRole?: ToolcraftControlPerformanceRole;
|
|
306
348
|
options?: readonly { label: string; value: string }[];
|
|
349
|
+
recommendedMaxItems?: number;
|
|
350
|
+
removeLabel?: string;
|
|
307
351
|
step?: number;
|
|
308
352
|
target: string;
|
|
309
353
|
type: string;
|
|
@@ -358,7 +402,8 @@ export type ToolcraftAppSchema = {
|
|
|
358
402
|
|
|
359
403
|
export type ResolvedToolcraftAppSchema = {
|
|
360
404
|
assembly: ToolcraftAssemblyContract;
|
|
361
|
-
canvas: Required<ToolcraftCanvasSchema> & {
|
|
405
|
+
canvas: Omit<Required<ToolcraftCanvasSchema>, "renderScale"> & {
|
|
406
|
+
renderScale: ResolvedToolcraftCanvasRenderScaleSchema;
|
|
362
407
|
size: ToolcraftCanvasSize;
|
|
363
408
|
sizeSource: ToolcraftCanvasSizeSource;
|
|
364
409
|
};
|
|
@@ -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", () => {
|
|
@@ -45,6 +45,59 @@ describe("toolcraftReducer", () => {
|
|
|
45
45
|
expect(state.history.undo.at(-1)?.label).toBe("Reset controls");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("resets selected control targets to defaults and records one history patch", () => {
|
|
49
|
+
const app = defineToolcraft({
|
|
50
|
+
canvas: { enabled: false },
|
|
51
|
+
panels: {
|
|
52
|
+
controls: {
|
|
53
|
+
sections: [
|
|
54
|
+
{
|
|
55
|
+
controls: {
|
|
56
|
+
contrast: {
|
|
57
|
+
defaultValue: 22,
|
|
58
|
+
target: "style.contrast",
|
|
59
|
+
type: "slider",
|
|
60
|
+
},
|
|
61
|
+
opacity: {
|
|
62
|
+
defaultValue: 75,
|
|
63
|
+
target: "selectedLayer.opacity",
|
|
64
|
+
type: "slider",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
title: "Tone",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
title: "Controls",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const changedOpacity = toolcraftReducer(createToolcraftState(app), {
|
|
75
|
+
target: "selectedLayer.opacity",
|
|
76
|
+
type: "controls.setValue",
|
|
77
|
+
value: 12,
|
|
78
|
+
});
|
|
79
|
+
const changedBoth = toolcraftReducer(changedOpacity, {
|
|
80
|
+
target: "style.contrast",
|
|
81
|
+
type: "controls.setValue",
|
|
82
|
+
value: 9,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const state = toolcraftReducer(changedBoth, {
|
|
86
|
+
label: "Reset Tone section",
|
|
87
|
+
targets: ["selectedLayer.opacity"],
|
|
88
|
+
type: "controls.resetTargets",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(state.values["selectedLayer.opacity"]).toBe(75);
|
|
92
|
+
expect(state.values["style.contrast"]).toBe(9);
|
|
93
|
+
expect(state.history.undo).toHaveLength(3);
|
|
94
|
+
expect(state.history.undo.at(-1)).toMatchObject({
|
|
95
|
+
after: { "selectedLayer.opacity": 75 },
|
|
96
|
+
before: { "selectedLayer.opacity": 12 },
|
|
97
|
+
label: "Reset Tone section",
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
48
101
|
it("updates canvas size and records history", () => {
|
|
49
102
|
const size = { width: 1200, height: 900, unit: "px" } as const;
|
|
50
103
|
|
|
@@ -103,9 +156,15 @@ describe("toolcraftReducer", () => {
|
|
|
103
156
|
const redone = toolcraftReducer(undone, { type: "history.redo" });
|
|
104
157
|
|
|
105
158
|
expect(changed.canvas.size.width).toBe(640);
|
|
106
|
-
expect(changed.canvas.size.height).toBe(
|
|
159
|
+
expect(changed.canvas.size.height).toBe(768);
|
|
160
|
+
expect(changed.values["canvas.aspectRatio"]).toEqual({
|
|
161
|
+
height: 6,
|
|
162
|
+
mode: "custom",
|
|
163
|
+
value: "5:6",
|
|
164
|
+
width: 5,
|
|
165
|
+
});
|
|
107
166
|
expect(changed.values["canvas.size.width"]).toBe(640);
|
|
108
|
-
expect(changed.values["canvas.size.height"]).toBe(
|
|
167
|
+
expect(changed.values["canvas.size.height"]).toBe(768);
|
|
109
168
|
expect(changed.history.undo.at(-1)?.label).toBe("canvas.size.width");
|
|
110
169
|
expect(reset.canvas.size.width).toBe(1200);
|
|
111
170
|
expect(reset.canvas.size.height).toBe(768);
|
|
@@ -115,7 +174,7 @@ describe("toolcraftReducer", () => {
|
|
|
115
174
|
expect(undone.canvas.size.width).toBe(1200);
|
|
116
175
|
expect(undone.canvas.size.height).toBe(768);
|
|
117
176
|
expect(redone.canvas.size.width).toBe(640);
|
|
118
|
-
expect(redone.canvas.size.height).toBe(
|
|
177
|
+
expect(redone.canvas.size.height).toBe(768);
|
|
119
178
|
});
|
|
120
179
|
|
|
121
180
|
it("routes canvas aspect ratio presets through canvas runtime state", () => {
|
|
@@ -144,7 +203,7 @@ describe("toolcraftReducer", () => {
|
|
|
144
203
|
expect(changed.history.undo.at(-1)?.label).toBe("canvas.aspectRatio");
|
|
145
204
|
});
|
|
146
205
|
|
|
147
|
-
it("
|
|
206
|
+
it("turns manual canvas size edits into a custom aspect ratio", () => {
|
|
148
207
|
const state = toolcraftReducer(createState(), {
|
|
149
208
|
target: "canvas.aspectRatio",
|
|
150
209
|
type: "controls.setValue",
|
|
@@ -162,9 +221,47 @@ describe("toolcraftReducer", () => {
|
|
|
162
221
|
value: "720",
|
|
163
222
|
});
|
|
164
223
|
|
|
165
|
-
expect(changed.canvas.size).toEqual({ height: 720, unit: "px", width:
|
|
224
|
+
expect(changed.canvas.size).toEqual({ height: 720, unit: "px", width: 1920 });
|
|
225
|
+
expect(changed.values["canvas.aspectRatio"]).toEqual({
|
|
226
|
+
height: 3,
|
|
227
|
+
mode: "custom",
|
|
228
|
+
value: "8:3",
|
|
229
|
+
width: 8,
|
|
230
|
+
});
|
|
166
231
|
expect(changed.values["canvas.size.height"]).toBe(720);
|
|
167
|
-
expect(changed.values["canvas.size.width"]).toBe(
|
|
232
|
+
expect(changed.values["canvas.size.width"]).toBe(1920);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("keeps repeated canvas size values as no-op without changing aspect ratio mode", () => {
|
|
236
|
+
const app = defineToolcraft({
|
|
237
|
+
canvas: {
|
|
238
|
+
enabled: true,
|
|
239
|
+
size: { height: 1080, unit: "px", width: 1920 },
|
|
240
|
+
sizing: { mode: "editable-output" },
|
|
241
|
+
},
|
|
242
|
+
panels: {
|
|
243
|
+
controls: {
|
|
244
|
+
sections: [],
|
|
245
|
+
title: "Controls",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const state = createToolcraftState(app);
|
|
250
|
+
|
|
251
|
+
const unchanged = toolcraftReducer(state, {
|
|
252
|
+
target: "canvas.size.width",
|
|
253
|
+
type: "controls.setValue",
|
|
254
|
+
value: "1920",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(unchanged).toBe(state);
|
|
258
|
+
expect(unchanged.values["canvas.aspectRatio"]).toEqual({
|
|
259
|
+
height: 9,
|
|
260
|
+
mode: "preset",
|
|
261
|
+
value: "16:9",
|
|
262
|
+
width: 16,
|
|
263
|
+
});
|
|
264
|
+
expect(unchanged.history.undo).toEqual([]);
|
|
168
265
|
});
|
|
169
266
|
|
|
170
267
|
it("updates canvas offset without recording history", () => {
|
|
@@ -225,11 +322,11 @@ describe("toolcraftReducer", () => {
|
|
|
225
322
|
state = toolcraftReducer(state, { type: "canvas.zoomOut" });
|
|
226
323
|
state = toolcraftReducer(state, { type: "canvas.zoomIn" });
|
|
227
324
|
|
|
228
|
-
expect(state.canvas.zoom).toBe(
|
|
325
|
+
expect(state.canvas.zoom).toBe(90);
|
|
229
326
|
|
|
230
327
|
state = toolcraftReducer(state, { type: "canvas.zoomReset" });
|
|
231
328
|
|
|
232
|
-
expect(state.canvas.zoom).toBe(
|
|
329
|
+
expect(state.canvas.zoom).toBe(100);
|
|
233
330
|
});
|
|
234
331
|
|
|
235
332
|
it("sets viewport zoom and offset for gesture zoom", () => {
|
|
@@ -380,6 +477,40 @@ describe("toolcraftReducer", () => {
|
|
|
380
477
|
});
|
|
381
478
|
});
|
|
382
479
|
|
|
480
|
+
it("appends single-layer media when import is explicitly non-replacing", () => {
|
|
481
|
+
const state = createState();
|
|
482
|
+
const first = toolcraftReducer(state, {
|
|
483
|
+
asset: {
|
|
484
|
+
dataUrl: "data:image/png;base64,first",
|
|
485
|
+
fileName: "first.png",
|
|
486
|
+
mimeType: "image/png",
|
|
487
|
+
position: { x: 0, y: 0 },
|
|
488
|
+
size: state.canvas.size,
|
|
489
|
+
},
|
|
490
|
+
replaceExisting: false,
|
|
491
|
+
type: "media.import",
|
|
492
|
+
});
|
|
493
|
+
const second = toolcraftReducer(first, {
|
|
494
|
+
asset: {
|
|
495
|
+
dataUrl: "data:image/png;base64,second",
|
|
496
|
+
fileName: "second.png",
|
|
497
|
+
mimeType: "image/png",
|
|
498
|
+
position: { x: 0, y: 0 },
|
|
499
|
+
size: state.canvas.size,
|
|
500
|
+
},
|
|
501
|
+
replaceExisting: false,
|
|
502
|
+
type: "media.import",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(second.layers.map((layer) => layer.id)).toEqual(["layer-1", "layer-2"]);
|
|
506
|
+
expect(second.mediaAssets.map((asset) => asset.id)).toEqual(["media-1", "media-2"]);
|
|
507
|
+
expect(second.mediaAssets.map((asset) => asset.fileName)).toEqual([
|
|
508
|
+
"first.png",
|
|
509
|
+
"second.png",
|
|
510
|
+
]);
|
|
511
|
+
expect(second.selectedLayerId).toBe("layer-2");
|
|
512
|
+
});
|
|
513
|
+
|
|
383
514
|
it("adds and selects runtime layers and groups", () => {
|
|
384
515
|
const withGroup = toolcraftReducer(createState(), {
|
|
385
516
|
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,
|
|
@@ -196,6 +212,17 @@ function getResetCanvasSize(
|
|
|
196
212
|
};
|
|
197
213
|
}
|
|
198
214
|
|
|
215
|
+
function canvasSizesEqual(
|
|
216
|
+
first: ToolcraftState["canvas"]["size"],
|
|
217
|
+
second: ToolcraftState["canvas"]["size"],
|
|
218
|
+
): boolean {
|
|
219
|
+
return (
|
|
220
|
+
first.height === second.height &&
|
|
221
|
+
first.unit === second.unit &&
|
|
222
|
+
first.width === second.width
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
199
226
|
function clampTimelineDuration(value: number): number {
|
|
200
227
|
if (!Number.isFinite(value)) {
|
|
201
228
|
return minTimelineDurationSeconds;
|
|
@@ -567,7 +594,7 @@ export function toolcraftReducer(
|
|
|
567
594
|
if (
|
|
568
595
|
state.canvas.size.width === size.width &&
|
|
569
596
|
state.canvas.size.height === size.height &&
|
|
570
|
-
|
|
597
|
+
canvasAspectRatioValuesEqual(state.values[command.target], ratio)
|
|
571
598
|
) {
|
|
572
599
|
return state;
|
|
573
600
|
}
|
|
@@ -599,44 +626,42 @@ export function toolcraftReducer(
|
|
|
599
626
|
return state;
|
|
600
627
|
}
|
|
601
628
|
|
|
602
|
-
const
|
|
629
|
+
const hasAspectRatioControl =
|
|
603
630
|
canvasAspectRatioTarget in state.values ||
|
|
604
631
|
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
|
-
};
|
|
632
|
+
const size = {
|
|
633
|
+
...state.canvas.size,
|
|
634
|
+
[canvasSizeDimension]: dimensionValue,
|
|
635
|
+
};
|
|
636
|
+
const aspectRatio = getCanvasAspectRatioFromSize(size);
|
|
619
637
|
const targetValue = size[canvasSizeDimension];
|
|
620
638
|
const otherTarget =
|
|
621
639
|
canvasSizeDimension === "width" ? canvasSizeHeightTarget : canvasSizeWidthTarget;
|
|
622
640
|
const otherValue = canvasSizeDimension === "width" ? size.height : size.width;
|
|
623
641
|
|
|
624
|
-
|
|
642
|
+
const sizeUnchanged =
|
|
625
643
|
state.canvas.size.width === size.width &&
|
|
626
644
|
state.canvas.size.height === size.height &&
|
|
627
645
|
state.values[command.target] === targetValue &&
|
|
628
|
-
state.values[otherTarget] === otherValue
|
|
629
|
-
|
|
646
|
+
state.values[otherTarget] === otherValue;
|
|
647
|
+
|
|
648
|
+
if (sizeUnchanged) {
|
|
630
649
|
return state;
|
|
631
650
|
}
|
|
632
651
|
|
|
633
652
|
return commitStatePatch(state, {
|
|
634
653
|
after: {
|
|
654
|
+
...(hasAspectRatioControl
|
|
655
|
+
? { [canvasAspectRatioTarget]: aspectRatio }
|
|
656
|
+
: {}),
|
|
635
657
|
"canvas.size": size,
|
|
636
658
|
[command.target]: targetValue,
|
|
637
659
|
[otherTarget]: otherValue,
|
|
638
660
|
},
|
|
639
661
|
before: {
|
|
662
|
+
...(hasAspectRatioControl
|
|
663
|
+
? { [canvasAspectRatioTarget]: state.values[canvasAspectRatioTarget] }
|
|
664
|
+
: {}),
|
|
640
665
|
"canvas.size": state.canvas.size,
|
|
641
666
|
[command.target]: state.values[command.target],
|
|
642
667
|
[otherTarget]: state.values[otherTarget],
|
|
@@ -698,6 +723,42 @@ export function toolcraftReducer(
|
|
|
698
723
|
);
|
|
699
724
|
}
|
|
700
725
|
|
|
726
|
+
case "controls.resetTargets": {
|
|
727
|
+
const targetSet = new Set(command.targets);
|
|
728
|
+
const before: Record<string, unknown> = {};
|
|
729
|
+
const after: Record<string, unknown> = {};
|
|
730
|
+
|
|
731
|
+
for (const target of targetSet) {
|
|
732
|
+
if (!(target in state.defaults) || Object.is(state.values[target], state.defaults[target])) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
before[target] = state.values[target];
|
|
737
|
+
after[target] = state.defaults[target];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const resetCanvasSize = getResetCanvasSize(state);
|
|
741
|
+
const shouldResetCanvasSize =
|
|
742
|
+
resetCanvasSize !== null &&
|
|
743
|
+
(targetSet.has(canvasSizeWidthTarget) || targetSet.has(canvasSizeHeightTarget)) &&
|
|
744
|
+
!canvasSizesEqual(state.canvas.size, resetCanvasSize);
|
|
745
|
+
|
|
746
|
+
if (shouldResetCanvasSize) {
|
|
747
|
+
before["canvas.size"] = state.canvas.size;
|
|
748
|
+
after["canvas.size"] = resetCanvasSize;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (Object.keys(after).length === 0) {
|
|
752
|
+
return state;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return commitStatePatch(state, {
|
|
756
|
+
after,
|
|
757
|
+
before,
|
|
758
|
+
label: command.label ?? "Reset section",
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
701
762
|
case "layers.add": {
|
|
702
763
|
const layer = createLayer(state, command.layer);
|
|
703
764
|
const insertIndex = clampInsertIndex(state.layers.length, command.insertIndex);
|
|
@@ -1014,13 +1075,18 @@ export function toolcraftReducer(
|
|
|
1014
1075
|
};
|
|
1015
1076
|
|
|
1016
1077
|
case "media.import": {
|
|
1017
|
-
const shouldReplaceSingleLayerMedia =
|
|
1078
|
+
const shouldReplaceSingleLayerMedia =
|
|
1079
|
+
!state.schema.panels.layers && command.replaceExisting !== false;
|
|
1018
1080
|
const shouldResizeCanvas =
|
|
1019
1081
|
state.schema.canvas.sizing.mode === "intrinsic-media";
|
|
1020
1082
|
const layerId =
|
|
1021
|
-
command.asset.layerId ??
|
|
1083
|
+
command.asset.layerId ??
|
|
1084
|
+
(shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
|
|
1085
|
+
getNextLayerId(state);
|
|
1022
1086
|
const mediaId =
|
|
1023
|
-
command.asset.id ??
|
|
1087
|
+
command.asset.id ??
|
|
1088
|
+
(shouldReplaceSingleLayerMedia ? getSingleMediaImportId(state) : undefined) ??
|
|
1089
|
+
getNextMediaId(state);
|
|
1024
1090
|
const layer = {
|
|
1025
1091
|
displayName: command.asset.layerName ?? getImportedLayerName(command.asset.fileName),
|
|
1026
1092
|
id: layerId,
|
|
@@ -11,6 +11,7 @@ export type ToolcraftCommand =
|
|
|
11
11
|
}
|
|
12
12
|
| { type: "controls.apply" }
|
|
13
13
|
| { type: "controls.reset" }
|
|
14
|
+
| { label?: string; targets: string[]; type: "controls.resetTargets" }
|
|
14
15
|
| { insertIndex?: number; layer?: ToolcraftLayerDraft; type: "layers.add" }
|
|
15
16
|
| { layerId: string; type: "layers.delete" }
|
|
16
17
|
| { layerIds: string[]; parentGroupId: string | null; type: "layers.moveToGroup" }
|
|
@@ -39,6 +40,7 @@ export type ToolcraftCommand =
|
|
|
39
40
|
layerId?: string;
|
|
40
41
|
layerName?: string;
|
|
41
42
|
};
|
|
43
|
+
replaceExisting?: boolean;
|
|
42
44
|
type: "media.import";
|
|
43
45
|
}
|
|
44
46
|
| { mediaId: string; type: "media.delete" }
|
|
@@ -81,6 +83,7 @@ export const toolcraftRuntimeCommandTypes = [
|
|
|
81
83
|
"controls.setValue",
|
|
82
84
|
"controls.apply",
|
|
83
85
|
"controls.reset",
|
|
86
|
+
"controls.resetTargets",
|
|
84
87
|
"layers.add",
|
|
85
88
|
"layers.delete",
|
|
86
89
|
"layers.moveToGroup",
|
|
@@ -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.
|
|
@@ -111,7 +111,7 @@ AI must work on this app through the required workflow skills when the environme
|
|
|
111
111
|
- Before editing code from an approved spec, use `writing-plans` to produce a deterministic implementation plan focused on app files, tests, build, and browser verification.
|
|
112
112
|
- Before fixing any broken control, failed test, build failure, visual mismatch, export issue, or runtime regression, use `systematic-debugging` to find the root cause first.
|
|
113
113
|
- When the prompt includes a Figma URL, use Figma MCP/design context before implementation. Read the actual node, layer, component, variable, and asset structure; screenshots are only for final visual QA, not the source of truth.
|
|
114
|
-
- After implementation, use the `browser` workflow or equivalent local browser verification to test the running app, not only typecheck/build output. The
|
|
114
|
+
- After implementation, use the `browser` workflow or equivalent local browser verification to test the running app, not only typecheck/build output. The default browser gate is `pnpm test:browser`; `pnpm test:browser:perf` is reserved for full performance checkpoints.
|
|
115
115
|
- Run `pnpm ai:check` before app generation or major changes.
|
|
116
116
|
- If a required skill is missing and the environment supports skill installation, install it before implementation and restart or refresh the session if the skill list does not update.
|
|
117
117
|
- If skill installation is not available, stop before implementation and tell the user exactly which required skills are missing.
|
|
@@ -139,16 +139,16 @@ Choose the tier by blast radius, not by line count. If uncertain, move one tier
|
|
|
139
139
|
| Tier 0 — docs/copy | Documentation, comments, copy, labels, or titles change without schema targets, values, runtime behavior, renderer output, or layout mechanics. | Targeted docs/typecheck or targeted app test. Browser is not required unless visual text fitting is the risk. |
|
|
140
140
|
| Tier 1 — local control presentation | One control or panel visual state changes: spacing, hover, focus, disabled, marker visibility, label fit, or component variant display. Runtime state shape and product renderer are unchanged. | Targeted unit/component test plus one focused browser check for the affected control or panel. |
|
|
141
141
|
| Tier 2 — schema/product behavior | Controls, sections, defaults, persistence, panel actions, export actions, acceptance rows, or product behavior mapping changes. | `pnpm verify:quick` plus relevant browser acceptance. Run perf only when the changed control affects renderer workload or responsiveness. |
|
|
142
|
-
| Tier 3 — renderer/canvas/runtime feature | Custom renderer, animation loop, canvas sizing, upload/media, timeline, layers, toolbar, export bytes, WebGL/Canvas/SVG output, zoom, radar, history, heavy control behavior changes, or a post-generation iteration that touches renderer workload or viewport stability. | `pnpm verify:quick`, targeted browser acceptance, and
|
|
143
|
-
| Tier 4 — final delivery/template architecture | Fresh generated app completion, folder export, commit-ready delivery, dependency changes, runtime/template/contract/CLI changes, broad refactors, or major post-generation iterations that rewrite renderer, canvas, animation, timeline/keyframes, layers, media, export, or control mapping. | Fresh folders run `pnpm install` once, then `pnpm verify:final
|
|
142
|
+
| Tier 3 — renderer/canvas/runtime feature | Custom renderer, animation loop, canvas sizing, upload/media, timeline, layers, toolbar, export bytes, WebGL/Canvas/SVG output, zoom, radar, history, heavy control behavior changes, or a post-generation iteration that touches renderer workload or viewport stability. | `pnpm verify:quick`, targeted browser acceptance, and targeted performance scenarios only for touched workload/viewport/export paths. |
|
|
143
|
+
| Tier 4 — final delivery/template architecture | Fresh generated app completion, folder export, commit-ready delivery, dependency changes, runtime/template/contract/CLI changes, broad refactors, or major post-generation iterations that rewrite renderer, canvas, animation, timeline/keyframes, layers, media, export, or control mapping. | Fresh folders run `pnpm install` once, then `pnpm verify:final`; add `pnpm verify:perf` only for the first working app version or explicit performance complaints, then start `pnpm dev` to provide the local URL. |
|
|
144
144
|
|
|
145
145
|
Do not rerun `pnpm install` after every edit. Run it after fresh export, dependency changes, lockfile changes, or a missing package error.
|
|
146
146
|
|
|
147
|
-
Do not run the full browser performance suite for Tier 0-2 edits
|
|
147
|
+
Do not run the full browser performance suite for Tier 0-2 edits.
|
|
148
148
|
|
|
149
|
-
Run a full performance checkpoint with `pnpm verify:perf` when the first working version of an app exists,
|
|
149
|
+
Run a full performance checkpoint with `pnpm verify:perf` only when the first working version of an app exists, or when the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance.
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
Feature loops after the first working version do not run the full performance suite by default. Renderer, canvas, animation, export, timeline, layers, `canvas.renderScale`, bug fixes, and performance-sensitive controls still need targeted functional/browser checks first, plus targeted performance scenarios only when they directly exercise the touched workload/viewport/export path. Record any skipped full performance run and reason in the verification note or worklog.
|
|
152
152
|
|
|
153
153
|
## Required Checks
|
|
154
154
|
|
|
@@ -161,7 +161,7 @@ pnpm dev
|
|
|
161
161
|
|
|
162
162
|
Use `pnpm install` before this final gate when the folder is fresh or dependencies changed.
|
|
163
163
|
|
|
164
|
-
`pnpm test` must include `node scripts/check-toolcraft-docs.mjs`, `node scripts/check-toolcraft-integrity.mjs`, and app tests. `pnpm verify:ui` / `pnpm test:browser` must run against the real app UI and product output. `pnpm verify:perf` / `pnpm test:browser:perf` must run the performance browser suite sequentially so budgets are measured without parallel e2e noise.
|
|
164
|
+
`pnpm test` must include `node scripts/check-toolcraft-docs.mjs`, `node scripts/check-toolcraft-integrity.mjs`, and app tests. `pnpm verify:ui` / `pnpm test:browser` must run against the real app UI and product output. `pnpm verify:perf` / `pnpm test:browser:perf` remains available for the two full-performance triggers and must run the performance browser suite sequentially so budgets are measured without parallel e2e noise.
|
|
165
165
|
|
|
166
166
|
Do not stop or kill existing local servers to free a port. `pnpm dev`, `pnpm preview`, and browser verification prefer port `3002`, but automatically move to the next free port when it is busy. Use `TOOLCRAFT_PORT`, `TOOLCRAFT_DEV_PORT`, or `TOOLCRAFT_TEST_PORT` only to change the preferred starting port.
|
|
167
167
|
|
|
@@ -26,15 +26,16 @@ Every implementation pass must choose a verification tier before editing. Use th
|
|
|
26
26
|
| Tier 0 | Docs/copy only | targeted docs/typecheck |
|
|
27
27
|
| Tier 1 | One control or panel visual state | targeted test + focused browser check |
|
|
28
28
|
| Tier 2 | Schema, defaults, persistence, actions, product mapping | `pnpm verify:quick` + relevant browser acceptance |
|
|
29
|
-
| Tier 3 | Renderer, canvas, timeline, layers, upload, export, zoom, heavy controls, or a performance
|
|
30
|
-
| Tier 4 | Final delivery, fresh export, runtime/template/contract changes, broad renderer/product rewrites | `pnpm verify:final` |
|
|
29
|
+
| Tier 3 | Renderer, canvas, timeline, layers, upload, export, zoom, heavy controls, or a touched performance-sensitive path | `pnpm verify:quick` + targeted browser checks, plus targeted perf scenarios only for the touched path |
|
|
30
|
+
| Tier 4 | Final delivery, fresh export, runtime/template/contract changes, broad renderer/product rewrites | `pnpm verify:final`; add `pnpm verify:perf` only for the first working app version or explicit performance complaints |
|
|
31
31
|
|
|
32
|
-
Run `pnpm verify:perf`
|
|
32
|
+
Run the full `pnpm verify:perf` suite only when the first working app version exists, or when the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance.
|
|
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
|
|
|
36
36
|
```bash
|
|
37
37
|
pnpm verify:final
|
|
38
|
+
pnpm verify:perf # first working version or explicit performance complaint only
|
|
38
39
|
pnpm dev
|
|
39
40
|
```
|
|
40
41
|
|
|
@@ -14,9 +14,9 @@ Every visible product entity must prove it works. A control is not accepted beca
|
|
|
14
14
|
- `e2e/app-performance.spec.ts`
|
|
15
15
|
- `e2e/product-observable-helpers.ts`
|
|
16
16
|
|
|
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`
|
|
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 full `pnpm verify:perf` only for the first working app version or an explicit performance complaint.
|
|
18
18
|
|
|
19
|
-
A performance checkpoint is triggered by the first working app version,
|
|
19
|
+
A full performance checkpoint is triggered only by the first working app version, or by a user request to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise investigate poor performance.
|
|
20
20
|
|
|
21
21
|
## Product Readiness
|
|
22
22
|
|
|
@@ -72,6 +72,7 @@ Required parts:
|
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| `anchorGrid` | `anchorGrid.position` |
|
|
74
74
|
| `channelMixer` | `channelMixer.activeChannel`, `channelMixer.values`; only for RGB channel matrix behavior |
|
|
75
|
+
| `collectionActions` | `collectionActions.add`, `collectionActions.remove`, `collectionActions.items` |
|
|
75
76
|
| `colorOpacity` | `colorOpacity.hex`, `colorOpacity.opacity` |
|
|
76
77
|
| `curves` | RGB variant: `curves.activeChannel`, `curves.points`; `variant: "single"`: `curves.points` |
|
|
77
78
|
| `fontPicker` | `fontPicker.fontId`, `fontPicker.fontWeight`, `fontPicker.fontSize`, `fontPicker.letterSpacing`, `fontPicker.lineHeight`, `fontPicker.textCase`, `fontPicker.color`, `fontPicker.opacity` |
|
|
@@ -97,6 +98,7 @@ High-confidence wrong-substitution cases:
|
|
|
97
98
|
- typography without `fontPicker`;
|
|
98
99
|
- sibling typography controls that split case, color, opacity, size, weight, letter spacing, or line height away from `fontPicker`;
|
|
99
100
|
- color plus opacity without `colorOpacity`;
|
|
101
|
+
- repeatable user-editable item sets without `collectionActions` or another justified collection owner;
|
|
100
102
|
- from/to range without `rangeSlider` or `rangeInput`;
|
|
101
103
|
- curve, remap, easing, or response without `curves`;
|
|
102
104
|
- position, direction, focus, or vector without `vector`;
|
|
@@ -146,6 +148,8 @@ Footer action acceptance must not include Reset. Reset is already available in t
|
|
|
146
148
|
|
|
147
149
|
Local `actions` acceptance must click every visible action and prove the nearby entity changed through runtime state or product output. A section-level `Randomize palette` must change palette output, `Normalize weights` must change weights/output, and `Clear selection` must clear only the scoped selection. Do not accept a test that only proves the button rendered.
|
|
148
150
|
|
|
151
|
+
`collectionActions` acceptance must click plus and minus in the real panel, prove the runtime target array length changes, prove `minItems` prevents invalid removal, prove `recommendedMaxItems` is not a hidden hard limit, and prove preview/export consumes the changed item list.
|
|
152
|
+
|
|
149
153
|
PNG export tests must prove runtime background behavior: changing the background color affects preview/export, turning `export.includeBackground` off creates transparent PNG output while live preview, workspace canvas backing, and video keep the background, turning it on includes the current background color in PNG, and exported pixel dimensions are retina size, at least `state.canvas.size * 2`.
|
|
150
154
|
|
|
151
155
|
Invalid final acceptance evidence:
|