@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,217 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "../schema/define-toolcraft";
4
+ import { createToolcraftState } from "./create-template-state";
5
+ import {
6
+ createToolcraftPersistenceSnapshot,
7
+ mergeToolcraftInitialState,
8
+ parseToolcraftPersistenceSnapshot,
9
+ } from "./persistence";
10
+
11
+ function createPersistentSchema() {
12
+ return defineToolcraft({
13
+ canvas: {
14
+ enabled: true,
15
+ size: { height: 720, unit: "px", width: 1280 },
16
+ upload: true,
17
+ },
18
+ panels: {
19
+ controls: {
20
+ sections: [
21
+ {
22
+ controls: {
23
+ opacity: {
24
+ defaultValue: 75,
25
+ target: "selectedLayer.opacity",
26
+ type: "slider",
27
+ },
28
+ prompt: {
29
+ defaultValue: "Describe the effect",
30
+ target: "generation.prompt",
31
+ type: "text",
32
+ },
33
+ },
34
+ },
35
+ ],
36
+ title: "Controls",
37
+ },
38
+ layers: true,
39
+ timeline: { mode: "keyframes" },
40
+ },
41
+ persistence: {
42
+ include: ["values", "canvas", "panels", "timeline", "layers"],
43
+ key: "toolcraft:persistence-test:state:v1",
44
+ storage: "localStorage",
45
+ version: 1,
46
+ },
47
+ });
48
+ }
49
+
50
+ describe("Toolcraft template state persistence", () => {
51
+ it("serializes only allowed runtime slices and schema control values", () => {
52
+ const schema = createPersistentSchema();
53
+ const state = createToolcraftState(schema, {
54
+ canvas: {
55
+ offset: { x: 12, y: -8 },
56
+ zoom: 82,
57
+ },
58
+ layers: [
59
+ {
60
+ displayName: "Layer 1",
61
+ id: "layer-1",
62
+ kind: "layer",
63
+ name: "layer-1",
64
+ visible: true,
65
+ },
66
+ ],
67
+ mediaAssets: [
68
+ {
69
+ dataUrl: "data:image/png;base64,AAAA",
70
+ fileName: "source.png",
71
+ id: "media-1",
72
+ layerId: "layer-1",
73
+ mimeType: "image/png",
74
+ position: { x: 0, y: 0 },
75
+ size: { height: 64, unit: "px", width: 64 },
76
+ },
77
+ ],
78
+ panels: {
79
+ controls: { offset: { x: 20, y: 10 } },
80
+ },
81
+ selectedLayerId: "layer-1",
82
+ timeline: {
83
+ currentTimeSeconds: 2,
84
+ durationSeconds: 10,
85
+ expanded: true,
86
+ keyframeGroups: [
87
+ {
88
+ controlId: "selectedLayer.opacity",
89
+ keyframes: [
90
+ {
91
+ controlId: "selectedLayer.opacity",
92
+ controlLabel: "Opacity",
93
+ id: "keyframe-1",
94
+ timeSeconds: 2,
95
+ value: 75,
96
+ valueLabel: "75%",
97
+ },
98
+ ],
99
+ label: "Opacity",
100
+ },
101
+ ],
102
+ },
103
+ values: {
104
+ "generation.prompt": "Persisted prompt",
105
+ "legacy.target": "remove me",
106
+ "selectedLayer.opacity": 42,
107
+ },
108
+ });
109
+
110
+ const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence);
111
+
112
+ expect(snapshot).toMatchObject({
113
+ state: {
114
+ canvas: {
115
+ offset: { x: 12, y: -8 },
116
+ size: { height: 720, unit: "px", width: 1280 },
117
+ zoom: 82,
118
+ },
119
+ layers: [
120
+ {
121
+ id: "layer-1",
122
+ name: "layer-1",
123
+ visible: true,
124
+ },
125
+ ],
126
+ panels: {
127
+ controls: { offset: { x: 20, y: 10 } },
128
+ },
129
+ selectedLayerId: "layer-1",
130
+ timeline: {
131
+ currentTimeSeconds: 2,
132
+ durationSeconds: 10,
133
+ expanded: true,
134
+ },
135
+ values: {
136
+ "generation.prompt": "Persisted prompt",
137
+ "selectedLayer.opacity": 42,
138
+ },
139
+ },
140
+ version: 1,
141
+ });
142
+ expect(
143
+ snapshot?.state.timeline?.keyframeGroups?.[0]?.keyframes[0]?.value,
144
+ ).toBe(75);
145
+ expect(snapshot?.state).not.toHaveProperty("mediaAssets");
146
+ expect(snapshot?.state.values).not.toHaveProperty("legacy.target");
147
+ });
148
+
149
+ it("parses matching versions and ignores stale targets or invalid versions", () => {
150
+ const schema = createPersistentSchema();
151
+ const rawValue = JSON.stringify({
152
+ state: {
153
+ values: {
154
+ "generation.prompt": "Restored prompt",
155
+ "legacy.target": "remove me",
156
+ "selectedLayer.opacity": 55,
157
+ },
158
+ },
159
+ version: 1,
160
+ });
161
+
162
+ expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({
163
+ values: {
164
+ "generation.prompt": "Restored prompt",
165
+ "selectedLayer.opacity": 55,
166
+ },
167
+ });
168
+ expect(
169
+ parseToolcraftPersistenceSnapshot(
170
+ schema,
171
+ JSON.stringify({ state: { values: { "generation.prompt": "Old" } }, version: 2 }),
172
+ ),
173
+ ).toBeUndefined();
174
+ expect(parseToolcraftPersistenceSnapshot(schema, "not json")).toBeUndefined();
175
+ });
176
+
177
+ it("lets explicit initial state override persisted state", () => {
178
+ expect(
179
+ mergeToolcraftInitialState(
180
+ {
181
+ canvas: {
182
+ offset: { x: 10, y: 20 },
183
+ zoom: 80,
184
+ },
185
+ panels: {
186
+ controls: { offset: { x: 4, y: 8 } },
187
+ },
188
+ values: {
189
+ "generation.prompt": "Persisted",
190
+ },
191
+ },
192
+ {
193
+ canvas: {
194
+ zoom: 95,
195
+ },
196
+ panels: {
197
+ controls: { collapsed: true },
198
+ },
199
+ values: {
200
+ "generation.prompt": "Explicit",
201
+ },
202
+ },
203
+ ),
204
+ ).toEqual({
205
+ canvas: {
206
+ offset: { x: 10, y: 20 },
207
+ zoom: 95,
208
+ },
209
+ panels: {
210
+ controls: { collapsed: true, offset: { x: 4, y: 8 } },
211
+ },
212
+ values: {
213
+ "generation.prompt": "Explicit",
214
+ },
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,511 @@
1
+ import type {
2
+ ToolcraftLocalStoragePersistenceSchema,
3
+ ResolvedToolcraftAppSchema,
4
+ } from "../schema/types";
5
+ import type {
6
+ ToolcraftCanvasState,
7
+ ToolcraftInitialState,
8
+ ToolcraftLayer,
9
+ ToolcraftPanelId,
10
+ ToolcraftPanelState,
11
+ ToolcraftState,
12
+ ToolcraftTimelineBezierControlPoints,
13
+ ToolcraftTimelineKeyframe,
14
+ ToolcraftTimelineKeyframeEasing,
15
+ ToolcraftTimelineKeyframeGroup,
16
+ ToolcraftTimelineState,
17
+ } from "./types";
18
+
19
+ export type ToolcraftPersistencePayload = {
20
+ state: ToolcraftInitialState;
21
+ version: number;
22
+ };
23
+
24
+ const panelIds = ["controls", "layers", "timeline", "toolbar"] as const satisfies readonly ToolcraftPanelId[];
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function isFiniteNumber(value: unknown): value is number {
31
+ return typeof value === "number" && Number.isFinite(value);
32
+ }
33
+
34
+ function readPoint(value: unknown): { x: number; y: number } | undefined {
35
+ if (!isRecord(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) {
36
+ return undefined;
37
+ }
38
+
39
+ return { x: value.x, y: value.y };
40
+ }
41
+
42
+ function readCanvasSize(value: unknown): ToolcraftCanvasState["size"] | undefined {
43
+ if (
44
+ !isRecord(value) ||
45
+ !isFiniteNumber(value.width) ||
46
+ !isFiniteNumber(value.height) ||
47
+ value.unit !== "px"
48
+ ) {
49
+ return undefined;
50
+ }
51
+
52
+ return { height: value.height, unit: "px", width: value.width };
53
+ }
54
+
55
+ function readCanvas(value: unknown): Partial<ToolcraftCanvasState> | undefined {
56
+ if (!isRecord(value)) {
57
+ return undefined;
58
+ }
59
+
60
+ const canvas: Partial<ToolcraftCanvasState> = {};
61
+ const offset = readPoint(value.offset);
62
+ const size = readCanvasSize(value.size);
63
+
64
+ if (offset) {
65
+ canvas.offset = offset;
66
+ }
67
+
68
+ if (size) {
69
+ canvas.size = size;
70
+ }
71
+
72
+ if (isFiniteNumber(value.zoom)) {
73
+ canvas.zoom = value.zoom;
74
+ }
75
+
76
+ return Object.keys(canvas).length > 0 ? canvas : undefined;
77
+ }
78
+
79
+ function readPanel(value: unknown): Partial<ToolcraftPanelState> | undefined {
80
+ if (!isRecord(value)) {
81
+ return undefined;
82
+ }
83
+
84
+ const panel: Partial<ToolcraftPanelState> = {};
85
+ const offset = readPoint(value.offset);
86
+
87
+ if (offset) {
88
+ panel.offset = offset;
89
+ }
90
+
91
+ if (typeof value.collapsed === "boolean") {
92
+ panel.collapsed = value.collapsed;
93
+ }
94
+
95
+ return Object.keys(panel).length > 0 ? panel : undefined;
96
+ }
97
+
98
+ function readPanels(
99
+ value: unknown,
100
+ ): Partial<Record<ToolcraftPanelId, Partial<ToolcraftPanelState>>> | undefined {
101
+ if (!isRecord(value)) {
102
+ return undefined;
103
+ }
104
+
105
+ const panels: Partial<Record<ToolcraftPanelId, Partial<ToolcraftPanelState>>> = {};
106
+
107
+ for (const panelId of panelIds) {
108
+ const panel = readPanel(value[panelId]);
109
+
110
+ if (panel) {
111
+ panels[panelId] = panel;
112
+ }
113
+ }
114
+
115
+ return Object.keys(panels).length > 0 ? panels : undefined;
116
+ }
117
+
118
+ function readLayer(value: unknown): ToolcraftLayer | undefined {
119
+ if (
120
+ !isRecord(value) ||
121
+ typeof value.id !== "string" ||
122
+ typeof value.name !== "string" ||
123
+ typeof value.visible !== "boolean"
124
+ ) {
125
+ return undefined;
126
+ }
127
+
128
+ const layer: ToolcraftLayer = {
129
+ id: value.id,
130
+ name: value.name,
131
+ visible: value.visible,
132
+ };
133
+
134
+ if (value.kind === "group" || value.kind === "layer") {
135
+ layer.kind = value.kind;
136
+ }
137
+
138
+ if (typeof value.collapsed === "boolean") {
139
+ layer.collapsed = value.collapsed;
140
+ }
141
+
142
+ if (typeof value.displayName === "string") {
143
+ layer.displayName = value.displayName;
144
+ }
145
+
146
+ if (typeof value.parentGroupId === "string") {
147
+ layer.parentGroupId = value.parentGroupId;
148
+ }
149
+
150
+ return layer;
151
+ }
152
+
153
+ function readLayers(value: unknown): ToolcraftLayer[] | undefined {
154
+ if (!Array.isArray(value)) {
155
+ return undefined;
156
+ }
157
+
158
+ return value.flatMap((item) => {
159
+ const layer = readLayer(item);
160
+ return layer ? [layer] : [];
161
+ });
162
+ }
163
+
164
+ function readBezierControlPoints(
165
+ value: unknown,
166
+ ): ToolcraftTimelineBezierControlPoints | undefined {
167
+ if (
168
+ !Array.isArray(value) ||
169
+ value.length !== 4 ||
170
+ !value.every((item) => isFiniteNumber(item))
171
+ ) {
172
+ return undefined;
173
+ }
174
+
175
+ return [
176
+ value[0] as number,
177
+ value[1] as number,
178
+ value[2] as number,
179
+ value[3] as number,
180
+ ];
181
+ }
182
+
183
+ function readKeyframeEasing(value: unknown): ToolcraftTimelineKeyframeEasing | undefined {
184
+ if (!isRecord(value)) {
185
+ return undefined;
186
+ }
187
+
188
+ if (value.type === "step") {
189
+ return { type: "step" };
190
+ }
191
+
192
+ if (value.type !== "bezier") {
193
+ return undefined;
194
+ }
195
+
196
+ const controlPoints = readBezierControlPoints(value.controlPoints);
197
+
198
+ return controlPoints ? { controlPoints, type: "bezier" } : undefined;
199
+ }
200
+
201
+ function readKeyframe(value: unknown): ToolcraftTimelineKeyframe | undefined {
202
+ if (
203
+ !isRecord(value) ||
204
+ typeof value.id !== "string" ||
205
+ typeof value.controlId !== "string" ||
206
+ typeof value.controlLabel !== "string" ||
207
+ typeof value.valueLabel !== "string" ||
208
+ !isFiniteNumber(value.timeSeconds)
209
+ ) {
210
+ return undefined;
211
+ }
212
+
213
+ const keyframe: ToolcraftTimelineKeyframe = {
214
+ controlId: value.controlId,
215
+ controlLabel: value.controlLabel,
216
+ id: value.id,
217
+ timeSeconds: value.timeSeconds,
218
+ valueLabel: value.valueLabel,
219
+ };
220
+ const easing = readKeyframeEasing(value.easing);
221
+
222
+ if ("value" in value) {
223
+ keyframe.value = value.value;
224
+ }
225
+
226
+ if (easing) {
227
+ keyframe.easing = easing;
228
+ }
229
+
230
+ return keyframe;
231
+ }
232
+
233
+ function readKeyframeGroup(value: unknown): ToolcraftTimelineKeyframeGroup | undefined {
234
+ if (
235
+ !isRecord(value) ||
236
+ typeof value.controlId !== "string" ||
237
+ typeof value.label !== "string" ||
238
+ !Array.isArray(value.keyframes)
239
+ ) {
240
+ return undefined;
241
+ }
242
+
243
+ return {
244
+ controlId: value.controlId,
245
+ keyframes: value.keyframes.flatMap((item) => {
246
+ const keyframe = readKeyframe(item);
247
+ return keyframe ? [keyframe] : [];
248
+ }),
249
+ label: value.label,
250
+ };
251
+ }
252
+
253
+ function readTimeline(value: unknown): Partial<ToolcraftTimelineState> | undefined {
254
+ if (!isRecord(value)) {
255
+ return undefined;
256
+ }
257
+
258
+ const timeline: Partial<ToolcraftTimelineState> = {};
259
+
260
+ if (isFiniteNumber(value.currentTimeSeconds)) {
261
+ timeline.currentTimeSeconds = value.currentTimeSeconds;
262
+ }
263
+
264
+ if (isFiniteNumber(value.durationSeconds)) {
265
+ timeline.durationSeconds = value.durationSeconds;
266
+ }
267
+
268
+ if (typeof value.expanded === "boolean") {
269
+ timeline.expanded = value.expanded;
270
+ }
271
+
272
+ if (typeof value.isLooping === "boolean") {
273
+ timeline.isLooping = value.isLooping;
274
+ }
275
+
276
+ if (typeof value.isPlaying === "boolean") {
277
+ timeline.isPlaying = value.isPlaying;
278
+ }
279
+
280
+ if (typeof value.selectedKeyframeId === "string" || value.selectedKeyframeId === null) {
281
+ timeline.selectedKeyframeId = value.selectedKeyframeId;
282
+ }
283
+
284
+ if (Array.isArray(value.keyframeGroups)) {
285
+ timeline.keyframeGroups = value.keyframeGroups.flatMap((item) => {
286
+ const group = readKeyframeGroup(item);
287
+ return group ? [group] : [];
288
+ });
289
+ }
290
+
291
+ return Object.keys(timeline).length > 0 ? timeline : undefined;
292
+ }
293
+
294
+ function getKnownValueTargets(schema: ResolvedToolcraftAppSchema): Set<string> {
295
+ const targets = new Set<string>();
296
+
297
+ for (const section of schema.panels.controls?.sections ?? []) {
298
+ for (const control of Object.values(section.controls)) {
299
+ if (control.type !== "panelActions") {
300
+ targets.add(control.target);
301
+ }
302
+ }
303
+ }
304
+
305
+ return targets;
306
+ }
307
+
308
+ function readValues(
309
+ schema: ResolvedToolcraftAppSchema,
310
+ value: unknown,
311
+ ): Record<string, unknown> | undefined {
312
+ if (!isRecord(value)) {
313
+ return undefined;
314
+ }
315
+
316
+ const targets = getKnownValueTargets(schema);
317
+ const values: Record<string, unknown> = {};
318
+
319
+ for (const target of targets) {
320
+ if (Object.hasOwn(value, target)) {
321
+ values[target] = value[target];
322
+ }
323
+ }
324
+
325
+ return Object.keys(values).length > 0 ? values : undefined;
326
+ }
327
+
328
+ function pickPersistedValues(state: ToolcraftState): Record<string, unknown> | undefined {
329
+ const values: Record<string, unknown> = {};
330
+
331
+ for (const target of Object.keys(state.defaults)) {
332
+ if (Object.hasOwn(state.values, target)) {
333
+ values[target] = state.values[target];
334
+ }
335
+ }
336
+
337
+ return Object.keys(values).length > 0 ? values : undefined;
338
+ }
339
+
340
+ export function createToolcraftPersistenceSnapshot(
341
+ state: ToolcraftState,
342
+ persistence: ResolvedToolcraftAppSchema["persistence"],
343
+ ): ToolcraftPersistencePayload | undefined {
344
+ if (persistence.storage !== "localStorage") {
345
+ return undefined;
346
+ }
347
+
348
+ const included = new Set(persistence.include);
349
+ const initialState: ToolcraftInitialState = {};
350
+
351
+ if (included.has("values")) {
352
+ initialState.values = pickPersistedValues(state);
353
+ }
354
+
355
+ if (included.has("canvas")) {
356
+ initialState.canvas = state.canvas;
357
+ }
358
+
359
+ if (included.has("panels")) {
360
+ initialState.panels = state.panels;
361
+ }
362
+
363
+ if (included.has("timeline")) {
364
+ initialState.timeline = state.timeline;
365
+ }
366
+
367
+ if (included.has("layers")) {
368
+ initialState.layers = state.layers;
369
+ initialState.selectedLayerId = state.selectedLayerId;
370
+ }
371
+
372
+ return {
373
+ state: initialState,
374
+ version: persistence.version,
375
+ };
376
+ }
377
+
378
+ export function parseToolcraftPersistenceSnapshot(
379
+ schema: ResolvedToolcraftAppSchema,
380
+ rawValue: string | null,
381
+ ): ToolcraftInitialState | undefined {
382
+ const persistence = schema.persistence;
383
+
384
+ if (persistence.storage !== "localStorage" || !rawValue) {
385
+ return undefined;
386
+ }
387
+
388
+ let payload: unknown;
389
+
390
+ try {
391
+ payload = JSON.parse(rawValue);
392
+ } catch {
393
+ return undefined;
394
+ }
395
+
396
+ if (!isRecord(payload) || payload.version !== persistence.version || !isRecord(payload.state)) {
397
+ return undefined;
398
+ }
399
+
400
+ const persistedState = payload.state;
401
+ const included = new Set(persistence.include);
402
+ const initialState: ToolcraftInitialState = {};
403
+
404
+ if (included.has("values")) {
405
+ const values = readValues(schema, persistedState.values);
406
+
407
+ if (values) {
408
+ initialState.values = values;
409
+ }
410
+ }
411
+
412
+ if (included.has("canvas")) {
413
+ const canvas = readCanvas(persistedState.canvas);
414
+
415
+ if (canvas) {
416
+ initialState.canvas = canvas;
417
+ }
418
+ }
419
+
420
+ if (included.has("panels")) {
421
+ const panels = readPanels(persistedState.panels);
422
+
423
+ if (panels) {
424
+ initialState.panels = panels;
425
+ }
426
+ }
427
+
428
+ if (included.has("timeline")) {
429
+ const timeline = readTimeline(persistedState.timeline);
430
+
431
+ if (timeline) {
432
+ initialState.timeline = timeline;
433
+ }
434
+ }
435
+
436
+ if (included.has("layers")) {
437
+ const layers = readLayers(persistedState.layers);
438
+
439
+ if (layers) {
440
+ initialState.layers = layers;
441
+
442
+ if (
443
+ typeof persistedState.selectedLayerId === "string" &&
444
+ layers.some((layer) => layer.id === persistedState.selectedLayerId)
445
+ ) {
446
+ initialState.selectedLayerId = persistedState.selectedLayerId;
447
+ } else if (persistedState.selectedLayerId === null) {
448
+ initialState.selectedLayerId = null;
449
+ }
450
+ }
451
+ }
452
+
453
+ return Object.keys(initialState).length > 0 ? initialState : undefined;
454
+ }
455
+
456
+ export function mergeToolcraftInitialState(
457
+ persistedState?: ToolcraftInitialState,
458
+ explicitState?: ToolcraftInitialState,
459
+ ): ToolcraftInitialState {
460
+ const merged: ToolcraftInitialState = {};
461
+
462
+ for (const state of [persistedState, explicitState]) {
463
+ if (!state) {
464
+ continue;
465
+ }
466
+
467
+ if (state.canvas) {
468
+ merged.canvas = { ...merged.canvas, ...state.canvas };
469
+ }
470
+
471
+ if (state.panels) {
472
+ merged.panels = { ...merged.panels };
473
+
474
+ for (const panelId of panelIds) {
475
+ const panel = state.panels[panelId];
476
+
477
+ if (panel) {
478
+ merged.panels[panelId] = { ...merged.panels[panelId], ...panel };
479
+ }
480
+ }
481
+ }
482
+
483
+ if (state.timeline) {
484
+ merged.timeline = { ...merged.timeline, ...state.timeline };
485
+ }
486
+
487
+ if (state.values) {
488
+ merged.values = { ...merged.values, ...state.values };
489
+ }
490
+
491
+ if (state.layers) {
492
+ merged.layers = state.layers;
493
+ }
494
+
495
+ if (Object.hasOwn(state, "mediaAssets")) {
496
+ merged.mediaAssets = state.mediaAssets;
497
+ }
498
+
499
+ if (Object.hasOwn(state, "selectedLayerId")) {
500
+ merged.selectedLayerId = state.selectedLayerId;
501
+ }
502
+ }
503
+
504
+ return merged;
505
+ }
506
+
507
+ export function getToolcraftPersistenceKey(
508
+ persistence: ResolvedToolcraftAppSchema["persistence"],
509
+ ): ToolcraftLocalStoragePersistenceSchema["key"] | undefined {
510
+ return persistence.storage === "localStorage" ? persistence.key : undefined;
511
+ }