@pixel-point/toolcraft 0.0.9 → 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 (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -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
  );