@pixel-point/toolcraft 0.0.8 → 0.0.11

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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -110,7 +110,9 @@ export type ToolcraftAssemblyCommand =
110
110
  | "media.delete"
111
111
  | "media.import"
112
112
  | "media.reorder"
113
+ | "media.transform"
113
114
  | "panels.resetOffset"
115
+ | "panels.setHidden"
114
116
  | "panels.setOffset"
115
117
  | "timeline.changeKeyframeEasing"
116
118
  | "timeline.deleteControlKeyframes"
@@ -180,11 +182,13 @@ export type ToolcraftTimelineMode = "keyframes" | "playback";
180
182
  export type ToolcraftTimelinePanelSchema =
181
183
  | boolean
182
184
  | {
185
+ defaultDurationSeconds?: number;
183
186
  enabled?: boolean;
184
187
  mode?: ToolcraftTimelineMode;
185
188
  };
186
189
 
187
190
  export type ResolvedToolcraftTimelinePanelSchema = {
191
+ defaultDurationSeconds: number;
188
192
  enabled: boolean;
189
193
  mode: ToolcraftTimelineMode;
190
194
  };
@@ -192,6 +196,7 @@ export type ResolvedToolcraftTimelinePanelSchema = {
192
196
  export type ToolcraftPersistableStateSlice =
193
197
  | "canvas"
194
198
  | "layers"
199
+ | "media"
195
200
  | "panels"
196
201
  | "timeline"
197
202
  | "values";
@@ -247,6 +252,7 @@ export type ToolcraftActionSchema = {
247
252
  | "export"
248
253
  | "rotate-ccw"
249
254
  | "shuffle"
255
+ | "upload-simple"
250
256
  | "wand-sparkles";
251
257
  label?: string;
252
258
  value: string;
@@ -276,6 +282,39 @@ export type ToolcraftControlPerformanceRole =
276
282
 
277
283
  export type ToolcraftFileDropAssetKind = "file" | "image";
278
284
 
285
+ export type ToolcraftMediaPositionSchema = {
286
+ x: number;
287
+ y: number;
288
+ };
289
+
290
+ export type ToolcraftMediaTransformSchema = {
291
+ flipHorizontal?: boolean;
292
+ flipVertical?: boolean;
293
+ rotationDeg?: 0 | 90 | 180 | 270;
294
+ };
295
+
296
+ export type ToolcraftDefaultMediaAssetSchema = {
297
+ assetKind?: ToolcraftFileDropAssetKind;
298
+ dataUrl: string;
299
+ fileName: string;
300
+ id?: string;
301
+ layerId?: string;
302
+ layerName?: string;
303
+ mimeType?: string;
304
+ position?: ToolcraftMediaPositionSchema;
305
+ size?: ToolcraftCanvasSize;
306
+ sourceTarget?: string;
307
+ transform?: ToolcraftMediaTransformSchema;
308
+ };
309
+
310
+ export type ToolcraftMediaSchema = {
311
+ defaultAssets?: readonly ToolcraftDefaultMediaAssetSchema[];
312
+ };
313
+
314
+ export type ResolvedToolcraftMediaSchema = {
315
+ defaultAssets: readonly ToolcraftDefaultMediaAssetSchema[];
316
+ };
317
+
279
318
  export type ToolcraftControlConditionSchema = {
280
319
  equals?: unknown;
281
320
  greaterThan?: number;
@@ -323,12 +362,15 @@ export type ToolcraftFontPickerValueSchema = {
323
362
 
324
363
  export type ToolcraftCurveInterpolation = "monotone" | "smooth";
325
364
 
365
+ export type ToolcraftVectorCoordinateMode = "cartesian" | "screen";
366
+
326
367
  export type ToolcraftControlSchema = {
327
368
  accept?: string;
328
369
  actions?: readonly (ToolcraftActionSchema | string)[];
329
370
  assetKind?: ToolcraftFileDropAssetKind;
330
371
  addLabel?: string;
331
372
  commitMode?: "content" | "setting";
373
+ coordinateMode?: ToolcraftVectorCoordinateMode;
332
374
  defaultValue?: unknown;
333
375
  description?: string;
334
376
  disabled?: boolean;
@@ -398,6 +440,7 @@ export type ResolvedToolcraftPanelsSchema = {
398
440
  export type ToolcraftAppSchema = {
399
441
  canvas: ToolcraftCanvasSchema;
400
442
  export?: ToolcraftExportSchema;
443
+ media?: ToolcraftMediaSchema;
401
444
  panels: ToolcraftPanelsSchema;
402
445
  persistence?: ToolcraftPersistenceSchema;
403
446
  settingsTransfer?: ToolcraftSettingsTransferSchema;
@@ -412,6 +455,7 @@ export type ResolvedToolcraftAppSchema = {
412
455
  sizeSource: ToolcraftCanvasSizeSource;
413
456
  };
414
457
  export: ResolvedToolcraftExportSchema;
458
+ media: ResolvedToolcraftMediaSchema;
415
459
  panels: ResolvedToolcraftPanelsSchema;
416
460
  persistence: ResolvedToolcraftPersistenceSchema;
417
461
  settingsTransfer: ResolvedToolcraftSettingsTransferSchema;
@@ -65,6 +65,42 @@ describe("createToolcraftState", () => {
65
65
  });
66
66
  });
67
67
 
68
+ it("keeps runtime timeline presentation out of product values and defaults", () => {
69
+ const app = defineToolcraft({
70
+ canvas: { enabled: true, renderScale: true, sizing: { mode: "editable-output" } },
71
+ panels: {
72
+ controls: {
73
+ sections: [],
74
+ title: "Controls",
75
+ },
76
+ timeline: { mode: "playback" },
77
+ },
78
+ });
79
+
80
+ const state = createToolcraftState(app);
81
+
82
+ expect(state.values).not.toHaveProperty("panels.timeline.extended");
83
+ expect(state.values).not.toHaveProperty("panels.timeline.visible");
84
+ expect(state.defaults).not.toHaveProperty("panels.timeline.extended");
85
+ expect(state.defaults).not.toHaveProperty("panels.timeline.visible");
86
+ expect(state.panels.timeline.extended).toBeUndefined();
87
+ expect(state.panels.timeline.hidden).toBeUndefined();
88
+ });
89
+
90
+ it("initializes timeline duration from the schema loop duration", () => {
91
+ const app = defineToolcraft({
92
+ canvas: { enabled: true },
93
+ panels: {
94
+ timeline: { defaultDurationSeconds: 14, mode: "playback" },
95
+ },
96
+ });
97
+
98
+ const state = createToolcraftState(app);
99
+
100
+ expect(state.timeline.durationSeconds).toBe(14);
101
+ expect(state.timeline.isLooping).toBe(true);
102
+ });
103
+
68
104
  it("initializes canvas state from resolved canvas size", () => {
69
105
  const size = { width: 1200, height: 900, unit: "px" } as const;
70
106
  const app = defineToolcraft({
@@ -122,6 +158,126 @@ describe("createToolcraftState", () => {
122
158
  expect(state.selectedLayerId).toBeNull();
123
159
  });
124
160
 
161
+ it("initializes predefined media files as runtime attachments", () => {
162
+ const app = defineToolcraft({
163
+ canvas: { enabled: true, upload: true },
164
+ media: {
165
+ defaultAssets: [
166
+ {
167
+ dataUrl: "data:image/png;base64,AAAA",
168
+ fileName: "background.png",
169
+ id: "default-background",
170
+ layerId: "default-background-layer",
171
+ mimeType: "image/png",
172
+ size: { height: 1080, unit: "px", width: 1920 },
173
+ sourceTarget: "media.background",
174
+ },
175
+ ],
176
+ },
177
+ panels: {
178
+ controls: {
179
+ sections: [
180
+ {
181
+ controls: {
182
+ background: {
183
+ defaultValue: null,
184
+ target: "media.background",
185
+ type: "fileDrop",
186
+ },
187
+ },
188
+ },
189
+ ],
190
+ title: "Controls",
191
+ },
192
+ },
193
+ });
194
+
195
+ const state = createToolcraftState(app);
196
+
197
+ expect(state.mediaAssets).toEqual([
198
+ expect.objectContaining({
199
+ dataUrl: "data:image/png;base64,AAAA",
200
+ fileName: "background.png",
201
+ id: "default-background",
202
+ layerId: "default-background-layer",
203
+ mimeType: "image/png",
204
+ sourceTarget: "media.background",
205
+ }),
206
+ ]);
207
+ expect(state.layers).toEqual([
208
+ expect.objectContaining({
209
+ id: "default-background-layer",
210
+ name: "background",
211
+ visible: true,
212
+ }),
213
+ ]);
214
+ expect(state.selectedLayerId).toBe("default-background-layer");
215
+ });
216
+
217
+ it("lets persisted media state override schema default media", () => {
218
+ const app = defineToolcraft({
219
+ canvas: { enabled: true, upload: true },
220
+ media: {
221
+ defaultAssets: [
222
+ {
223
+ dataUrl: "data:image/png;base64,AAAA",
224
+ fileName: "background.png",
225
+ sourceTarget: "media.background",
226
+ },
227
+ ],
228
+ },
229
+ panels: {},
230
+ });
231
+
232
+ const state = createToolcraftState(app, { mediaAssets: [] });
233
+
234
+ expect(state.mediaAssets).toEqual([]);
235
+ expect(state.layers).toEqual([]);
236
+ expect(state.selectedLayerId).toBeNull();
237
+ });
238
+
239
+ it("restores default media layer names when persisted media omits layer state", () => {
240
+ const app = defineToolcraft({
241
+ canvas: { enabled: true, upload: true },
242
+ media: {
243
+ defaultAssets: [
244
+ {
245
+ dataUrl: "data:image/png;base64,AAAA",
246
+ fileName: "background.png",
247
+ id: "default-background",
248
+ layerId: "default-background-layer",
249
+ layerName: "Hero source",
250
+ sourceTarget: "media.background",
251
+ },
252
+ ],
253
+ },
254
+ panels: {},
255
+ });
256
+
257
+ const state = createToolcraftState(app, {
258
+ mediaAssets: [
259
+ {
260
+ dataUrl: "data:image/png;base64,AAAA",
261
+ fileName: "background.png",
262
+ id: "default-background",
263
+ layerId: "default-background-layer",
264
+ mimeType: "image/png",
265
+ position: { x: 0, y: 0 },
266
+ sourceTarget: "media.background",
267
+ },
268
+ ],
269
+ });
270
+
271
+ expect(state.layers).toEqual([
272
+ expect.objectContaining({
273
+ displayName: "Hero source",
274
+ id: "default-background-layer",
275
+ name: "Hero source",
276
+ }),
277
+ ]);
278
+ expect(state.selectedLayerId).toBe("default-background-layer");
279
+ });
280
+
125
281
  it("accepts seeded runtime layer state", () => {
126
282
  const app = defineToolcraft({
127
283
  canvas: { enabled: true },
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedToolcraftAppSchema } from "../schema/types";
2
+ import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
2
3
  import type {
3
4
  ToolcraftInitialState,
4
5
  ToolcraftState,
@@ -6,6 +7,12 @@ import type {
6
7
  ToolcraftTimelineState,
7
8
  } from "./types";
8
9
  import { toolcraftCanvasZoomDefault } from "./canvas-zoom";
10
+ import {
11
+ cloneToolcraftLayers,
12
+ cloneToolcraftMediaAssets,
13
+ createToolcraftDefaultMediaState,
14
+ createToolcraftLayersFromMediaAssets,
15
+ } from "./media-defaults";
9
16
  import { getMediaReadyTimelineState } from "./timeline-readiness";
10
17
 
11
18
  function cloneTimelineKeyframeGroups(
@@ -26,12 +33,16 @@ function cloneTimelineKeyframeGroups(
26
33
  }));
27
34
  }
28
35
 
29
- function createDefaultTimelineState(
30
- timeline?: Partial<ToolcraftTimelineState>,
31
- ): ToolcraftTimelineState {
36
+ function createDefaultTimelineState({
37
+ defaultDurationSeconds,
38
+ timeline,
39
+ }: {
40
+ defaultDurationSeconds: number;
41
+ timeline?: Partial<ToolcraftTimelineState>;
42
+ }): ToolcraftTimelineState {
32
43
  return {
33
44
  currentTimeSeconds: 0,
34
- durationSeconds: 8,
45
+ durationSeconds: defaultDurationSeconds,
35
46
  expanded: false,
36
47
  isLooping: true,
37
48
  isPlaying: true,
@@ -46,15 +57,34 @@ export function createToolcraftState(
46
57
  initialState: ToolcraftInitialState = {},
47
58
  ): ToolcraftState {
48
59
  const defaults: Record<string, unknown> = {};
49
- const mediaAssets = initialState.mediaAssets ?? [];
60
+ const defaultMediaState = createToolcraftDefaultMediaState(schema);
61
+ const hasInitialMediaAssets = Object.hasOwn(initialState, "mediaAssets");
62
+ const mediaAssets = hasInitialMediaAssets
63
+ ? cloneToolcraftMediaAssets(initialState.mediaAssets ?? [])
64
+ : cloneToolcraftMediaAssets(defaultMediaState.mediaAssets);
65
+ const layers =
66
+ initialState.layers ??
67
+ (hasInitialMediaAssets
68
+ ? createToolcraftLayersFromMediaAssets(mediaAssets, defaultMediaState.layers)
69
+ : cloneToolcraftLayers(defaultMediaState.layers));
70
+ const selectedLayerId =
71
+ initialState.selectedLayerId ??
72
+ (hasInitialMediaAssets ? (layers[0]?.id ?? null) : defaultMediaState.selectedLayerId);
50
73
  const timeline = getMediaReadyTimelineState(
51
74
  schema,
52
- createDefaultTimelineState(initialState.timeline),
75
+ createDefaultTimelineState({
76
+ defaultDurationSeconds: schema.panels.timeline?.defaultDurationSeconds ?? 8,
77
+ timeline: initialState.timeline,
78
+ }),
53
79
  mediaAssets,
54
80
  );
55
81
 
56
82
  for (const section of schema.panels.controls?.sections ?? []) {
57
83
  for (const control of Object.values(section.controls)) {
84
+ if (isToolcraftTimelinePanelRuntimeTarget(control.target)) {
85
+ continue;
86
+ }
87
+
58
88
  defaults[control.target] = control.defaultValue;
59
89
  }
60
90
  }
@@ -79,7 +109,7 @@ export function createToolcraftState(
79
109
  redo: [],
80
110
  undo: [],
81
111
  },
82
- layers: initialState.layers ?? [],
112
+ layers,
83
113
  mediaAssets,
84
114
  panels: {
85
115
  controls: { ...panels.controls, ...initialState.panels?.controls },
@@ -88,7 +118,7 @@ export function createToolcraftState(
88
118
  toolbar: { ...panels.toolbar, ...initialState.panels?.toolbar },
89
119
  },
90
120
  schema,
91
- selectedLayerId: initialState.selectedLayerId ?? null,
121
+ selectedLayerId,
92
122
  timeline,
93
123
  values: { ...defaults, ...initialState.values },
94
124
  };
@@ -0,0 +1,105 @@
1
+ import type { ResolvedToolcraftAppSchema } from "../schema/types";
2
+ import type { ToolcraftLayer, ToolcraftMediaAsset } from "./types";
3
+
4
+ export type ToolcraftDefaultMediaState = {
5
+ layers: ToolcraftLayer[];
6
+ mediaAssets: ToolcraftMediaAsset[];
7
+ selectedLayerId: string | null;
8
+ };
9
+
10
+ function getDefaultLayerName(fileName: string): string {
11
+ const baseName = fileName.replace(/\.[^.]+$/u, "").trim();
12
+
13
+ return baseName || "Media";
14
+ }
15
+
16
+ function cloneMediaAsset(asset: ToolcraftMediaAsset): ToolcraftMediaAsset {
17
+ return {
18
+ ...asset,
19
+ position: { ...asset.position },
20
+ ...(asset.size ? { size: { ...asset.size } } : {}),
21
+ ...(asset.transform ? { transform: { ...asset.transform } } : {}),
22
+ };
23
+ }
24
+
25
+ function cloneLayer(layer: ToolcraftLayer): ToolcraftLayer {
26
+ return { ...layer };
27
+ }
28
+
29
+ export function cloneToolcraftMediaAssets(
30
+ mediaAssets: readonly ToolcraftMediaAsset[],
31
+ ): ToolcraftMediaAsset[] {
32
+ return mediaAssets.map(cloneMediaAsset);
33
+ }
34
+
35
+ export function cloneToolcraftLayers(layers: readonly ToolcraftLayer[]): ToolcraftLayer[] {
36
+ return layers.map(cloneLayer);
37
+ }
38
+
39
+ export function createToolcraftLayersFromMediaAssets(
40
+ mediaAssets: readonly ToolcraftMediaAsset[],
41
+ defaultLayers: readonly ToolcraftLayer[] = [],
42
+ ): ToolcraftLayer[] {
43
+ const layers: ToolcraftLayer[] = [];
44
+ const defaultLayerById = new Map(defaultLayers.map((layer) => [layer.id, layer]));
45
+ const seenLayerIds = new Set<string>();
46
+
47
+ for (const asset of mediaAssets) {
48
+ if (seenLayerIds.has(asset.layerId)) {
49
+ continue;
50
+ }
51
+
52
+ seenLayerIds.add(asset.layerId);
53
+ layers.push(
54
+ cloneLayer(
55
+ defaultLayerById.get(asset.layerId) ?? {
56
+ displayName: getDefaultLayerName(asset.fileName),
57
+ id: asset.layerId,
58
+ kind: "layer",
59
+ name: getDefaultLayerName(asset.fileName),
60
+ visible: true,
61
+ },
62
+ ),
63
+ );
64
+ }
65
+
66
+ return layers;
67
+ }
68
+
69
+ export function createToolcraftDefaultMediaState(
70
+ schema: ResolvedToolcraftAppSchema,
71
+ ): ToolcraftDefaultMediaState {
72
+ const layers: ToolcraftLayer[] = [];
73
+ const mediaAssets: ToolcraftMediaAsset[] = [];
74
+
75
+ schema.media.defaultAssets.forEach((asset, index) => {
76
+ const layerId = asset.layerId ?? `default-media-layer-${index + 1}`;
77
+ const layerName = asset.layerName ?? getDefaultLayerName(asset.fileName);
78
+
79
+ layers.push({
80
+ displayName: layerName,
81
+ id: layerId,
82
+ kind: "layer",
83
+ name: layerName,
84
+ visible: true,
85
+ });
86
+ mediaAssets.push({
87
+ ...(asset.assetKind ? { assetKind: asset.assetKind } : {}),
88
+ dataUrl: asset.dataUrl,
89
+ fileName: asset.fileName,
90
+ id: asset.id ?? `default-media-${index + 1}`,
91
+ layerId,
92
+ mimeType: asset.mimeType ?? (asset.assetKind === "file" ? "application/octet-stream" : "image/*"),
93
+ position: asset.position ?? { x: 0, y: 0 },
94
+ ...(asset.size ? { size: asset.size } : {}),
95
+ ...(asset.sourceTarget ? { sourceTarget: asset.sourceTarget } : {}),
96
+ ...(asset.transform ? { transform: asset.transform } : {}),
97
+ });
98
+ });
99
+
100
+ return {
101
+ layers,
102
+ mediaAssets,
103
+ selectedLayerId: layers[0]?.id ?? null,
104
+ };
105
+ }
@@ -77,6 +77,7 @@ describe("Toolcraft template state persistence", () => {
77
77
  ],
78
78
  panels: {
79
79
  controls: { offset: { x: 20, y: 10 } },
80
+ timeline: { hidden: true },
80
81
  },
81
82
  selectedLayerId: "layer-1",
82
83
  timeline: {
@@ -125,6 +126,7 @@ describe("Toolcraft template state persistence", () => {
125
126
  ],
126
127
  panels: {
127
128
  controls: { offset: { x: 20, y: 10 } },
129
+ timeline: { hidden: true },
128
130
  },
129
131
  selectedLayerId: "layer-1",
130
132
  timeline: {
@@ -146,10 +148,63 @@ describe("Toolcraft template state persistence", () => {
146
148
  expect(snapshot?.state.values).not.toHaveProperty("legacy.target");
147
149
  });
148
150
 
151
+ it("persists media state when the app opts into media persistence", () => {
152
+ const schema = defineToolcraft({
153
+ canvas: { enabled: true, upload: true },
154
+ media: {
155
+ defaultAssets: [
156
+ {
157
+ dataUrl: "data:image/png;base64,AAAA",
158
+ fileName: "background.png",
159
+ id: "default-background",
160
+ layerId: "default-background-layer",
161
+ mimeType: "image/png",
162
+ sourceTarget: "media.background",
163
+ },
164
+ ],
165
+ },
166
+ panels: {
167
+ controls: {
168
+ sections: [
169
+ {
170
+ controls: {
171
+ background: {
172
+ defaultValue: null,
173
+ target: "media.background",
174
+ type: "fileDrop",
175
+ },
176
+ },
177
+ },
178
+ ],
179
+ title: "Controls",
180
+ },
181
+ },
182
+ persistence: {
183
+ include: ["values", "panels", "media"],
184
+ key: "toolcraft:media-persistence-test:state:v1",
185
+ storage: "localStorage",
186
+ version: 1,
187
+ },
188
+ });
189
+ const state = createToolcraftState(schema, { mediaAssets: [] });
190
+ const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence);
191
+ const parsed = parseToolcraftPersistenceSnapshot(
192
+ schema,
193
+ JSON.stringify(snapshot),
194
+ );
195
+
196
+ expect(snapshot?.state.mediaAssets).toEqual([]);
197
+ expect(parsed?.mediaAssets).toEqual([]);
198
+ expect(createToolcraftState(schema, parsed).mediaAssets).toEqual([]);
199
+ });
200
+
149
201
  it("parses matching versions and ignores stale targets or invalid versions", () => {
150
202
  const schema = createPersistentSchema();
151
203
  const rawValue = JSON.stringify({
152
204
  state: {
205
+ panels: {
206
+ timeline: { hidden: true },
207
+ },
153
208
  values: {
154
209
  "generation.prompt": "Restored prompt",
155
210
  "legacy.target": "remove me",
@@ -160,6 +215,9 @@ describe("Toolcraft template state persistence", () => {
160
215
  });
161
216
 
162
217
  expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({
218
+ panels: {
219
+ timeline: { hidden: true },
220
+ },
163
221
  values: {
164
222
  "generation.prompt": "Restored prompt",
165
223
  "selectedLayer.opacity": 55,