@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
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  getToolcraftCanvasSizeTargetDimension,
3
3
  isToolcraftCanvasAspectRatioTarget,
4
+ isToolcraftTimelinePanelExtendedTarget,
5
+ isToolcraftTimelinePanelVisibleTarget,
4
6
  } from "../schema/runtime-targets";
5
7
  import { getToolcraftCanvasAspectRatioPreset } from "../schema/canvas-aspect-ratio-presets";
6
8
  import {
@@ -8,6 +10,11 @@ import {
8
10
  toolcraftCanvasZoomDefault,
9
11
  toolcraftCanvasZoomStep,
10
12
  } from "./canvas-zoom";
13
+ import {
14
+ cloneToolcraftLayers,
15
+ cloneToolcraftMediaAssets,
16
+ createToolcraftDefaultMediaState,
17
+ } from "./media-defaults";
11
18
  import { getMediaReadyTimelineState } from "./timeline-readiness";
12
19
  import type {
13
20
  ToolcraftCommand,
@@ -15,6 +22,8 @@ import type {
15
22
  ToolcraftHistoryMode,
16
23
  ToolcraftLayer,
17
24
  ToolcraftLayerDraft,
25
+ ToolcraftMediaAsset,
26
+ ToolcraftMediaTransform,
18
27
  ToolcraftState,
19
28
  ToolcraftTimelineKeyframe,
20
29
  ToolcraftTimelineKeyframeGroup,
@@ -235,7 +244,7 @@ function getResetMediaPatch(
235
244
  state: ToolcraftState,
236
245
  targets?: ReadonlySet<string>,
237
246
  ): Pick<ToolcraftHistoryPatch, "after" | "before"> | null {
238
- if (state.schema.panels.layers || state.mediaAssets.length === 0) {
247
+ if (state.schema.panels.layers) {
239
248
  return null;
240
249
  }
241
250
 
@@ -245,15 +254,46 @@ function getResetMediaPatch(
245
254
  return null;
246
255
  }
247
256
 
248
- const mediaAssets = state.mediaAssets.filter((asset) => {
249
- if (asset.sourceTarget) {
250
- return !fileDropTargets.has(asset.sourceTarget);
251
- }
252
-
253
- return false;
254
- });
257
+ const defaultMediaState = createToolcraftDefaultMediaState(state.schema);
258
+ const defaultTargetMediaAssets = defaultMediaState.mediaAssets.filter((asset) =>
259
+ asset.sourceTarget ? fileDropTargets.has(asset.sourceTarget) : false,
260
+ );
261
+ const defaultTargetLayerIds = new Set(defaultTargetMediaAssets.map((asset) => asset.layerId));
262
+ const currentTargetLayerIds = new Set(
263
+ state.mediaAssets.flatMap((asset) =>
264
+ asset.sourceTarget && fileDropTargets.has(asset.sourceTarget) ? [asset.layerId] : [],
265
+ ),
266
+ );
267
+ const mediaAssets = [
268
+ ...state.mediaAssets.filter((asset) => {
269
+ if (asset.sourceTarget) {
270
+ return !fileDropTargets.has(asset.sourceTarget);
271
+ }
255
272
 
256
- if (mediaAssets.length === state.mediaAssets.length) {
273
+ return false;
274
+ }),
275
+ ...cloneToolcraftMediaAssets(defaultTargetMediaAssets),
276
+ ];
277
+ const layers = [
278
+ ...state.layers.filter(
279
+ (layer) => !currentTargetLayerIds.has(layer.id) && !defaultTargetLayerIds.has(layer.id),
280
+ ),
281
+ ...cloneToolcraftLayers(
282
+ defaultMediaState.layers.filter((layer) => defaultTargetLayerIds.has(layer.id)),
283
+ ),
284
+ ];
285
+ const selectedLayerId =
286
+ state.selectedLayerId && layers.some((layer) => layer.id === state.selectedLayerId)
287
+ ? state.selectedLayerId
288
+ : (layers[0]?.id ?? null);
289
+
290
+ if (
291
+ mediaAssets.length === state.mediaAssets.length &&
292
+ mediaAssets.every((asset, index) => asset.id === state.mediaAssets[index]?.id) &&
293
+ layers.length === state.layers.length &&
294
+ layers.every((layer, index) => layer.id === state.layers[index]?.id) &&
295
+ selectedLayerId === state.selectedLayerId
296
+ ) {
257
297
  return null;
258
298
  }
259
299
 
@@ -262,11 +302,15 @@ function getResetMediaPatch(
262
302
 
263
303
  return {
264
304
  after: {
305
+ layers,
265
306
  mediaAssets,
307
+ selectedLayerId,
266
308
  ...(shouldCommitTimeline ? { timeline } : {}),
267
309
  },
268
310
  before: {
311
+ layers: state.layers,
269
312
  mediaAssets: state.mediaAssets,
313
+ selectedLayerId: state.selectedLayerId,
270
314
  ...(shouldCommitTimeline ? { timeline: state.timeline } : {}),
271
315
  },
272
316
  };
@@ -533,6 +577,74 @@ function getImportedLayerName(fileName: string): string {
533
577
  return name || "Material";
534
578
  }
535
579
 
580
+ function normalizeMediaRotation(rotationDeg: number): 0 | 90 | 180 | 270 {
581
+ const normalized = ((Math.round(rotationDeg / 90) * 90) % 360 + 360) % 360;
582
+
583
+ return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
584
+ }
585
+
586
+ function compactMediaTransform(
587
+ transform: ToolcraftMediaTransform,
588
+ ): ToolcraftMediaTransform | undefined {
589
+ const normalizedRotation = normalizeMediaRotation(transform.rotationDeg ?? 0);
590
+ const nextTransform: ToolcraftMediaTransform = {};
591
+
592
+ if (normalizedRotation !== 0) {
593
+ nextTransform.rotationDeg = normalizedRotation;
594
+ }
595
+
596
+ if (transform.flipHorizontal) {
597
+ nextTransform.flipHorizontal = true;
598
+ }
599
+
600
+ if (transform.flipVertical) {
601
+ nextTransform.flipVertical = true;
602
+ }
603
+
604
+ return Object.keys(nextTransform).length > 0 ? nextTransform : undefined;
605
+ }
606
+
607
+ function getTransformedMediaAsset(
608
+ mediaAsset: ToolcraftMediaAsset,
609
+ operation: Extract<ToolcraftCommand, { type: "media.transform" }>["operation"],
610
+ ): ToolcraftMediaAsset {
611
+ const currentTransform = mediaAsset.transform ?? {};
612
+ const rotationDeg = normalizeMediaRotation(currentTransform.rotationDeg ?? 0);
613
+ const transform: ToolcraftMediaTransform = {
614
+ flipHorizontal: currentTransform.flipHorizontal,
615
+ flipVertical: currentTransform.flipVertical,
616
+ rotationDeg,
617
+ };
618
+
619
+ switch (operation) {
620
+ case "flip-horizontal":
621
+ transform.flipHorizontal = !transform.flipHorizontal;
622
+ break;
623
+ case "flip-vertical":
624
+ transform.flipVertical = !transform.flipVertical;
625
+ break;
626
+ case "rotate-left":
627
+ transform.rotationDeg = normalizeMediaRotation(rotationDeg - 90);
628
+ break;
629
+ case "rotate-right":
630
+ transform.rotationDeg = normalizeMediaRotation(rotationDeg + 90);
631
+ break;
632
+ }
633
+
634
+ const compactTransform = compactMediaTransform(transform);
635
+
636
+ if (!compactTransform) {
637
+ const { transform: _transform, ...rest } = mediaAsset;
638
+
639
+ return rest;
640
+ }
641
+
642
+ return {
643
+ ...mediaAsset,
644
+ transform: compactTransform,
645
+ };
646
+ }
647
+
536
648
  function getNextLayerName(
537
649
  layers: readonly ToolcraftLayer[],
538
650
  prefix: "Group" | "Layer",
@@ -640,6 +752,44 @@ export function toolcraftReducer(
640
752
  case "controls.setValue": {
641
753
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(command.target);
642
754
 
755
+ if (isToolcraftTimelinePanelExtendedTarget(command.target)) {
756
+ const extended = command.value === true;
757
+
758
+ if (state.panels.timeline.extended === extended) {
759
+ return state;
760
+ }
761
+
762
+ return {
763
+ ...state,
764
+ panels: {
765
+ ...state.panels,
766
+ timeline: {
767
+ ...state.panels.timeline,
768
+ extended,
769
+ },
770
+ },
771
+ };
772
+ }
773
+
774
+ if (isToolcraftTimelinePanelVisibleTarget(command.target)) {
775
+ const hidden = command.value === false;
776
+
777
+ if (state.panels.timeline.hidden === hidden) {
778
+ return state;
779
+ }
780
+
781
+ return {
782
+ ...state,
783
+ panels: {
784
+ ...state.panels,
785
+ timeline: {
786
+ ...state.panels.timeline,
787
+ hidden,
788
+ },
789
+ },
790
+ };
791
+ }
792
+
643
793
  if (isToolcraftCanvasAspectRatioTarget(command.target)) {
644
794
  const ratio = normalizeCanvasAspectRatioValue(command.value, state.canvas.size);
645
795
  const size =
@@ -1131,6 +1281,22 @@ export function toolcraftReducer(
1131
1281
  },
1132
1282
  };
1133
1283
 
1284
+ case "panels.setHidden":
1285
+ if (state.panels[command.panelId].hidden === command.hidden) {
1286
+ return state;
1287
+ }
1288
+
1289
+ return {
1290
+ ...state,
1291
+ panels: {
1292
+ ...state.panels,
1293
+ [command.panelId]: {
1294
+ ...state.panels[command.panelId],
1295
+ hidden: command.hidden,
1296
+ },
1297
+ },
1298
+ };
1299
+
1134
1300
  case "panels.resetOffset":
1135
1301
  return {
1136
1302
  ...state,
@@ -1288,6 +1454,26 @@ export function toolcraftReducer(
1288
1454
  });
1289
1455
  }
1290
1456
 
1457
+ case "media.transform": {
1458
+ const targetMediaAsset = state.mediaAssets.find((asset) => asset.id === command.mediaId);
1459
+
1460
+ if (!targetMediaAsset || (targetMediaAsset.assetKind ?? "image") !== "image") {
1461
+ return state;
1462
+ }
1463
+
1464
+ const mediaAssets = state.mediaAssets.map((asset) =>
1465
+ asset.id === targetMediaAsset.id
1466
+ ? getTransformedMediaAsset(asset, command.operation)
1467
+ : asset,
1468
+ );
1469
+
1470
+ return commitStatePatch(state, {
1471
+ after: { mediaAssets },
1472
+ before: { mediaAssets: state.mediaAssets },
1473
+ label: "Transform media",
1474
+ });
1475
+ }
1476
+
1291
1477
  case "timeline.setCurrentTime": {
1292
1478
  return {
1293
1479
  ...state,
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ getToolcraftTimelineLoopProgress,
5
+ getToolcraftTimelineLoopTime,
6
+ } from "./timeline-loop";
7
+
8
+ describe("timeline loop helpers", () => {
9
+ it("wraps timeline time into a forward-only seamless loop", () => {
10
+ expect(
11
+ getToolcraftTimelineLoopTime({
12
+ currentTimeSeconds: 0,
13
+ durationSeconds: 8,
14
+ }),
15
+ ).toBe(0);
16
+ expect(
17
+ getToolcraftTimelineLoopTime({
18
+ currentTimeSeconds: 7.5,
19
+ durationSeconds: 8,
20
+ }),
21
+ ).toBe(7.5);
22
+ expect(
23
+ getToolcraftTimelineLoopTime({
24
+ currentTimeSeconds: 8,
25
+ durationSeconds: 8,
26
+ }),
27
+ ).toBe(0);
28
+ expect(
29
+ getToolcraftTimelineLoopTime({
30
+ currentTimeSeconds: 9.25,
31
+ durationSeconds: 8,
32
+ }),
33
+ ).toBe(1.25);
34
+ });
35
+
36
+ it("maps one full product cycle to the current timeline duration", () => {
37
+ expect(
38
+ getToolcraftTimelineLoopProgress({
39
+ currentTimeSeconds: 2,
40
+ durationSeconds: 4,
41
+ }),
42
+ ).toBe(0.5);
43
+ expect(
44
+ getToolcraftTimelineLoopProgress({
45
+ currentTimeSeconds: 2,
46
+ durationSeconds: 8,
47
+ }),
48
+ ).toBe(0.25);
49
+ expect(
50
+ getToolcraftTimelineLoopProgress({
51
+ currentTimeSeconds: 8,
52
+ durationSeconds: 8,
53
+ }),
54
+ ).toBe(0);
55
+ });
56
+
57
+ it("handles invalid timeline values as the first loop frame", () => {
58
+ expect(
59
+ getToolcraftTimelineLoopProgress({
60
+ currentTimeSeconds: Number.NaN,
61
+ durationSeconds: 8,
62
+ }),
63
+ ).toBe(0);
64
+ expect(
65
+ getToolcraftTimelineLoopProgress({
66
+ currentTimeSeconds: 4,
67
+ durationSeconds: 0,
68
+ }),
69
+ ).toBe(0);
70
+ });
71
+ });
@@ -0,0 +1,35 @@
1
+ import type { ToolcraftTimelineState } from "./types";
2
+
3
+ export type ToolcraftTimelineLoopOptions = Pick<
4
+ ToolcraftTimelineState,
5
+ "currentTimeSeconds" | "durationSeconds"
6
+ >;
7
+
8
+ function getPositiveModulo(value: number, modulo: number): number {
9
+ return ((value % modulo) + modulo) % modulo;
10
+ }
11
+
12
+ export function getToolcraftTimelineLoopTime({
13
+ currentTimeSeconds,
14
+ durationSeconds,
15
+ }: ToolcraftTimelineLoopOptions): number {
16
+ if (
17
+ !Number.isFinite(currentTimeSeconds) ||
18
+ !Number.isFinite(durationSeconds) ||
19
+ durationSeconds <= 0
20
+ ) {
21
+ return 0;
22
+ }
23
+
24
+ return getPositiveModulo(currentTimeSeconds, durationSeconds);
25
+ }
26
+
27
+ export function getToolcraftTimelineLoopProgress(
28
+ options: ToolcraftTimelineLoopOptions,
29
+ ): number {
30
+ if (!Number.isFinite(options.durationSeconds) || options.durationSeconds <= 0) {
31
+ return 0;
32
+ }
33
+
34
+ return getToolcraftTimelineLoopTime(options) / options.durationSeconds;
35
+ }
@@ -37,6 +37,11 @@ export type ToolcraftCommand =
37
37
  panelId: ToolcraftPanelId;
38
38
  type: "panels.setOffset";
39
39
  }
40
+ | {
41
+ hidden: boolean;
42
+ panelId: ToolcraftPanelId;
43
+ type: "panels.setHidden";
44
+ }
40
45
  | { panelId: ToolcraftPanelId; type: "panels.resetOffset" }
41
46
  | {
42
47
  asset: Omit<ToolcraftMediaAsset, "id" | "layerId"> & {
@@ -49,6 +54,11 @@ export type ToolcraftCommand =
49
54
  }
50
55
  | { mediaId: string; type: "media.delete" }
51
56
  | { mediaIds: string[]; type: "media.reorder" }
57
+ | {
58
+ mediaId: string;
59
+ operation: ToolcraftMediaTransformOperation;
60
+ type: "media.transform";
61
+ }
52
62
  | { currentTimeSeconds: number; type: "timeline.setCurrentTime" }
53
63
  | { durationSeconds: number; type: "timeline.setDuration" }
54
64
  | { expanded: boolean; type: "timeline.setExpanded" }
@@ -106,10 +116,12 @@ export const toolcraftRuntimeCommandTypes = [
106
116
  "canvas.zoomReset",
107
117
  "canvas.setViewport",
108
118
  "panels.setOffset",
119
+ "panels.setHidden",
109
120
  "panels.resetOffset",
110
121
  "media.import",
111
122
  "media.delete",
112
123
  "media.reorder",
124
+ "media.transform",
113
125
  "timeline.setCurrentTime",
114
126
  "timeline.setDuration",
115
127
  "timeline.setExpanded",
@@ -171,8 +183,21 @@ export type ToolcraftMediaAsset = {
171
183
  position: ToolcraftPoint;
172
184
  size?: ToolcraftCanvasSize;
173
185
  sourceTarget?: string;
186
+ transform?: ToolcraftMediaTransform;
187
+ };
188
+
189
+ export type ToolcraftMediaTransform = {
190
+ flipHorizontal?: boolean;
191
+ flipVertical?: boolean;
192
+ rotationDeg?: 0 | 90 | 180 | 270;
174
193
  };
175
194
 
195
+ export type ToolcraftMediaTransformOperation =
196
+ | "flip-horizontal"
197
+ | "flip-vertical"
198
+ | "rotate-left"
199
+ | "rotate-right";
200
+
176
201
  export type ToolcraftHistoryPatch = {
177
202
  after: Record<string, unknown>;
178
203
  before: Record<string, unknown>;
@@ -223,6 +248,8 @@ export type ToolcraftPanelId = "controls" | "layers" | "timeline" | "toolbar";
223
248
 
224
249
  export type ToolcraftPanelState = {
225
250
  collapsed?: boolean;
251
+ extended?: boolean;
252
+ hidden?: boolean;
226
253
  offset: { x: number; y: number };
227
254
  };
228
255