@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,937 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "../schema/define-toolcraft";
4
+ import { createToolcraftState } from "./create-template-state";
5
+ import { toolcraftReducer } from "./reducer";
6
+
7
+ function createState() {
8
+ const app = defineToolcraft({
9
+ canvas: {
10
+ enabled: true,
11
+ size: { width: 1024, height: 768, unit: "px" },
12
+ },
13
+ panels: {
14
+ controls: {
15
+ sections: [
16
+ {
17
+ controls: {
18
+ opacity: {
19
+ defaultValue: 75,
20
+ target: "selectedLayer.opacity",
21
+ type: "slider",
22
+ },
23
+ },
24
+ },
25
+ ],
26
+ title: "Controls",
27
+ },
28
+ },
29
+ });
30
+
31
+ return createToolcraftState(app);
32
+ }
33
+
34
+ describe("toolcraftReducer", () => {
35
+ it("resets controls to defaults and records history", () => {
36
+ const changed = toolcraftReducer(createState(), {
37
+ target: "selectedLayer.opacity",
38
+ type: "controls.setValue",
39
+ value: 12,
40
+ });
41
+
42
+ const state = toolcraftReducer(changed, { type: "controls.reset" });
43
+
44
+ expect(state.values["selectedLayer.opacity"]).toBe(75);
45
+ expect(state.history.undo.at(-1)?.label).toBe("Reset controls");
46
+ });
47
+
48
+ it("updates canvas size and records history", () => {
49
+ const size = { width: 1200, height: 900, unit: "px" } as const;
50
+
51
+ const state = toolcraftReducer(createState(), {
52
+ size,
53
+ type: "canvas.setSize",
54
+ });
55
+
56
+ expect(state.canvas.size).toEqual(size);
57
+ expect(state.history.undo.at(-1)?.label).toBe("Resize canvas");
58
+ });
59
+
60
+ it("preserves app-chosen canvas size changes below the app shell minimum", () => {
61
+ const state = toolcraftReducer(createState(), {
62
+ size: { width: 640, height: 900, unit: "px" },
63
+ type: "canvas.setSize",
64
+ });
65
+
66
+ expect(state.canvas.size).toEqual({ width: 640, height: 900, unit: "px" });
67
+ expect(state.history.undo.at(-1)?.after).toEqual({
68
+ "canvas.size": { width: 640, height: 900, unit: "px" },
69
+ });
70
+ });
71
+
72
+ it("routes canvas size control targets through canvas runtime state", () => {
73
+ const app = defineToolcraft({
74
+ canvas: {
75
+ enabled: true,
76
+ size: { width: 1200, height: 768, unit: "px" },
77
+ },
78
+ panels: {
79
+ controls: {
80
+ sections: [
81
+ {
82
+ controls: {
83
+ width: {
84
+ defaultValue: 1200,
85
+ target: "canvas.size.width",
86
+ type: "text",
87
+ },
88
+ },
89
+ },
90
+ ],
91
+ title: "Controls",
92
+ },
93
+ },
94
+ });
95
+
96
+ const changed = toolcraftReducer(createToolcraftState(app), {
97
+ target: "canvas.size.width",
98
+ type: "controls.setValue",
99
+ value: "640",
100
+ });
101
+ const reset = toolcraftReducer(changed, { type: "controls.reset" });
102
+ const undone = toolcraftReducer(changed, { type: "history.undo" });
103
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
104
+
105
+ expect(changed.canvas.size.width).toBe(640);
106
+ expect(changed.values["canvas.size.width"]).toBe(640);
107
+ expect(changed.history.undo.at(-1)?.label).toBe("canvas.size.width");
108
+ expect(reset.canvas.size.width).toBe(1200);
109
+ expect(reset.values["canvas.size.width"]).toBe(1200);
110
+ expect(reset.history.undo.at(-1)?.label).toBe("Reset controls");
111
+ expect(undone.canvas.size.width).toBe(1200);
112
+ expect(redone.canvas.size.width).toBe(640);
113
+ });
114
+
115
+ it("updates canvas offset without recording history", () => {
116
+ const state = createState();
117
+
118
+ const next = toolcraftReducer(state, {
119
+ offset: { x: 12, y: -8 },
120
+ type: "canvas.setOffset",
121
+ });
122
+
123
+ expect(next.canvas.offset).toEqual({ x: 12, y: -8 });
124
+ expect(next.history.undo).toHaveLength(0);
125
+ });
126
+
127
+ it("pans canvas by a delta", () => {
128
+ const moved = toolcraftReducer(createState(), {
129
+ offset: { x: 12, y: -8 },
130
+ type: "canvas.setOffset",
131
+ });
132
+
133
+ const next = toolcraftReducer(moved, {
134
+ delta: { x: 3, y: 10 },
135
+ type: "canvas.panBy",
136
+ });
137
+
138
+ expect(next.canvas.offset).toEqual({ x: 15, y: 2 });
139
+ });
140
+
141
+ it("undoes canvas size changes", () => {
142
+ const changed = toolcraftReducer(createState(), {
143
+ size: { width: 1200, height: 900, unit: "px" },
144
+ type: "canvas.setSize",
145
+ });
146
+
147
+ const state = toolcraftReducer(changed, { type: "history.undo" });
148
+
149
+ expect(state.canvas.size).toEqual({ width: 1024, height: 768, unit: "px" });
150
+ expect(state.values).not.toHaveProperty("canvas.size");
151
+ });
152
+
153
+ it("redoes canvas size changes", () => {
154
+ const changed = toolcraftReducer(createState(), {
155
+ size: { width: 1200, height: 900, unit: "px" },
156
+ type: "canvas.setSize",
157
+ });
158
+
159
+ const undone = toolcraftReducer(changed, { type: "history.undo" });
160
+ const state = toolcraftReducer(undone, { type: "history.redo" });
161
+
162
+ expect(state.canvas.size).toEqual({ width: 1200, height: 900, unit: "px" });
163
+ expect(state.values).not.toHaveProperty("canvas.size");
164
+ });
165
+
166
+ it("zooms in, out, and resets", () => {
167
+ let state = createState();
168
+
169
+ state = toolcraftReducer(state, { type: "canvas.zoomOut" });
170
+ state = toolcraftReducer(state, { type: "canvas.zoomOut" });
171
+ state = toolcraftReducer(state, { type: "canvas.zoomIn" });
172
+
173
+ expect(state.canvas.zoom).toBe(60);
174
+
175
+ state = toolcraftReducer(state, { type: "canvas.zoomReset" });
176
+
177
+ expect(state.canvas.zoom).toBe(70);
178
+ });
179
+
180
+ it("sets viewport zoom and offset for gesture zoom", () => {
181
+ const state = toolcraftReducer(createState(), {
182
+ offset: { x: -100, y: 24 },
183
+ type: "canvas.setViewport",
184
+ zoom: 999,
185
+ });
186
+
187
+ expect(state.canvas.offset).toEqual({ x: -100, y: 24 });
188
+ expect(state.canvas.zoom).toBe(400);
189
+ });
190
+
191
+ it("updates panel offsets without changing control values", () => {
192
+ const state = createState();
193
+
194
+ const next = toolcraftReducer(state, {
195
+ offset: { x: 24, y: -12 },
196
+ panelId: "controls",
197
+ type: "panels.setOffset",
198
+ });
199
+
200
+ expect(next.panels.controls.offset).toEqual({ x: 24, y: -12 });
201
+ expect(next.values).toBe(state.values);
202
+ });
203
+
204
+ it("resets a panel offset to its default position", () => {
205
+ const moved = toolcraftReducer(createState(), {
206
+ offset: { x: 40, y: 80 },
207
+ panelId: "controls",
208
+ type: "panels.setOffset",
209
+ });
210
+
211
+ const next = toolcraftReducer(moved, {
212
+ panelId: "controls",
213
+ type: "panels.resetOffset",
214
+ });
215
+
216
+ expect(next.panels.controls.offset).toEqual({ x: 0, y: 0 });
217
+ });
218
+
219
+ it("imports media as an editable-canvas-sized layer and records history", () => {
220
+ const state = createState();
221
+
222
+ const next = toolcraftReducer(state, {
223
+ asset: {
224
+ dataUrl: "data:image/png;base64,test",
225
+ fileName: "material.png",
226
+ mimeType: "image/png",
227
+ position: { x: 10, y: 20 },
228
+ size: state.canvas.size,
229
+ },
230
+ type: "media.import",
231
+ });
232
+
233
+ expect(next.layers).toEqual([
234
+ {
235
+ displayName: "material",
236
+ id: "layer-1",
237
+ kind: "layer",
238
+ name: "material",
239
+ visible: true,
240
+ },
241
+ ]);
242
+ expect(next.mediaAssets).toEqual([
243
+ {
244
+ dataUrl: "data:image/png;base64,test",
245
+ fileName: "material.png",
246
+ id: "media-1",
247
+ layerId: "layer-1",
248
+ mimeType: "image/png",
249
+ position: { x: 10, y: 20 },
250
+ size: state.canvas.size,
251
+ },
252
+ ]);
253
+ expect(next.selectedLayerId).toBe("layer-1");
254
+ expect(next.history.undo.at(-1)?.label).toBe("Import media");
255
+ });
256
+
257
+ it("deletes imported media without deleting the owning layer", () => {
258
+ const state = toolcraftReducer(createState(), {
259
+ asset: {
260
+ dataUrl: "data:image/png;base64,test",
261
+ fileName: "material.png",
262
+ mimeType: "image/png",
263
+ position: { x: 10, y: 20 },
264
+ size: { height: 512, unit: "px", width: 512 },
265
+ },
266
+ type: "media.import",
267
+ });
268
+
269
+ const next = toolcraftReducer(state, {
270
+ mediaId: "media-1",
271
+ type: "media.delete",
272
+ });
273
+
274
+ expect(next.layers).toEqual(state.layers);
275
+ expect(next.selectedLayerId).toBe("layer-1");
276
+ expect(next.mediaAssets).toEqual([]);
277
+ expect(next.history.undo.at(-1)?.label).toBe("Delete media");
278
+ });
279
+
280
+ it("sizes intrinsic media apps from the imported image and replaces single-layer media", () => {
281
+ const app = defineToolcraft({
282
+ canvas: { enabled: true, upload: true },
283
+ panels: {},
284
+ });
285
+ const state = createToolcraftState(app);
286
+ const first = toolcraftReducer(state, {
287
+ asset: {
288
+ dataUrl: "data:image/png;base64,first",
289
+ fileName: "first.png",
290
+ mimeType: "image/png",
291
+ position: { x: 42, y: 24 },
292
+ size: { height: 600, unit: "px", width: 800 },
293
+ },
294
+ type: "media.import",
295
+ });
296
+ const second = toolcraftReducer(first, {
297
+ asset: {
298
+ dataUrl: "data:image/png;base64,second",
299
+ fileName: "second.png",
300
+ mimeType: "image/png",
301
+ position: { x: -20, y: 10 },
302
+ size: { height: 720, unit: "px", width: 1280 },
303
+ },
304
+ type: "media.import",
305
+ });
306
+
307
+ expect(first.canvas.size).toEqual({ height: 600, unit: "px", width: 800 });
308
+ expect(first.mediaAssets[0]).toMatchObject({
309
+ fileName: "first.png",
310
+ position: { x: 0, y: 0 },
311
+ size: { height: 600, unit: "px", width: 800 },
312
+ });
313
+ expect(first.history.undo.at(-1)?.after).toMatchObject({
314
+ "canvas.size": { height: 600, unit: "px", width: 800 },
315
+ });
316
+ expect(second.canvas.size).toEqual({ height: 720, unit: "px", width: 1280 });
317
+ expect(second.layers).toHaveLength(1);
318
+ expect(second.mediaAssets).toHaveLength(1);
319
+ expect(second.mediaAssets[0]).toMatchObject({
320
+ fileName: "second.png",
321
+ id: "media-1",
322
+ layerId: "layer-1",
323
+ position: { x: 0, y: 0 },
324
+ size: { height: 720, unit: "px", width: 1280 },
325
+ });
326
+ });
327
+
328
+ it("adds and selects runtime layers and groups", () => {
329
+ const withGroup = toolcraftReducer(createState(), {
330
+ layer: {
331
+ displayName: "Scene Group",
332
+ id: "group-1",
333
+ kind: "group",
334
+ name: "scene-group",
335
+ },
336
+ type: "layers.add",
337
+ });
338
+ const state = toolcraftReducer(withGroup, {
339
+ insertIndex: 1,
340
+ layer: {
341
+ displayName: "Layer 1",
342
+ id: "layer-1",
343
+ name: "layer-1",
344
+ parentGroupId: "group-1",
345
+ },
346
+ type: "layers.add",
347
+ });
348
+
349
+ expect(state.layers).toEqual([
350
+ {
351
+ collapsed: false,
352
+ displayName: "Scene Group",
353
+ id: "group-1",
354
+ kind: "group",
355
+ name: "scene-group",
356
+ parentGroupId: undefined,
357
+ visible: true,
358
+ },
359
+ {
360
+ collapsed: undefined,
361
+ displayName: "Layer 1",
362
+ id: "layer-1",
363
+ kind: "layer",
364
+ name: "layer-1",
365
+ parentGroupId: "group-1",
366
+ visible: true,
367
+ },
368
+ ]);
369
+ expect(state.selectedLayerId).toBe("layer-1");
370
+ });
371
+
372
+ it("renames layers and toggles visibility and collapsed groups", () => {
373
+ const withGroup = toolcraftReducer(createState(), {
374
+ layer: { id: "group-1", kind: "group", name: "Scene Group" },
375
+ type: "layers.add",
376
+ });
377
+ const hidden = toolcraftReducer(withGroup, {
378
+ layerId: "group-1",
379
+ type: "layers.toggleVisibility",
380
+ });
381
+ const collapsed = toolcraftReducer(hidden, {
382
+ layerId: "group-1",
383
+ type: "layers.toggleCollapsed",
384
+ });
385
+ const renamed = toolcraftReducer(collapsed, {
386
+ layerId: "group-1",
387
+ name: "Main Scene",
388
+ type: "layers.rename",
389
+ });
390
+
391
+ expect(renamed.layers[0]).toMatchObject({
392
+ collapsed: true,
393
+ displayName: "Main Scene",
394
+ visible: false,
395
+ });
396
+ expect(renamed.history.undo.at(-1)?.label).toBe("Rename layer");
397
+ });
398
+
399
+ it("deletes groups with their children", () => {
400
+ const withGroup = toolcraftReducer(createState(), {
401
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
402
+ type: "layers.add",
403
+ });
404
+ const withChild = toolcraftReducer(withGroup, {
405
+ layer: { id: "layer-1", name: "Layer 1", parentGroupId: "group-1" },
406
+ type: "layers.add",
407
+ });
408
+ const withSibling = toolcraftReducer(withChild, {
409
+ layer: { id: "layer-2", name: "Layer 2" },
410
+ type: "layers.add",
411
+ });
412
+
413
+ const state = toolcraftReducer(withSibling, {
414
+ layerId: "group-1",
415
+ type: "layers.delete",
416
+ });
417
+
418
+ expect(state.layers.map((layer) => layer.id)).toEqual(["layer-2"]);
419
+ expect(state.selectedLayerId).toBe("layer-2");
420
+ });
421
+
422
+ it("deletes nested groups with every descendant layer", () => {
423
+ let state = createState();
424
+
425
+ state = toolcraftReducer(state, {
426
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
427
+ type: "layers.add",
428
+ });
429
+ state = toolcraftReducer(state, {
430
+ layer: { id: "group-2", kind: "group", name: "Group 2", parentGroupId: "group-1" },
431
+ type: "layers.add",
432
+ });
433
+ state = toolcraftReducer(state, {
434
+ layer: { id: "layer-1", name: "Layer 1", parentGroupId: "group-2" },
435
+ type: "layers.add",
436
+ });
437
+ state = toolcraftReducer(state, {
438
+ layer: { id: "layer-2", name: "Layer 2" },
439
+ type: "layers.add",
440
+ });
441
+
442
+ const deleted = toolcraftReducer(state, {
443
+ layerId: "group-1",
444
+ type: "layers.delete",
445
+ });
446
+
447
+ expect(deleted.layers.map((layer) => layer.id)).toEqual(["layer-2"]);
448
+ expect(deleted.selectedLayerId).toBe("layer-2");
449
+ });
450
+
451
+ it("deletes media assets attached to a layer", () => {
452
+ const imported = toolcraftReducer(createState(), {
453
+ asset: {
454
+ dataUrl: "data:image/png;base64,test",
455
+ fileName: "material.png",
456
+ mimeType: "image/png",
457
+ position: { x: 0, y: 0 },
458
+ size: { width: 1024, height: 768, unit: "px" },
459
+ },
460
+ type: "media.import",
461
+ });
462
+
463
+ const state = toolcraftReducer(imported, {
464
+ layerId: "layer-1",
465
+ type: "layers.delete",
466
+ });
467
+
468
+ expect(state.layers).toEqual([]);
469
+ expect(state.mediaAssets).toEqual([]);
470
+ expect(state.selectedLayerId).toBeNull();
471
+ });
472
+
473
+ it("moves layers into groups and back to root", () => {
474
+ const withGroup = toolcraftReducer(createState(), {
475
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
476
+ type: "layers.add",
477
+ });
478
+ const withLayer = toolcraftReducer(withGroup, {
479
+ layer: { id: "layer-1", name: "Layer 1" },
480
+ type: "layers.add",
481
+ });
482
+
483
+ const grouped = toolcraftReducer(withLayer, {
484
+ layerIds: ["layer-1"],
485
+ parentGroupId: "group-1",
486
+ type: "layers.moveToGroup",
487
+ });
488
+ const rooted = toolcraftReducer(grouped, {
489
+ layerIds: ["layer-1"],
490
+ parentGroupId: null,
491
+ type: "layers.moveToGroup",
492
+ });
493
+
494
+ expect(grouped.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBe("group-1");
495
+ expect(grouped.history.undo.at(-1)?.label).toBe("Move layers to group");
496
+ expect(rooted.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBeUndefined();
497
+ expect(rooted.history.undo.at(-1)?.label).toBe("Move layers to root");
498
+ });
499
+
500
+ it("opens collapsed groups when moving layers into them", () => {
501
+ const withGroup = toolcraftReducer(createState(), {
502
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
503
+ type: "layers.add",
504
+ });
505
+ const withCollapsedGroup = toolcraftReducer(withGroup, {
506
+ layerId: "group-1",
507
+ type: "layers.toggleCollapsed",
508
+ });
509
+ const withLayer = toolcraftReducer(withCollapsedGroup, {
510
+ layer: { id: "layer-1", name: "Layer 1" },
511
+ type: "layers.add",
512
+ });
513
+
514
+ const grouped = toolcraftReducer(withLayer, {
515
+ layerIds: ["layer-1"],
516
+ parentGroupId: "group-1",
517
+ type: "layers.moveToGroup",
518
+ });
519
+
520
+ expect(grouped.layers.find((layer) => layer.id === "group-1")?.collapsed).toBe(false);
521
+ expect(grouped.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBe("group-1");
522
+ });
523
+
524
+ it("places moved layers directly under the target group", () => {
525
+ const withFirst = toolcraftReducer(createState(), {
526
+ layer: { id: "layer-1", name: "Layer 1" },
527
+ type: "layers.add",
528
+ });
529
+ const withGroup = toolcraftReducer(withFirst, {
530
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
531
+ type: "layers.add",
532
+ });
533
+ const withExistingChild = toolcraftReducer(withGroup, {
534
+ layer: { id: "layer-2", name: "Layer 2", parentGroupId: "group-1" },
535
+ type: "layers.add",
536
+ });
537
+ const withDraggedLayer = toolcraftReducer(withExistingChild, {
538
+ layer: { id: "layer-3", name: "Layer 3" },
539
+ type: "layers.add",
540
+ });
541
+
542
+ const state = toolcraftReducer(withDraggedLayer, {
543
+ layerIds: ["layer-3"],
544
+ parentGroupId: "group-1",
545
+ type: "layers.moveToGroup",
546
+ });
547
+
548
+ expect(state.layers.map((layer) => layer.id)).toEqual([
549
+ "layer-1",
550
+ "group-1",
551
+ "layer-3",
552
+ "layer-2",
553
+ ]);
554
+ expect(state.layers.find((layer) => layer.id === "layer-3")?.parentGroupId).toBe("group-1");
555
+ });
556
+
557
+ it("does not move groups into their own descendants", () => {
558
+ const withParent = toolcraftReducer(createState(), {
559
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
560
+ type: "layers.add",
561
+ });
562
+ const withChild = toolcraftReducer(withParent, {
563
+ layer: { id: "group-2", kind: "group", name: "Group 2", parentGroupId: "group-1" },
564
+ type: "layers.add",
565
+ });
566
+
567
+ const state = toolcraftReducer(withChild, {
568
+ layerIds: ["group-1"],
569
+ parentGroupId: "group-2",
570
+ type: "layers.moveToGroup",
571
+ });
572
+
573
+ expect(state).toBe(withChild);
574
+ });
575
+
576
+ it("reorders layers and supports undo and redo", () => {
577
+ const withFirst = toolcraftReducer(createState(), {
578
+ layer: { id: "layer-1", name: "Layer 1" },
579
+ type: "layers.add",
580
+ });
581
+ const withSecond = toolcraftReducer(withFirst, {
582
+ layer: { id: "layer-2", name: "Layer 2" },
583
+ type: "layers.add",
584
+ });
585
+ const reordered = toolcraftReducer(withSecond, {
586
+ layers: [withSecond.layers[1]!, withSecond.layers[0]!],
587
+ selectedLayerId: "layer-1",
588
+ type: "layers.reorder",
589
+ });
590
+ const undone = toolcraftReducer(reordered, { type: "history.undo" });
591
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
592
+
593
+ expect(reordered.layers.map((layer) => layer.id)).toEqual(["layer-2", "layer-1"]);
594
+ expect(reordered.selectedLayerId).toBe("layer-1");
595
+ expect(undone.layers.map((layer) => layer.id)).toEqual(["layer-1", "layer-2"]);
596
+ expect(redone.layers.map((layer) => layer.id)).toEqual(["layer-2", "layer-1"]);
597
+ });
598
+
599
+ it("preserves nested move undo and redo", () => {
600
+ let state = createState();
601
+
602
+ state = toolcraftReducer(state, {
603
+ layer: { id: "group-1", kind: "group", name: "Group 1" },
604
+ type: "layers.add",
605
+ });
606
+ state = toolcraftReducer(state, {
607
+ layer: { id: "group-2", kind: "group", name: "Group 2" },
608
+ type: "layers.add",
609
+ });
610
+ state = toolcraftReducer(state, {
611
+ layer: { id: "layer-1", name: "Layer 1", parentGroupId: "group-1" },
612
+ type: "layers.add",
613
+ });
614
+
615
+ const moved = toolcraftReducer(state, {
616
+ layerIds: ["layer-1"],
617
+ parentGroupId: "group-2",
618
+ type: "layers.moveToGroup",
619
+ });
620
+ const undone = toolcraftReducer(moved, { type: "history.undo" });
621
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
622
+
623
+ expect(moved.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBe("group-2");
624
+ expect(undone.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBe("group-1");
625
+ expect(redone.layers.find((layer) => layer.id === "layer-1")?.parentGroupId).toBe("group-2");
626
+ });
627
+
628
+ it("undoes and redoes media imports", () => {
629
+ const imported = toolcraftReducer(createState(), {
630
+ asset: {
631
+ dataUrl: "data:image/png;base64,test",
632
+ fileName: "material.png",
633
+ mimeType: "image/png",
634
+ position: { x: 10, y: 20 },
635
+ size: { width: 1024, height: 768, unit: "px" },
636
+ },
637
+ type: "media.import",
638
+ });
639
+
640
+ const undone = toolcraftReducer(imported, { type: "history.undo" });
641
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
642
+
643
+ expect(undone.layers).toEqual([]);
644
+ expect(undone.mediaAssets).toEqual([]);
645
+ expect(undone.selectedLayerId).toBeNull();
646
+ expect(redone.layers).toHaveLength(1);
647
+ expect(redone.mediaAssets).toHaveLength(1);
648
+ expect(redone.selectedLayerId).toBe("layer-1");
649
+ });
650
+
651
+ it("undoes and redoes value changes", () => {
652
+ const changed = toolcraftReducer(createState(), {
653
+ target: "selectedLayer.opacity",
654
+ type: "controls.setValue",
655
+ value: 12,
656
+ });
657
+
658
+ const undone = toolcraftReducer(changed, { type: "history.undo" });
659
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
660
+
661
+ expect(undone.values["selectedLayer.opacity"]).toBe(75);
662
+ expect(redone.values["selectedLayer.opacity"]).toBe(12);
663
+ });
664
+
665
+ it("merges live control changes from one editor gesture into one undo step", () => {
666
+ const base = createState();
667
+ const first = toolcraftReducer(base, {
668
+ history: "merge",
669
+ historyGroup: "opacity-drag-1",
670
+ target: "selectedLayer.opacity",
671
+ type: "controls.setValue",
672
+ value: 76,
673
+ });
674
+ const second = toolcraftReducer(first, {
675
+ history: "merge",
676
+ historyGroup: "opacity-drag-1",
677
+ target: "selectedLayer.opacity",
678
+ type: "controls.setValue",
679
+ value: 82,
680
+ });
681
+ const third = toolcraftReducer(second, {
682
+ history: "merge",
683
+ historyGroup: "opacity-drag-1",
684
+ target: "selectedLayer.opacity",
685
+ type: "controls.setValue",
686
+ value: 91,
687
+ });
688
+
689
+ expect(third.values["selectedLayer.opacity"]).toBe(91);
690
+ expect(third.history.undo).toHaveLength(1);
691
+ expect(third.history.undo[0]).toMatchObject({
692
+ after: { "selectedLayer.opacity": 91 },
693
+ before: { "selectedLayer.opacity": 75 },
694
+ group: "opacity-drag-1",
695
+ });
696
+
697
+ const undone = toolcraftReducer(third, { type: "history.undo" });
698
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
699
+
700
+ expect(undone.values["selectedLayer.opacity"]).toBe(75);
701
+ expect(redone.values["selectedLayer.opacity"]).toBe(91);
702
+ });
703
+
704
+ it("starts a new undo step for a new live gesture on the same control", () => {
705
+ const firstGesture = toolcraftReducer(createState(), {
706
+ history: "merge",
707
+ historyGroup: "opacity-drag-1",
708
+ target: "selectedLayer.opacity",
709
+ type: "controls.setValue",
710
+ value: 82,
711
+ });
712
+ const secondGesture = toolcraftReducer(firstGesture, {
713
+ history: "merge",
714
+ historyGroup: "opacity-drag-2",
715
+ target: "selectedLayer.opacity",
716
+ type: "controls.setValue",
717
+ value: 64,
718
+ });
719
+
720
+ expect(secondGesture.history.undo).toHaveLength(2);
721
+ expect(secondGesture.history.undo[0]?.before).toEqual({
722
+ "selectedLayer.opacity": 75,
723
+ });
724
+ expect(secondGesture.history.undo[0]?.after).toEqual({
725
+ "selectedLayer.opacity": 82,
726
+ });
727
+ expect(secondGesture.history.undo[1]?.before).toEqual({
728
+ "selectedLayer.opacity": 82,
729
+ });
730
+ expect(secondGesture.history.undo[1]?.after).toEqual({
731
+ "selectedLayer.opacity": 64,
732
+ });
733
+ });
734
+
735
+ it("updates timeline playback state without recording history", () => {
736
+ const paused = toolcraftReducer(createState(), { type: "timeline.togglePlayback" });
737
+ const loopDisabled = toolcraftReducer(paused, { type: "timeline.toggleLoop" });
738
+ const scrubbed = toolcraftReducer(loopDisabled, {
739
+ currentTimeSeconds: 4.25,
740
+ type: "timeline.setCurrentTime",
741
+ });
742
+
743
+ expect(scrubbed.timeline.isPlaying).toBe(false);
744
+ expect(scrubbed.timeline.isLooping).toBe(false);
745
+ expect(scrubbed.timeline.currentTimeSeconds).toBe(4.25);
746
+ expect(scrubbed.history.undo).toHaveLength(0);
747
+ });
748
+
749
+ it("restarts playback from the beginning when play is pressed at the non-looping end", () => {
750
+ const paused = toolcraftReducer(createState(), { type: "timeline.togglePlayback" });
751
+ const loopDisabled = toolcraftReducer(paused, { type: "timeline.toggleLoop" });
752
+ const ended = toolcraftReducer(loopDisabled, {
753
+ currentTimeSeconds: loopDisabled.timeline.durationSeconds,
754
+ type: "timeline.setCurrentTime",
755
+ });
756
+ const replaying = toolcraftReducer(ended, { type: "timeline.togglePlayback" });
757
+
758
+ expect(replaying.timeline.currentTimeSeconds).toBe(0);
759
+ expect(replaying.timeline.isPlaying).toBe(true);
760
+ expect(replaying.history.undo).toHaveLength(0);
761
+ });
762
+
763
+ it("pauses and resets upload-dependent timeline playback when deleting the last media asset", () => {
764
+ const app = defineToolcraft({
765
+ canvas: {
766
+ enabled: true,
767
+ sizing: { mode: "intrinsic-media" },
768
+ upload: true,
769
+ },
770
+ panels: {
771
+ timeline: { mode: "playback" },
772
+ },
773
+ });
774
+ const state = createToolcraftState(app, {
775
+ mediaAssets: [
776
+ {
777
+ dataUrl: "data:image/png;base64,AAAA",
778
+ fileName: "source.png",
779
+ id: "media-1",
780
+ layerId: "layer-1",
781
+ mimeType: "image/png",
782
+ position: { x: 0, y: 0 },
783
+ size: { height: 320, unit: "px", width: 512 },
784
+ },
785
+ ],
786
+ timeline: {
787
+ currentTimeSeconds: 3.5,
788
+ isPlaying: true,
789
+ },
790
+ });
791
+
792
+ const deleted = toolcraftReducer(state, {
793
+ mediaId: "media-1",
794
+ type: "media.delete",
795
+ });
796
+
797
+ expect(deleted.mediaAssets).toEqual([]);
798
+ expect(deleted.timeline.currentTimeSeconds).toBe(0);
799
+ expect(deleted.timeline.isPlaying).toBe(false);
800
+ expect(deleted.history.undo.at(-1)?.after).toMatchObject({
801
+ mediaAssets: [],
802
+ timeline: expect.objectContaining({
803
+ currentTimeSeconds: 0,
804
+ isPlaying: false,
805
+ }),
806
+ });
807
+ });
808
+
809
+ it("stores timeline expanded state without recording history", () => {
810
+ const expanded = toolcraftReducer(createState(), {
811
+ expanded: true,
812
+ type: "timeline.setExpanded",
813
+ });
814
+ const collapsed = toolcraftReducer(expanded, { type: "timeline.toggleExpanded" });
815
+
816
+ expect(expanded.timeline.expanded).toBe(true);
817
+ expect(expanded.history.undo).toHaveLength(0);
818
+ expect(collapsed.timeline.expanded).toBe(false);
819
+ expect(collapsed.history.undo).toHaveLength(0);
820
+ });
821
+
822
+ it("adds, updates, and removes control-owned timeline keyframes", () => {
823
+ const baseState = createState();
824
+ const emptyTimelineState = {
825
+ ...baseState,
826
+ timeline: {
827
+ ...baseState.timeline,
828
+ currentTimeSeconds: 2,
829
+ expanded: false,
830
+ keyframeGroups: [],
831
+ selectedKeyframeId: null,
832
+ },
833
+ };
834
+ const keyed = toolcraftReducer(emptyTimelineState, {
835
+ controlId: "selectedLayer.opacity",
836
+ controlLabel: "Opacity",
837
+ type: "timeline.toggleControlKeyframes",
838
+ value: 75,
839
+ valueLabel: "75%",
840
+ });
841
+
842
+ expect(keyed.timeline.expanded).toBe(true);
843
+ expect(keyed.timeline.selectedKeyframeId).toBe("selectedLayer.opacity::2");
844
+ expect(keyed.timeline.keyframeGroups).toEqual([
845
+ {
846
+ controlId: "selectedLayer.opacity",
847
+ keyframes: [
848
+ {
849
+ controlId: "selectedLayer.opacity",
850
+ controlLabel: "Opacity",
851
+ id: "selectedLayer.opacity::2",
852
+ timeSeconds: 2,
853
+ value: 75,
854
+ valueLabel: "75%",
855
+ },
856
+ ],
857
+ label: "Opacity",
858
+ },
859
+ ]);
860
+ expect(keyed.history.undo.at(-1)?.label).toBe("Add control keyframe");
861
+
862
+ const updated = toolcraftReducer(keyed, {
863
+ controlId: "selectedLayer.opacity",
864
+ controlLabel: "Opacity",
865
+ type: "timeline.upsertControlKeyframe",
866
+ value: 55,
867
+ valueLabel: "55%",
868
+ });
869
+
870
+ expect(updated.timeline.keyframeGroups[0]?.keyframes).toHaveLength(1);
871
+ expect(updated.timeline.keyframeGroups[0]?.keyframes[0]?.valueLabel).toBe("55%");
872
+ expect(updated.timeline.keyframeGroups[0]?.keyframes[0]?.value).toBe(55);
873
+ expect(updated.history.undo.at(-1)?.label).toBe("Set control keyframe");
874
+
875
+ const removed = toolcraftReducer(updated, {
876
+ controlId: "selectedLayer.opacity",
877
+ controlLabel: "Opacity",
878
+ type: "timeline.toggleControlKeyframes",
879
+ value: 55,
880
+ valueLabel: "55%",
881
+ });
882
+
883
+ expect(removed.timeline.keyframeGroups).toEqual([]);
884
+ expect(removed.timeline.selectedKeyframeId).toBeNull();
885
+ expect(removed.history.undo.at(-1)?.label).toBe("Delete control keyframes");
886
+ });
887
+
888
+ it("records timeline keyframe edits in history", () => {
889
+ const baseState = createState();
890
+ const stateWithKeyframe = {
891
+ ...baseState,
892
+ timeline: {
893
+ ...baseState.timeline,
894
+ keyframeGroups: [
895
+ {
896
+ controlId: "opacity",
897
+ keyframes: [
898
+ {
899
+ controlId: "opacity",
900
+ controlLabel: "Opacity",
901
+ id: "opacity-0.75",
902
+ timeSeconds: 0.75,
903
+ valueLabel: "Opacity 20%",
904
+ },
905
+ ],
906
+ label: "Opacity",
907
+ },
908
+ ],
909
+ },
910
+ };
911
+ const moved = toolcraftReducer(stateWithKeyframe, {
912
+ keyframeId: "opacity-0.75",
913
+ timeSeconds: 2,
914
+ type: "timeline.moveKeyframe",
915
+ });
916
+ const undone = toolcraftReducer(moved, { type: "history.undo" });
917
+ const redone = toolcraftReducer(undone, { type: "history.redo" });
918
+
919
+ expect(moved.timeline.selectedKeyframeId).toBe("opacity::2");
920
+ expect(
921
+ moved.timeline.keyframeGroups
922
+ .flatMap((group) => group.keyframes)
923
+ .find((keyframe) => keyframe.id === "opacity::2")?.timeSeconds,
924
+ ).toBe(2);
925
+ expect(moved.history.undo.at(-1)?.label).toBe("Move keyframe");
926
+ expect(
927
+ undone.timeline.keyframeGroups
928
+ .flatMap((group) => group.keyframes)
929
+ .some((keyframe) => keyframe.id === "opacity-0.75"),
930
+ ).toBe(true);
931
+ expect(
932
+ redone.timeline.keyframeGroups
933
+ .flatMap((group) => group.keyframes)
934
+ .some((keyframe) => keyframe.id === "opacity::2"),
935
+ ).toBe(true);
936
+ });
937
+ });