@pixel-point/toolcraft 0.0.8 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -24,7 +24,7 @@ describe("Toolcraft template decision contract", () => {
24
24
  for (const rule of TOOLCRAFT_DECISION_CONTRACT) {
25
25
  expect(rule.id).toMatch(/^[a-z0-9-]+$/);
26
26
  expect(rule.area).toMatch(
27
- /^(runtime-shell|canvas|panels|layers|timeline|controls|renderer|reference-clone|acceptance|performance|persistence|workflow)$/,
27
+ /^(runtime-shell|canvas|panels|layers|timeline|controls|renderer|reference-analysis|reference-clone|acceptance|performance|persistence|workflow)$/,
28
28
  );
29
29
  expect(rule.title.trim().length).toBeGreaterThan(0);
30
30
  expect(rule.currentConstraint.trim().length).toBeGreaterThan(0);
@@ -40,6 +40,11 @@ describe("Toolcraft template decision contract", () => {
40
40
  expect(rule?.verdict).toBe("move-to-validator");
41
41
  expect(rule?.enforcement).toContain("browser-helper");
42
42
  expect(rule?.desiredBehavior).toMatch(/product result/i);
43
+ expect(rule?.desiredBehavior).toMatch(/uploaded or imported content is part/i);
44
+ expect(rule?.desiredBehavior).toMatch(/fake sample output/i);
45
+ expect(rule?.desiredBehavior).toMatch(/agent-invented preset modes/i);
46
+ expect(rule?.desiredBehavior).toMatch(/worklog records that evidence/i);
47
+ expect(rule?.currentConstraint).toMatch(/uploaded\/imported source-material flows/i);
43
48
  });
44
49
 
45
50
  it("keeps runtime shell bypass prevention in validators, not only docs", () => {
@@ -80,6 +85,36 @@ describe("Toolcraft template decision contract", () => {
80
85
  expect(rule?.enforcement).toContain("performance-validator");
81
86
  });
82
87
 
88
+ it("requires reference clone functionality to be inventoried and acceptance-backed", () => {
89
+ const rule = getToolcraftDecisionRule("reference-clone-source-of-truth");
90
+
91
+ expect(rule?.level).toBe("invariant");
92
+ expect(rule?.desiredBehavior).toMatch(/referenceStudy evidence/i);
93
+ expect(rule?.desiredBehavior).toMatch(/running the original or restoring it locally/i);
94
+ expect(rule?.desiredBehavior).toMatch(/reference feature inventory/i);
95
+ expect(rule?.desiredBehavior).toMatch(/feature-level behavior evidence/i);
96
+ expect(rule?.desiredBehavior).toMatch(/user-visible and output-affecting feature/i);
97
+ expect(rule?.desiredBehavior).toMatch(/Toolcraft implementation/i);
98
+ expect(rule?.desiredBehavior).toMatch(/acceptance coverage/i);
99
+ expect(rule?.desiredBehavior).toMatch(/explicit user approval/i);
100
+ expect(rule?.enforcement).toContain("acceptance-validator");
101
+ });
102
+
103
+ it("requires video references to be studied as behavior before implementation", () => {
104
+ const rule = getToolcraftDecisionRule("video-reference-analysis");
105
+
106
+ expect(rule?.level).toBe("invariant");
107
+ expect(rule?.area).toBe("reference-analysis");
108
+ expect(rule?.desiredBehavior).toMatch(/video, GIF, screen recording/i);
109
+ expect(rule?.desiredBehavior).toMatch(/storyboard/i);
110
+ expect(rule?.desiredBehavior).toMatch(/timecoded frame observations/i);
111
+ expect(rule?.desiredBehavior).toMatch(/frame-to-frame transitions/i);
112
+ expect(rule?.desiredBehavior).toMatch(/map those observed behaviors to acceptance rows/i);
113
+ expect(rule?.desiredBehavior).toMatch(/independent from loop duration or timeline choice/i);
114
+ expect(rule?.enforcement).toContain("acceptance-validator");
115
+ expect(rule?.enforcement).toContain("starter-agents");
116
+ });
117
+
83
118
  it("splits performance into workload and lightweight responsiveness coverage", () => {
84
119
  const rule = getToolcraftDecisionRule("performance-coverage-levels");
85
120
 
@@ -89,6 +124,8 @@ describe("Toolcraft template decision contract", () => {
89
124
  expect(rule?.desiredBehavior).toMatch(/canvas drag, pan, pinch, zoom, and radar\/center/i);
90
125
  expect(rule?.desiredBehavior).toMatch(/playback state/i);
91
126
  expect(rule?.desiredBehavior).toMatch(/first working version/i);
127
+ expect(rule?.desiredBehavior).toMatch(/agent's controlled browser/i);
128
+ expect(rule?.desiredBehavior).toMatch(/Playwright is only the fallback runner/i);
92
129
  expect(rule?.desiredBehavior).toMatch(/optimize performance/i);
93
130
  expect(rule?.desiredBehavior).toMatch(/otherwise complains about performance/i);
94
131
  expect(rule?.desiredBehavior).toMatch(/targeted functional\/browser checks/i);
@@ -6,6 +6,7 @@ export type ToolcraftDecisionArea =
6
6
  | "panels"
7
7
  | "performance"
8
8
  | "persistence"
9
+ | "reference-analysis"
9
10
  | "reference-clone"
10
11
  | "renderer"
11
12
  | "runtime-shell"
@@ -68,9 +69,9 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
68
69
  {
69
70
  area: "canvas",
70
71
  currentConstraint:
71
- "canvasContent must not contain buttons, forms, CTAs, helper text, upload prompts, menus, or settings UI.",
72
+ "canvasContent must not contain buttons, forms, CTAs, helper text, upload prompts, menus, settings UI, or invented placeholder/demo artwork for apps with uploaded/imported source-material flows.",
72
73
  desiredBehavior:
73
- "Canvas renders only product result, source material, renderer output, and valid product editing handles.",
74
+ "Canvas renders only real product result, source material, renderer output derived from current state, and valid product editing handles. If uploaded or imported content is part of the product source flow, the pre-content canvas stays neutral and runtime-backed until real content exists. Do not add agent-invented preset modes, demo backgrounds, CTA copy, fake sample output, or decorative placeholders to fill the canvas; a default procedural/reference source is allowed only when the prompt or reference explicitly defines it and the worklog records that evidence.",
74
75
  enforcement: ["browser-helper", "starter-agents", "spec-checklist"],
75
76
  id: "canvas-no-app-ui",
76
77
  level: "invariant",
@@ -139,10 +140,10 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
139
140
  {
140
141
  area: "timeline",
141
142
  currentConstraint:
142
- "Timeline is optional, but animated products must first classify animation intent as product transport, editable keyframes, custom reference timeline, or autonomous decorative output.",
143
+ "Timeline is optional only for animated products that have no video export and are explicitly classified as autonomous decorative output.",
143
144
  desiredBehavior:
144
- "AI writes an Animation Intent Inventory before choosing no timeline, playback, keyframes, or custom reference timeline; user-requested product animation defaults to playback unless explicitly justified as autonomous output.",
145
- enforcement: ["starter-agents", "spec-checklist"],
145
+ "AI writes an Animation Intent Inventory before choosing no timeline, playback, keyframes, or custom reference timeline; user-requested product animation defaults to playback unless explicitly justified as autonomous output without video export. Export Video always requires a top Toolcraft timeline.",
146
+ enforcement: ["starter-agents", "spec-checklist", "acceptance-validator"],
146
147
  id: "timeline-mode-choice",
147
148
  level: "heuristic",
148
149
  title: "Timeline mode is chosen from behavior",
@@ -189,7 +190,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
189
190
  currentConstraint:
190
191
  "Labels, color placement, section grouping, selector order, and inline density need product-aware decisions.",
191
192
  desiredBehavior:
192
- "Apps define a Control Section Inventory before schema authoring: every controls section has a product entity or workflow stage, related targets stay together, and sections are not named after UI component types.",
193
+ "Apps export a Control Section Inventory before schema authoring: every product controls section has a product entity or workflow stage, exact targets, a grouping reason, and split evidence when one target entity is intentionally divided.",
193
194
  enforcement: ["acceptance-validator", "schema-normalization", "docs", "starter-agents"],
194
195
  id: "controls-layout-heuristics",
195
196
  level: "heuristic",
@@ -211,15 +212,27 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
211
212
  {
212
213
  area: "reference-clone",
213
214
  currentConstraint:
214
- "Reference-runtime-clone mode preserves the reference runtime as source of truth unless a redesign is explicit.",
215
+ "Reference-runtime-clone mode preserves the reference runtime as source of truth unless a redesign is explicit; functionality must be inventoried from inspected reference behavior before implementation.",
215
216
  desiredBehavior:
216
- "Ported apps keep reference loops, mutable state, transport semantics, media lifecycle, and export behavior before Toolcraft refinements.",
217
+ "Ported apps keep reference loops, mutable state, transport semantics, media lifecycle, and export behavior before Toolcraft refinements. The agent records referenceStudy evidence from source inspection plus running the original or restoring it locally when runnable/reconstructable, builds a reference feature inventory from inspected source/runtime/UI behavior, gives each inventory item feature-level behavior evidence from that study, maps every user-visible and output-affecting feature to Toolcraft implementation and acceptance coverage, and marks intentional behavior changes only with explicit user approval evidence.",
217
218
  enforcement: ["acceptance-validator", "browser-helper", "starter-agents"],
218
219
  id: "reference-clone-source-of-truth",
219
220
  level: "invariant",
220
221
  title: "Reference clone preserves behavior",
221
222
  verdict: "keep-hard",
222
223
  },
224
+ {
225
+ area: "reference-analysis",
226
+ currentConstraint:
227
+ "Video, GIF, and screen-recording references are often treated as static visual inspiration or summarized from a few frames.",
228
+ desiredBehavior:
229
+ "Whenever a supplied reference is a video, GIF, screen recording, contact sheet, or extracted frame sequence, the agent studies it as behavioral evidence before implementation: extract or inspect a storyboard, record timecoded frame observations, compare frame-to-frame transitions, decompose changing entities and state into product behavior, and map those observed behaviors to acceptance rows. This applies to new Toolcraft apps and reference-runtime-clone work; it is independent from loop duration or timeline choice.",
230
+ enforcement: ["acceptance-validator", "browser-helper", "docs", "starter-agents"],
231
+ id: "video-reference-analysis",
232
+ level: "invariant",
233
+ title: "Video references require storyboard behavior study",
234
+ verdict: "move-to-validator",
235
+ },
223
236
  {
224
237
  area: "acceptance",
225
238
  currentConstraint:
@@ -237,7 +250,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
237
250
  currentConstraint:
238
251
  "Performance coverage currently asks every visible non-action control for a performance scenario.",
239
252
  desiredBehavior:
240
- "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required only when the first working version of an app exists, or whenever the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance. Renderer, canvas, animation, export, timeline, layers, canvas.renderScale, bug fixes, and performance-sensitive controls need targeted functional/browser checks first and targeted performance scenarios only for touched workload/viewport/export paths. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance tests read budgets from typed performance config and run sequentially for stable measurements.",
253
+ "Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required only when the first working version of an app exists, or whenever the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance. The checkpoint uses the current AI agent's controlled browser first; Playwright is only the fallback runner when no agent browser is available or in CI/non-agent automation. Renderer, canvas, animation, export, timeline, layers, canvas.renderScale, bug fixes, and performance-sensitive controls need targeted functional/browser checks first and targeted performance scenarios only for touched workload/viewport/export paths. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance checks read budgets from typed performance config and run sequentially for stable measurements.",
241
254
  enforcement: ["performance-validator", "browser-helper", "starter-agents"],
242
255
  id: "performance-coverage-levels",
243
256
  level: "invariant",
@@ -7,6 +7,7 @@ import {
7
7
  getToolcraftImageExportSize,
8
8
  getToolcraftRetinaExportPixelRatio,
9
9
  getToolcraftRetinaExportSize,
10
+ getToolcraftVideoExportSize,
10
11
  shouldIncludeToolcraftExportBackground,
11
12
  shouldIncludeToolcraftPreviewBackground,
12
13
  } from "./export";
@@ -185,6 +186,68 @@ describe("Toolcraft export helpers", () => {
185
186
  });
186
187
  });
187
188
 
189
+ it("resolves current video export to even current output dimensions", () => {
190
+ const state = createState();
191
+ state.canvas.size = { height: 101, unit: "px", width: 201 };
192
+
193
+ expect(getToolcraftVideoExportSize({ devicePixelRatio: 2, resolution: "current", state }))
194
+ .toEqual({
195
+ height: 102,
196
+ pixelRatio: 102 / 101,
197
+ width: 202,
198
+ });
199
+ });
200
+
201
+ it("keeps current video export distinct from 4k export at default canvas size", () => {
202
+ const state = createState();
203
+ state.canvas.size = { height: 1080, unit: "px", width: 1920 };
204
+
205
+ expect(getToolcraftVideoExportSize({ devicePixelRatio: 2, resolution: "current", state }))
206
+ .toEqual({
207
+ height: 1080,
208
+ pixelRatio: 1,
209
+ width: 1920,
210
+ });
211
+ expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
212
+ height: 2160,
213
+ pixelRatio: 2,
214
+ width: 3840,
215
+ });
216
+ });
217
+
218
+ it("fits 4k video export inside an encoder-safe UHD box", () => {
219
+ const state = createState();
220
+ state.canvas.size = { height: 900, unit: "px", width: 1440 };
221
+
222
+ expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
223
+ height: 2160,
224
+ pixelRatio: 2.4,
225
+ width: 3456,
226
+ });
227
+ });
228
+
229
+ it("preserves 16:9 at UHD dimensions for 4k video export", () => {
230
+ const state = createState();
231
+ state.canvas.size = { height: 1080, unit: "px", width: 1920 };
232
+
233
+ expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
234
+ height: 2160,
235
+ pixelRatio: 2,
236
+ width: 3840,
237
+ });
238
+ });
239
+
240
+ it("reports the actual rounded pixel ratio for 4k video export", () => {
241
+ const state = createState();
242
+ state.canvas.size = { height: 201, unit: "px", width: 333 };
243
+
244
+ expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
245
+ height: 2160,
246
+ pixelRatio: 2160 / 201,
247
+ width: 3578,
248
+ });
249
+ });
250
+
188
251
  it("creates a transparent retina png canvas when png background is disabled", () => {
189
252
  const schema = defineToolcraft({
190
253
  canvas: { enabled: true },
@@ -4,6 +4,7 @@ import type { ToolcraftState } from "../state/types";
4
4
  export type ToolcraftExportFormat = "png" | "video";
5
5
 
6
6
  export type ToolcraftImageExportResolution = "current" | "2k" | "4k" | "8k";
7
+ export type ToolcraftVideoExportResolution = "current" | "4k";
7
8
 
8
9
  export type ToolcraftRetinaExportSize = {
9
10
  height: number;
@@ -30,6 +31,10 @@ export type ToolcraftImageExportSizeOptions = ToolcraftExportSizeOptions & {
30
31
  resolution?: ToolcraftImageExportResolution | string;
31
32
  };
32
33
 
34
+ export type ToolcraftVideoExportSizeOptions = ToolcraftExportSizeOptions & {
35
+ resolution?: ToolcraftVideoExportResolution | string;
36
+ };
37
+
33
38
  export type ToolcraftPngRenderContext = {
34
39
  canvas: HTMLCanvasElement;
35
40
  context: CanvasRenderingContext2D;
@@ -60,6 +65,17 @@ const toolcraftImageExportLongEdges: Record<
60
65
  "8k": 8192,
61
66
  };
62
67
 
68
+ const toolcraftVideoExportMaxSizes: Record<
69
+ Exclude<ToolcraftVideoExportResolution, "current">,
70
+ { height: number; width: number }
71
+ > = {
72
+ "4k": { height: 2160, width: 3840 },
73
+ };
74
+
75
+ function roundVideoDimension(value: number): number {
76
+ return Math.max(2, Math.round(value / 2) * 2);
77
+ }
78
+
63
79
  export function getToolcraftRetinaExportPixelRatio(devicePixelRatio?: number): number {
64
80
  const globalPixelRatio = (globalThis as typeof globalThis & { devicePixelRatio?: number })
65
81
  .devicePixelRatio;
@@ -123,6 +139,45 @@ export function getToolcraftImageExportSize({
123
139
  };
124
140
  }
125
141
 
142
+ export function getToolcraftVideoExportSize({
143
+ resolution,
144
+ state,
145
+ }: ToolcraftVideoExportSizeOptions): ToolcraftRetinaExportSize {
146
+ const normalizedResolution = String(resolution ?? "current").toLowerCase();
147
+ const targetMaxSize =
148
+ toolcraftVideoExportMaxSizes[
149
+ normalizedResolution as Exclude<ToolcraftVideoExportResolution, "current">
150
+ ];
151
+
152
+ if (!targetMaxSize) {
153
+ const cssWidth = Math.max(1, state.canvas.size.width);
154
+ const cssHeight = Math.max(1, state.canvas.size.height);
155
+ const width = roundVideoDimension(cssWidth);
156
+ const height = roundVideoDimension(cssHeight);
157
+
158
+ return {
159
+ height,
160
+ pixelRatio: Math.max(width / cssWidth, height / cssHeight),
161
+ width,
162
+ };
163
+ }
164
+
165
+ const cssWidth = Math.max(1, state.canvas.size.width);
166
+ const cssHeight = Math.max(1, state.canvas.size.height);
167
+ const pixelRatio = Math.min(
168
+ targetMaxSize.width / cssWidth,
169
+ targetMaxSize.height / cssHeight,
170
+ );
171
+ const width = roundVideoDimension(cssWidth * pixelRatio);
172
+ const height = roundVideoDimension(cssHeight * pixelRatio);
173
+
174
+ return {
175
+ height,
176
+ pixelRatio: Math.max(width / cssWidth, height / cssHeight),
177
+ width,
178
+ };
179
+ }
180
+
126
181
  export function shouldIncludeToolcraftExportBackground({
127
182
  format,
128
183
  schema,
@@ -10,5 +10,6 @@ export * from "./state/create-template-state";
10
10
  export * from "./state/keyframe-evaluation";
11
11
  export * from "./state/persistence";
12
12
  export * from "./state/reducer";
13
+ export * from "./state/timeline-loop";
13
14
  export * from "./state/types";
14
15
  export * from "./testing/performance";
@@ -22,7 +22,18 @@ function createSchema() {
22
22
  });
23
23
  }
24
24
 
25
- function createAutoSizedSchema() {
25
+ function createIntrinsicMediaSchema() {
26
+ return defineToolcraft({
27
+ canvas: {
28
+ enabled: true,
29
+ sizing: { mode: "intrinsic-media" },
30
+ upload: true,
31
+ },
32
+ panels: {},
33
+ });
34
+ }
35
+
36
+ function createUploadDefaultSchema() {
26
37
  return defineToolcraft({
27
38
  canvas: {
28
39
  enabled: true,
@@ -87,9 +98,9 @@ describe("CanvasShell", () => {
87
98
  expect(editableCanvas?.style.height).toBe("200px");
88
99
  });
89
100
 
90
- it("does not render a fixed editable canvas when the app has not chosen a size", () => {
101
+ it("does not render a fixed editable canvas for explicit intrinsic media sizing", () => {
91
102
  const { container } = render(
92
- <ToolcraftRoot schema={createAutoSizedSchema()}>
103
+ <ToolcraftRoot schema={createIntrinsicMediaSchema()}>
93
104
  <div style={{ height: 640, position: "relative", width: 640 }}>
94
105
  <CanvasShell />
95
106
  </div>
@@ -101,7 +112,7 @@ describe("CanvasShell", () => {
101
112
 
102
113
  it("renders the canvas slot inside the editable canvas when custom app content is provided", () => {
103
114
  const { container } = render(
104
- <ToolcraftRoot schema={createAutoSizedSchema()}>
115
+ <ToolcraftRoot schema={createUploadDefaultSchema()}>
105
116
  <div style={{ height: 640, position: "relative", width: 640 }}>
106
117
  <CanvasShell>
107
118
  <div data-testid="custom-canvas-renderer" />
@@ -497,4 +508,47 @@ describe("CanvasShell", () => {
497
508
 
498
509
  expect(screen.queryByRole("button", { name: "Select material.png" })).toBeNull();
499
510
  });
511
+
512
+ it("renders uploaded canvas images with cover crop and runtime transforms", () => {
513
+ render(
514
+ <ToolcraftRoot
515
+ initialState={{
516
+ layers: [
517
+ {
518
+ displayName: "Layer 1",
519
+ id: "layer-1",
520
+ kind: "layer",
521
+ name: "layer-1",
522
+ visible: true,
523
+ },
524
+ ],
525
+ mediaAssets: [
526
+ {
527
+ dataUrl: "data:image/png;base64,test",
528
+ fileName: "material.png",
529
+ id: "media-1",
530
+ layerId: "layer-1",
531
+ mimeType: "image/png",
532
+ position: { x: 48, y: -24 },
533
+ size: { height: 200, unit: "px", width: 300 },
534
+ transform: { flipHorizontal: true, rotationDeg: 90 },
535
+ },
536
+ ],
537
+ selectedLayerId: "layer-1",
538
+ }}
539
+ schema={createSchema()}
540
+ >
541
+ <div style={{ height: 640, position: "relative", width: 640 }}>
542
+ <CanvasShell />
543
+ </div>
544
+ </ToolcraftRoot>,
545
+ );
546
+
547
+ const mediaButton = screen.getByRole("button", { name: "Select material.png" });
548
+ const mediaImage = screen.getByRole("img", { name: "material.png" });
549
+
550
+ expect(mediaButton.className).toContain("inset-0");
551
+ expect(mediaImage.className).toContain("object-cover");
552
+ expect(mediaImage.getAttribute("style")).toContain("rotate(90deg) scale(-1.5, 1.5)");
553
+ });
500
554
  });
@@ -5,6 +5,7 @@ import * as React from "react";
5
5
  import { clampToolcraftCanvasZoom } from "../state/canvas-zoom";
6
6
  import type {
7
7
  ToolcraftMediaAsset,
8
+ ToolcraftMediaTransform,
8
9
  ToolcraftPoint,
9
10
  ToolcraftState,
10
11
  } from "../state/types";
@@ -176,6 +177,33 @@ function isDefaultCanvasImageAsset(
176
177
  );
177
178
  }
178
179
 
180
+ function normalizeMediaRotation(rotationDeg: number | undefined): 0 | 90 | 180 | 270 {
181
+ const normalized = ((Math.round((rotationDeg ?? 0) / 90) * 90) % 360 + 360) % 360;
182
+
183
+ return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
184
+ }
185
+
186
+ function getCanvasMediaTransformStyle(
187
+ transform: ToolcraftMediaTransform | undefined,
188
+ canvasSize: ToolcraftState["canvas"]["size"],
189
+ ): React.CSSProperties {
190
+ const rotationDeg = normalizeMediaRotation(transform?.rotationDeg);
191
+ const coverScale =
192
+ rotationDeg === 90 || rotationDeg === 270
193
+ ? Math.max(canvasSize.width / canvasSize.height, canvasSize.height / canvasSize.width)
194
+ : 1;
195
+ const scaleX = (transform?.flipHorizontal ? -1 : 1) * coverScale;
196
+ const scaleY = (transform?.flipVertical ? -1 : 1) * coverScale;
197
+
198
+ if (rotationDeg === 0 && scaleX === 1 && scaleY === 1) {
199
+ return {};
200
+ }
201
+
202
+ return {
203
+ transform: `rotate(${rotationDeg}deg) scale(${scaleX}, ${scaleY})`,
204
+ };
205
+ }
206
+
179
207
  function getZoomedCanvasOffset({
180
208
  clientX,
181
209
  clientY,
@@ -507,7 +535,7 @@ export function CanvasShell({
507
535
  <button
508
536
  aria-label={`Select ${mediaAsset.fileName}`}
509
537
  className={cn(
510
- "absolute top-1/2 left-1/2 block cursor-pointer rounded-md border bg-[color:color-mix(in_oklab,var(--background)_84%,transparent)] p-1 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out",
538
+ "absolute inset-0 block cursor-pointer overflow-hidden rounded-none border bg-[color:color-mix(in_oklab,var(--background)_84%,transparent)] p-0 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out",
511
539
  selected
512
540
  ? "border-[color:var(--link)] shadow-[0_0_0_1px_color-mix(in_oklab,var(--link)_48%,transparent)]"
513
541
  : "border-[color:color-mix(in_oklab,var(--border)_10%,transparent)] hover:border-[color:color-mix(in_oklab,var(--border)_24%,transparent)]",
@@ -522,21 +550,15 @@ export function CanvasShell({
522
550
  });
523
551
  }}
524
552
  onPointerDown={(event) => event.stopPropagation()}
525
- style={{
526
- transform: `translate(calc(-50% + ${mediaAsset.position.x}px), calc(-50% + ${mediaAsset.position.y}px))`,
527
- }}
528
553
  type="button"
529
554
  >
530
555
  <img
531
556
  alt={mediaAsset.fileName}
532
- className="block select-none object-contain"
557
+ className="block size-full select-none object-cover"
533
558
  data-toolcraft-generated-output=""
534
559
  draggable={false}
535
560
  src={mediaAsset.dataUrl}
536
- style={{
537
- height: mediaAsset.size.height,
538
- width: mediaAsset.size.width,
539
- }}
561
+ style={getCanvasMediaTransformStyle(mediaAsset.transform, size)}
540
562
  />
541
563
  </button>
542
564
  );