@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,81 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import type { ResolvedToolcraftAppSchema } from "../schema/types";
6
+ import { CanvasShell } from "./canvas-shell";
7
+ import {
8
+ ControlsPanel,
9
+ type ToolcraftPanelActionHandler,
10
+ } from "./controls-panel";
11
+ import type { ToolcraftControlRendererMap } from "./control-renderers";
12
+ import { ToolcraftRoot } from "./toolcraft-root";
13
+ import { LayersPanel } from "./layers-panel";
14
+ import { TimelinePanel } from "./timeline-panel";
15
+ import { ToolbarPanel } from "./toolbar-panel";
16
+
17
+ export type ToolcraftAppProps = {
18
+ canvasContent?: React.ReactNode;
19
+ className?: string;
20
+ controlRenderers?: ToolcraftControlRendererMap;
21
+ onPanelAction?: ToolcraftPanelActionHandler;
22
+ renderDefaultCanvasMedia?: boolean;
23
+ schema: ResolvedToolcraftAppSchema;
24
+ style?: React.CSSProperties;
25
+ };
26
+
27
+ const toolcraftMinAppWidthPx = 1024;
28
+
29
+ function cn(...classNames: Array<string | false | null | undefined>): string {
30
+ return classNames.filter(Boolean).join(" ");
31
+ }
32
+
33
+ export function ToolcraftApp({
34
+ canvasContent,
35
+ className,
36
+ controlRenderers,
37
+ onPanelAction,
38
+ renderDefaultCanvasMedia = true,
39
+ schema,
40
+ style,
41
+ }: ToolcraftAppProps): React.JSX.Element {
42
+ const surfaces = schema.assembly.surfaces;
43
+
44
+ return (
45
+ <ToolcraftRoot schema={schema}>
46
+ <div
47
+ className={cn(
48
+ "relative min-h-[640px] w-full overflow-hidden bg-[color:var(--background)]",
49
+ className,
50
+ )}
51
+ data-slot="toolcraft-runtime-app"
52
+ style={{
53
+ ...style,
54
+ minWidth: toolcraftMinAppWidthPx,
55
+ }}
56
+ >
57
+ {surfaces.canvas.enabled ? (
58
+ <CanvasShell renderDefaultMedia={renderDefaultCanvasMedia}>
59
+ {canvasContent}
60
+ </CanvasShell>
61
+ ) : null}
62
+ {surfaces.panels.layers?.enabled ? (
63
+ <LayersPanel panelPlacement="floating" />
64
+ ) : null}
65
+ {surfaces.panels.controls?.enabled ? (
66
+ <ControlsPanel
67
+ controlRenderers={controlRenderers}
68
+ onPanelAction={onPanelAction}
69
+ panelPlacement="floating"
70
+ />
71
+ ) : null}
72
+ {surfaces.panels.timeline?.enabled ? (
73
+ <TimelinePanel panelPlacement="floating" />
74
+ ) : null}
75
+ {surfaces.panels.toolbar.enabled ? (
76
+ <ToolbarPanel panelPlacement="floating" />
77
+ ) : null}
78
+ </div>
79
+ </ToolcraftRoot>
80
+ );
81
+ }
@@ -0,0 +1,347 @@
1
+ import {
2
+ cleanup,
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ waitFor,
7
+ } from "@testing-library/react";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ import { defineToolcraft } from "../schema/define-toolcraft";
11
+ import { ToolcraftRoot } from "./toolcraft-root";
12
+ import { useToolcraft } from "./use-toolcraft";
13
+
14
+ const previousToolcraftNamespace = ["creative", "apps", "kit"].join("-");
15
+
16
+ function Probe() {
17
+ const { dispatch, state } = useToolcraft();
18
+
19
+ return (
20
+ <button
21
+ type="button"
22
+ onClick={() =>
23
+ dispatch({
24
+ target: "selectedLayer.opacity",
25
+ type: "controls.setValue",
26
+ value: 50,
27
+ })
28
+ }
29
+ >
30
+ {String(state.values["selectedLayer.opacity"])}
31
+ </button>
32
+ );
33
+ }
34
+
35
+ function ResetProbe() {
36
+ const { dispatch, state } = useToolcraft();
37
+
38
+ return (
39
+ <div>
40
+ <button
41
+ type="button"
42
+ onClick={() =>
43
+ dispatch({
44
+ target: "selectedLayer.opacity",
45
+ type: "controls.setValue",
46
+ value: 50,
47
+ })
48
+ }
49
+ >
50
+ {String(state.values["selectedLayer.opacity"])}
51
+ </button>
52
+ <button type="button" onClick={() => dispatch({ type: "controls.reset" })}>
53
+ Reset
54
+ </button>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ function PanelPersistenceProbe() {
60
+ const { dispatch, state } = useToolcraft();
61
+
62
+ return (
63
+ <button
64
+ type="button"
65
+ onClick={() =>
66
+ dispatch({
67
+ offset: { x: 72, y: -24 },
68
+ panelId: "controls",
69
+ type: "panels.setOffset",
70
+ })
71
+ }
72
+ >
73
+ {state.panels.controls.offset.x},{state.panels.controls.offset.y}
74
+ </button>
75
+ );
76
+ }
77
+
78
+ function createPersistentSchema() {
79
+ return defineToolcraft({
80
+ canvas: { enabled: true },
81
+ panels: {
82
+ controls: {
83
+ sections: [
84
+ {
85
+ controls: {
86
+ opacity: {
87
+ defaultValue: 75,
88
+ target: "selectedLayer.opacity",
89
+ type: "slider",
90
+ },
91
+ },
92
+ },
93
+ ],
94
+ title: "Controls",
95
+ },
96
+ },
97
+ persistence: {
98
+ include: ["values", "panels"],
99
+ key: "toolcraft:root-test:state:v1",
100
+ storage: "localStorage",
101
+ version: 1,
102
+ },
103
+ });
104
+ }
105
+
106
+ function createHistorySchema(history = true) {
107
+ return defineToolcraft({
108
+ canvas: { enabled: true },
109
+ panels: {
110
+ controls: {
111
+ sections: [
112
+ {
113
+ controls: {
114
+ opacity: {
115
+ defaultValue: 75,
116
+ target: "selectedLayer.opacity",
117
+ type: "slider",
118
+ },
119
+ },
120
+ },
121
+ ],
122
+ title: "Controls",
123
+ },
124
+ },
125
+ toolbar: { history },
126
+ });
127
+ }
128
+
129
+ afterEach(() => {
130
+ cleanup();
131
+ window.localStorage.clear();
132
+ });
133
+
134
+ describe("ToolcraftRoot", () => {
135
+ it("provides editor state and dispatch to children", async () => {
136
+ render(
137
+ <ToolcraftRoot
138
+ schema={defineToolcraft({
139
+ canvas: { enabled: true },
140
+ panels: {
141
+ controls: {
142
+ sections: [
143
+ {
144
+ controls: {
145
+ opacity: {
146
+ defaultValue: 75,
147
+ target: "selectedLayer.opacity",
148
+ type: "slider",
149
+ },
150
+ },
151
+ },
152
+ ],
153
+ title: "Controls",
154
+ },
155
+ },
156
+ })}
157
+ >
158
+ <Probe />
159
+ </ToolcraftRoot>,
160
+ );
161
+
162
+ fireEvent.click(screen.getByRole("button", { name: "75" }));
163
+
164
+ expect(await screen.findByRole("button", { name: "50" })).toBeTruthy();
165
+ });
166
+
167
+ it("restores persisted values before first render", () => {
168
+ const schema = createPersistentSchema();
169
+
170
+ window.localStorage.setItem(
171
+ "toolcraft:root-test:state:v1",
172
+ JSON.stringify({
173
+ state: { values: { "selectedLayer.opacity": 33 } },
174
+ version: 1,
175
+ }),
176
+ );
177
+
178
+ render(
179
+ <ToolcraftRoot schema={schema}>
180
+ <Probe />
181
+ </ToolcraftRoot>,
182
+ );
183
+
184
+ expect(screen.getByRole("button", { name: "33" })).toBeTruthy();
185
+ });
186
+
187
+ it("migrates persisted values from the previous storage namespace", () => {
188
+ const schema = createPersistentSchema();
189
+ const previousStorageKey = `${previousToolcraftNamespace}:root-test:state:v1`;
190
+
191
+ window.localStorage.setItem(
192
+ previousStorageKey,
193
+ JSON.stringify({
194
+ state: { values: { "selectedLayer.opacity": 44 } },
195
+ version: 1,
196
+ }),
197
+ );
198
+
199
+ render(
200
+ <ToolcraftRoot schema={schema}>
201
+ <Probe />
202
+ </ToolcraftRoot>,
203
+ );
204
+
205
+ expect(screen.getByRole("button", { name: "44" })).toBeTruthy();
206
+ expect(window.localStorage.getItem("toolcraft:root-test:state:v1")).toBe(
207
+ JSON.stringify({
208
+ state: { values: { "selectedLayer.opacity": 44 } },
209
+ version: 1,
210
+ }),
211
+ );
212
+ expect(window.localStorage.getItem(previousStorageKey)).toBeNull();
213
+ });
214
+
215
+ it("persists runtime values and reset results through schema policy", async () => {
216
+ const schema = createPersistentSchema();
217
+
218
+ render(
219
+ <ToolcraftRoot schema={schema}>
220
+ <ResetProbe />
221
+ </ToolcraftRoot>,
222
+ );
223
+
224
+ fireEvent.click(screen.getByRole("button", { name: "75" }));
225
+
226
+ await waitFor(() => {
227
+ expect(
228
+ JSON.parse(window.localStorage.getItem("toolcraft:root-test:state:v1") ?? "{}"),
229
+ ).toMatchObject({
230
+ state: { values: { "selectedLayer.opacity": 50 } },
231
+ version: 1,
232
+ });
233
+ });
234
+
235
+ fireEvent.click(screen.getByRole("button", { name: "Reset" }));
236
+
237
+ await waitFor(() => {
238
+ expect(
239
+ JSON.parse(window.localStorage.getItem("toolcraft:root-test:state:v1") ?? "{}"),
240
+ ).toMatchObject({
241
+ state: { values: { "selectedLayer.opacity": 75 } },
242
+ version: 1,
243
+ });
244
+ });
245
+ });
246
+
247
+ it("restores dragged panel offsets from the app persistence policy before first render", async () => {
248
+ const schema = createPersistentSchema();
249
+ const firstRender = render(
250
+ <ToolcraftRoot schema={schema}>
251
+ <PanelPersistenceProbe />
252
+ </ToolcraftRoot>,
253
+ );
254
+
255
+ fireEvent.click(screen.getByRole("button", { name: "0,0" }));
256
+
257
+ await waitFor(() => {
258
+ expect(
259
+ JSON.parse(window.localStorage.getItem("toolcraft:root-test:state:v1") ?? "{}"),
260
+ ).toMatchObject({
261
+ state: { panels: { controls: { offset: { x: 72, y: -24 } } } },
262
+ version: 1,
263
+ });
264
+ });
265
+
266
+ firstRender.unmount();
267
+
268
+ render(
269
+ <ToolcraftRoot schema={schema}>
270
+ <PanelPersistenceProbe />
271
+ </ToolcraftRoot>,
272
+ );
273
+
274
+ expect(screen.getByRole("button", { name: "72,-24" })).toBeTruthy();
275
+ });
276
+
277
+ it("binds runtime undo and redo to keyboard shortcuts when history is enabled", async () => {
278
+ render(
279
+ <ToolcraftRoot schema={createHistorySchema()}>
280
+ <Probe />
281
+ </ToolcraftRoot>,
282
+ );
283
+
284
+ fireEvent.click(screen.getByRole("button", { name: "75" }));
285
+ expect(await screen.findByRole("button", { name: "50" })).toBeTruthy();
286
+
287
+ fireEvent.keyDown(document, { key: "z", metaKey: true });
288
+ expect(screen.getByRole("button", { name: "75" })).toBeTruthy();
289
+
290
+ fireEvent.keyDown(document, { key: "z", metaKey: true, shiftKey: true });
291
+ expect(screen.getByRole("button", { name: "50" })).toBeTruthy();
292
+
293
+ fireEvent.keyDown(document, { key: "z", ctrlKey: true });
294
+ expect(screen.getByRole("button", { name: "75" })).toBeTruthy();
295
+
296
+ fireEvent.keyDown(document, { key: "y", ctrlKey: true });
297
+ expect(screen.getByRole("button", { name: "50" })).toBeTruthy();
298
+ });
299
+
300
+ it("does not hijack undo shortcuts while the user is typing into editable controls", async () => {
301
+ render(
302
+ <ToolcraftRoot schema={createHistorySchema()}>
303
+ <div>
304
+ <Probe />
305
+ <input aria-label="Text value" defaultValue="hello" />
306
+ <span
307
+ aria-label="Editable value"
308
+ contentEditable
309
+ role="textbox"
310
+ suppressContentEditableWarning
311
+ >
312
+ editable
313
+ </span>
314
+ </div>
315
+ </ToolcraftRoot>,
316
+ );
317
+
318
+ fireEvent.click(screen.getByRole("button", { name: "75" }));
319
+ expect(await screen.findByRole("button", { name: "50" })).toBeTruthy();
320
+
321
+ fireEvent.keyDown(screen.getByRole("textbox", { name: "Text value" }), {
322
+ key: "z",
323
+ metaKey: true,
324
+ });
325
+ expect(screen.getByRole("button", { name: "50" })).toBeTruthy();
326
+
327
+ fireEvent.keyDown(screen.getByRole("textbox", { name: "Editable value" }), {
328
+ key: "z",
329
+ metaKey: true,
330
+ });
331
+ expect(screen.getByRole("button", { name: "50" })).toBeTruthy();
332
+ });
333
+
334
+ it("does not register history shortcuts when toolbar history is disabled", async () => {
335
+ render(
336
+ <ToolcraftRoot schema={createHistorySchema(false)}>
337
+ <Probe />
338
+ </ToolcraftRoot>,
339
+ );
340
+
341
+ fireEvent.click(screen.getByRole("button", { name: "75" }));
342
+ expect(await screen.findByRole("button", { name: "50" })).toBeTruthy();
343
+
344
+ fireEvent.keyDown(document, { key: "z", metaKey: true });
345
+ expect(screen.getByRole("button", { name: "50" })).toBeTruthy();
346
+ });
347
+ });
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import type { ResolvedToolcraftAppSchema } from "../schema/types";
6
+ import { createToolcraftState } from "../state/create-template-state";
7
+ import {
8
+ createToolcraftPersistenceSnapshot,
9
+ getToolcraftPersistenceKey,
10
+ mergeToolcraftInitialState,
11
+ parseToolcraftPersistenceSnapshot,
12
+ } from "../state/persistence";
13
+ import { toolcraftReducer } from "../state/reducer";
14
+ import type {
15
+ ToolcraftCommand,
16
+ ToolcraftInitialState,
17
+ ToolcraftState,
18
+ } from "../state/types";
19
+ import { readToolcraftLocalStorageValue } from "./storage-key-migration";
20
+ import { ToolcraftThemeProvider } from "./theme-runtime";
21
+
22
+ export type ToolcraftContextValue = {
23
+ dispatch: React.Dispatch<ToolcraftCommand>;
24
+ state: ToolcraftState;
25
+ };
26
+
27
+ export const ToolcraftContext = React.createContext<ToolcraftContextValue | null>(null);
28
+
29
+ export type ToolcraftRootProps = {
30
+ children: React.ReactNode;
31
+ initialState?: ToolcraftInitialState;
32
+ schema: ResolvedToolcraftAppSchema;
33
+ };
34
+
35
+ function readPersistedInitialState(
36
+ schema: ResolvedToolcraftAppSchema,
37
+ ): ToolcraftInitialState | undefined {
38
+ const storageKey = getToolcraftPersistenceKey(schema.persistence);
39
+
40
+ if (!storageKey || typeof window === "undefined") {
41
+ return undefined;
42
+ }
43
+
44
+ try {
45
+ return parseToolcraftPersistenceSnapshot(
46
+ schema,
47
+ readToolcraftLocalStorageValue(storageKey),
48
+ );
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ function writePersistedState(
55
+ schema: ResolvedToolcraftAppSchema,
56
+ state: ToolcraftState,
57
+ ): void {
58
+ const storageKey = getToolcraftPersistenceKey(schema.persistence);
59
+
60
+ if (!storageKey || typeof window === "undefined") {
61
+ return;
62
+ }
63
+
64
+ const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence);
65
+
66
+ if (!snapshot) {
67
+ return;
68
+ }
69
+
70
+ try {
71
+ window.localStorage.setItem(storageKey, JSON.stringify(snapshot));
72
+ } catch {
73
+ // Persistence is best-effort; runtime state stays authoritative when storage is unavailable.
74
+ }
75
+ }
76
+
77
+ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
78
+ if (!target || typeof target !== "object") {
79
+ return false;
80
+ }
81
+
82
+ const candidate = target as {
83
+ closest?: (selector: string) => Element | null;
84
+ isContentEditable?: boolean;
85
+ tagName?: string;
86
+ };
87
+
88
+ if (candidate.isContentEditable) {
89
+ return true;
90
+ }
91
+
92
+ if (typeof candidate.closest === "function" && candidate.closest("[contenteditable='true']")) {
93
+ return true;
94
+ }
95
+
96
+ const tagName = candidate.tagName?.toLowerCase();
97
+
98
+ return tagName === "input" || tagName === "textarea" || tagName === "select";
99
+ }
100
+
101
+ function isUndoShortcut(event: KeyboardEvent): boolean {
102
+ return (
103
+ (event.metaKey || event.ctrlKey) &&
104
+ !event.shiftKey &&
105
+ !event.altKey &&
106
+ event.key.toLowerCase() === "z"
107
+ );
108
+ }
109
+
110
+ function isRedoShortcut(event: KeyboardEvent): boolean {
111
+ const key = event.key.toLowerCase();
112
+
113
+ return (
114
+ (event.metaKey || event.ctrlKey) &&
115
+ !event.altKey &&
116
+ ((event.shiftKey && key === "z") || (!event.metaKey && event.ctrlKey && key === "y"))
117
+ );
118
+ }
119
+
120
+ export function ToolcraftRoot({
121
+ children,
122
+ initialState,
123
+ schema,
124
+ }: ToolcraftRootProps) {
125
+ const [state, dispatch] = React.useReducer(
126
+ toolcraftReducer,
127
+ { initialState, schema },
128
+ ({ initialState, schema }) =>
129
+ createToolcraftState(
130
+ schema,
131
+ mergeToolcraftInitialState(readPersistedInitialState(schema), initialState),
132
+ ),
133
+ );
134
+ const latestStateRef = React.useRef(state);
135
+ const value = React.useMemo(() => ({ dispatch, state }), [dispatch, state]);
136
+
137
+ React.useEffect(() => {
138
+ latestStateRef.current = state;
139
+ }, [state]);
140
+
141
+ React.useEffect(() => {
142
+ if (!schema.toolbar.history || typeof document === "undefined") {
143
+ return undefined;
144
+ }
145
+
146
+ const handleDocumentKeyDown = (event: KeyboardEvent): void => {
147
+ if (event.defaultPrevented || isEditableKeyboardTarget(event.target)) {
148
+ return;
149
+ }
150
+
151
+ if (isUndoShortcut(event)) {
152
+ event.preventDefault();
153
+ dispatch({ type: "history.undo" });
154
+ return;
155
+ }
156
+
157
+ if (isRedoShortcut(event)) {
158
+ event.preventDefault();
159
+ dispatch({ type: "history.redo" });
160
+ }
161
+ };
162
+
163
+ document.addEventListener("keydown", handleDocumentKeyDown);
164
+
165
+ return () => {
166
+ document.removeEventListener("keydown", handleDocumentKeyDown);
167
+ };
168
+ }, [dispatch, schema.toolbar.history]);
169
+
170
+ React.useEffect(() => {
171
+ if (schema.persistence.storage !== "localStorage") {
172
+ return undefined;
173
+ }
174
+
175
+ const persistTimer = window.setTimeout(() => {
176
+ writePersistedState(schema, state);
177
+ }, 120);
178
+
179
+ return () => window.clearTimeout(persistTimer);
180
+ }, [schema, state]);
181
+
182
+ React.useEffect(() => {
183
+ if (schema.persistence.storage !== "localStorage") {
184
+ return undefined;
185
+ }
186
+
187
+ const handlePageHide = () => {
188
+ writePersistedState(schema, latestStateRef.current);
189
+ };
190
+
191
+ window.addEventListener("pagehide", handlePageHide);
192
+
193
+ return () => {
194
+ window.removeEventListener("pagehide", handlePageHide);
195
+ };
196
+ }, [schema]);
197
+
198
+ return (
199
+ <ToolcraftThemeProvider>
200
+ <ToolcraftContext.Provider value={value}>{children}</ToolcraftContext.Provider>
201
+ </ToolcraftThemeProvider>
202
+ );
203
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import {
6
+ evaluateToolcraftTimelineValue,
7
+ evaluateToolcraftTimelineValues,
8
+ } from "../state/keyframe-evaluation";
9
+ import { ToolcraftContext } from "./toolcraft-root";
10
+
11
+ export function useToolcraft() {
12
+ const context = React.useContext(ToolcraftContext);
13
+
14
+ if (!context) {
15
+ throw new Error("useToolcraft must be used inside ToolcraftRoot");
16
+ }
17
+
18
+ return context;
19
+ }
20
+
21
+ export function useToolcraftValue(target: string): unknown {
22
+ return useToolcraft().state.values[target];
23
+ }
24
+
25
+ export function useToolcraftEvaluatedValues(timeSeconds?: number): Record<string, unknown> {
26
+ const { state } = useToolcraft();
27
+
28
+ return React.useMemo(
29
+ () => evaluateToolcraftTimelineValues(state, timeSeconds),
30
+ [state, timeSeconds],
31
+ );
32
+ }
33
+
34
+ export function useToolcraftEvaluatedValue(target: string, timeSeconds?: number): unknown {
35
+ const { state } = useToolcraft();
36
+
37
+ return React.useMemo(
38
+ () => evaluateToolcraftTimelineValue(state, target, timeSeconds),
39
+ [state, target, timeSeconds],
40
+ );
41
+ }