@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.
Files changed (49) 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 +7 -1
  5. package/templates/runtime/contracts/component-contracts.test.ts +96 -3
  6. package/templates/runtime/contracts/component-contracts.ts +75 -3
  7. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  8. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  9. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  10. package/templates/runtime/react/controls-panel.test.tsx +455 -1
  11. package/templates/runtime/react/controls-panel.tsx +515 -30
  12. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  13. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  14. package/templates/runtime/react/timeline-panel.tsx +98 -10
  15. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  16. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  17. package/templates/runtime/schema/define-toolcraft.test.ts +78 -1
  18. package/templates/runtime/schema/define-toolcraft.ts +145 -6
  19. package/templates/runtime/schema/runtime-targets.ts +1 -0
  20. package/templates/runtime/schema/types.ts +46 -1
  21. package/templates/runtime/state/canvas-zoom.ts +1 -1
  22. package/templates/runtime/state/create-template-state.test.ts +6 -6
  23. package/templates/runtime/state/reducer.test.ts +139 -8
  24. package/templates/runtime/state/reducer.ts +88 -22
  25. package/templates/runtime/state/types.ts +3 -0
  26. package/templates/starter/AGENTS.md +8 -8
  27. package/templates/starter/docs/toolcraft/README.md +4 -3
  28. package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
  29. package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
  30. package/templates/starter/docs/toolcraft/component-rules.md +17 -1
  31. package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
  32. package/templates/starter/docs/toolcraft/performance.md +8 -7
  33. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  34. package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
  35. package/templates/starter/docs/toolcraft/workflow.md +5 -8
  36. package/templates/starter/gitignore +36 -0
  37. package/templates/starter/package.json +1 -1
  38. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  39. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  40. package/templates/starter/src/app/starter-performance.test.ts +1 -1
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  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 -6
  45. package/templates/ui/components/controls/index.ts +8 -0
  46. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  47. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  48. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
  49. 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 = 70;
4
+ export const toolcraftCanvasZoomDefault = 100;
5
5
 
6
6
  export function clampToolcraftCanvasZoom(zoom: number): number {
7
7
  return Math.min(toolcraftCanvasZoomMax, Math.max(toolcraftCanvasZoomMin, zoom));
@@ -55,13 +55,13 @@ describe("createToolcraftState", () => {
55
55
 
56
56
  expect(state.values).toMatchObject({
57
57
  "canvas.aspectRatio": {
58
- height: 1,
58
+ height: 9,
59
59
  mode: "preset",
60
- value: "1:1",
61
- width: 1,
60
+ value: "16:9",
61
+ width: 16,
62
62
  },
63
- "canvas.size.height": 1024,
64
- "canvas.size.width": 1024,
63
+ "canvas.size.height": 1080,
64
+ "canvas.size.width": 1920,
65
65
  });
66
66
  });
67
67
 
@@ -75,7 +75,7 @@ describe("createToolcraftState", () => {
75
75
  const state = createToolcraftState(app);
76
76
 
77
77
  expect(state.canvas.size).toEqual(size);
78
- expect(state.canvas.zoom).toBe(70);
78
+ expect(state.canvas.zoom).toBe(100);
79
79
  });
80
80
 
81
81
  it("preserves seeded canvas width", () => {
@@ -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(410);
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(410);
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(410);
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("keeps canvas size edits locked to the current aspect ratio", () => {
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: 1280 });
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(1280);
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(60);
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(70);
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
- Object.is(state.values[command.target], ratio)
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 hasAspectRatioLock =
629
+ const hasAspectRatioControl =
603
630
  canvasAspectRatioTarget in state.values ||
604
631
  canvasAspectRatioTarget in state.defaults;
605
- const size = hasAspectRatioLock
606
- ? applyCanvasAspectRatioToSize({
607
- anchor: canvasSizeDimension,
608
- ratio: normalizeCanvasAspectRatioValue(
609
- state.values[canvasAspectRatioTarget],
610
- state.canvas.size,
611
- ),
612
- size: state.canvas.size,
613
- value: dimensionValue,
614
- })
615
- : {
616
- ...state.canvas.size,
617
- [canvasSizeDimension]: dimensionValue,
618
- };
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
- if (
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 = !state.schema.panels.layers;
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 ?? getSingleLayerImportId(state) ?? getNextLayerId(state);
1083
+ command.asset.layerId ??
1084
+ (shouldReplaceSingleLayerMedia ? getSingleLayerImportId(state) : undefined) ??
1085
+ getNextLayerId(state);
1022
1086
  const mediaId =
1023
- command.asset.id ?? getSingleMediaImportId(state) ?? getNextMediaId(state);
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`, and `Canvas height` in that order and renders without a visible section heading.
26
+ 12. Use schema `settingsTransfer: "auto"` for complex apps that need import/export of control settings; never implement settings import/export through `panelActions` or route-local file inputs. After adding, removing, or reorganizing controls, sections, timeline, or layers, recalculate settings-transfer eligibility. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. Visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are owned by `editable-output` canvas sizing, not by settings transfer. Runtime aspect presets apply canonical canvas sizes, with `16:9` equal to `1920x1080`; manual Canvas width/height edits keep the typed dimension, keep the other dimension unchanged, switch Aspect ratio to Custom, and show the reduced current ratio in custom ratio inputs; when no explicit product size is provided, the runtime default canvas size is also `1920x1080`. Non-vector raster, Canvas 2D, WebGL, and WebGPU previews set `canvas.renderScale: true`; the first technical runtime section then appends `Resolution scale` after canvas sizing so backing pixels can increase up to 2x without changing CSS/output size. Performance fixes must preserve the selected render scale and keep canvas preview responsive to sliders/high-frequency controls at that scale; diagnose the bottleneck before reducing quality. Do not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping `canvas.renderScale` below the user's chosen value. When settings transfer and editable-output canvas sizing are both enabled, the first technical `Setup` runtime section contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, `Canvas height`, and, for raster outputs, `Resolution scale` in that order and renders without a visible section heading.
27
27
  13. Product apps expose a required `Background` section directly before export settings. It contains a Switch labeled `Include` and a background color control with `label: false` in one equal-width inline row; PNG export wires those runtime values into the standard export helper while live preview, workspace canvas backing, and video export keep the background. Every app with `Export PNG` exposes `Image Export` with `export.image.format` and `export.image.resolution` as two `select` controls in one compact two-column inline row, and passes the selected resolution to `createToolcraftPngExportCanvas({ resolution })` so 2K/4K/8K change actual PNG dimensions. Animated apps with both PNG and video export place `Image Export` immediately before `Video Export`.
28
28
  14. Keep `docs/toolcraft/agent-worklog.md` current with a decision trail, product decisions, evidence, verification, and risks.
29
29
  15. Prove every visible entity through acceptance, browser, and performance coverage.
@@ -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 automated browser gates are `pnpm test:browser` and `pnpm test:browser:perf`.
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 relevant `pnpm verify:perf` scenarios 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`, then start `pnpm dev` to provide the local URL. |
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 unless a performance checkpoint trigger applies.
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, 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` 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
- 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.
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 checkpoint trigger | `pnpm verify:quick` + targeted browser/perf scenarios |
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` 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 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` whenever a performance checkpoint is triggered.
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, 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 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: