@pixel-point/toolcraft 0.0.9 → 0.0.12

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 (77) hide show
  1. package/README.md +42 -9
  2. package/package.json +2 -1
  3. package/src/cli.mjs +4 -2
  4. package/src/cli.test.mjs +5 -2
  5. package/src/generate.mjs +14 -6
  6. package/src/generate.test.mjs +33 -2
  7. package/templates/runtime/contracts/component-contracts.test.ts +175 -36
  8. package/templates/runtime/contracts/component-contracts.ts +90 -45
  9. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  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 +478 -27
  17. package/templates/runtime/react/controls-panel.tsx +71 -26
  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 +225 -173
  27. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +41 -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 +657 -2
  41. package/templates/runtime/testing/performance.ts +789 -49
  42. package/templates/starter/AGENTS.md +22 -16
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +58 -37
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +30 -10
  50. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  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 +57 -4
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/index.html +1 -0
  56. package/templates/starter/package.json +3 -2
  57. package/templates/starter/playwright.config.ts +1 -1
  58. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  59. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  60. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  61. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  62. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  63. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  64. package/templates/starter/src/app/starter-performance.test.ts +130 -8
  65. package/templates/starter/src/app/starter-performance.ts +5 -0
  66. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  67. package/templates/starter/src/app/starter-schema.ts +6 -2
  68. package/templates/starter/vite.config.ts +58 -2
  69. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  70. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  71. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  72. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  73. package/templates/ui/components/controls/index.ts +2 -0
  74. package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
  75. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  76. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  77. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -1,4 +1,4 @@
1
- import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
1
+ import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
2
  import { FileDrop, Panel } from "@repo/ui";
3
3
  import {
4
4
  DEFAULT_COLOR_FORMAT_MODE,
@@ -54,18 +54,6 @@ function createSchema() {
54
54
  sections: [
55
55
  {
56
56
  controls: {
57
- canvasHeight: {
58
- defaultValue: 180,
59
- label: "Canvas height",
60
- target: "canvas.size.height",
61
- type: "text",
62
- },
63
- canvasWidth: {
64
- defaultValue: 320,
65
- label: "Canvas width",
66
- target: "canvas.size.width",
67
- type: "text",
68
- },
69
57
  prompt: {
70
58
  defaultValue: "Initial prompt",
71
59
  description: "Describe the generated result.",
@@ -109,13 +97,6 @@ function createSchema() {
109
97
  type: "switch",
110
98
  },
111
99
  },
112
- layoutGroups: [
113
- {
114
- columns: 2,
115
- controls: ["canvasWidth", "canvasHeight"],
116
- layout: "inline",
117
- },
118
- ],
119
100
  title: "Basic",
120
101
  },
121
102
  {
@@ -252,7 +233,19 @@ function StateProbe() {
252
233
  <span data-testid="media-ids">
253
234
  {state.mediaAssets.map((asset) => asset.id).join(",")}
254
235
  </span>
236
+ <span data-testid="media-sizes">
237
+ {state.mediaAssets
238
+ .map((asset) =>
239
+ asset.size ? `${asset.size.width}x${asset.size.height}` : "none",
240
+ )
241
+ .join(",")}
242
+ </span>
243
+ <span data-testid="media-transforms">
244
+ {JSON.stringify(state.mediaAssets.map((asset) => asset.transform ?? null))}
245
+ </span>
255
246
  <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
247
+ <span data-testid="timeline-panel-extended">{String(state.panels.timeline.extended)}</span>
248
+ <span data-testid="timeline-panel-hidden">{String(state.panels.timeline.hidden)}</span>
256
249
  <span data-testid="timeline-keyframes">
257
250
  {JSON.stringify(state.timeline.keyframeGroups)}
258
251
  </span>
@@ -388,6 +381,42 @@ describe("ControlsPanel", () => {
388
381
  expect(inlineGroup?.textContent).toContain("Snap Y");
389
382
  });
390
383
 
384
+ it("commits palette family and shade changes to runtime state immediately", () => {
385
+ const schema = defineToolcraft({
386
+ canvas: { enabled: false },
387
+ panels: {
388
+ controls: {
389
+ sections: [
390
+ {
391
+ controls: {
392
+ inkPalette: {
393
+ defaultValue: { family: "Slate", shade: "900" },
394
+ label: "Palette",
395
+ target: "ink.palette",
396
+ type: "palette",
397
+ },
398
+ },
399
+ title: "Ink",
400
+ },
401
+ ],
402
+ title: "Controls",
403
+ },
404
+ },
405
+ });
406
+
407
+ renderControlsPanelWithSchema(schema);
408
+
409
+ fireEvent.click(screen.getByRole("button", { name: "Primary family Red" }));
410
+ let values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
411
+
412
+ expect(values["ink.palette"]).toEqual({ family: "Red", shade: "900" });
413
+
414
+ fireEvent.click(screen.getByRole("button", { name: "Primary shade 500" }));
415
+ values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
416
+
417
+ expect(values["ink.palette"]).toEqual({ family: "Red", shade: "500" });
418
+ });
419
+
391
420
  it("renders local action buttons as a two-column grid below their label", () => {
392
421
  const schema = defineToolcraft({
393
422
  canvas: { enabled: false },
@@ -717,6 +746,44 @@ describe("ControlsPanel", () => {
717
746
  expect(container.querySelector('[data-slot="toggle-group"]')).toBeTruthy();
718
747
  });
719
748
 
749
+ it("renders standalone select controls stacked full-width instead of compact side-label rows", () => {
750
+ const schema = defineToolcraft({
751
+ canvas: { enabled: false },
752
+ panels: {
753
+ controls: {
754
+ sections: [
755
+ {
756
+ controls: {
757
+ shape: {
758
+ defaultValue: "pill",
759
+ label: "Shape",
760
+ options: [
761
+ { label: "Pill", value: "pill" },
762
+ { label: "Circle", value: "circle" },
763
+ ],
764
+ target: "glass.shape",
765
+ type: "select",
766
+ },
767
+ },
768
+ title: "Glass",
769
+ },
770
+ ],
771
+ title: "Controls",
772
+ },
773
+ },
774
+ });
775
+
776
+ renderControlsPanelWithSchema(schema);
777
+ const trigger = screen.getByRole("combobox");
778
+ const field = trigger.closest<HTMLElement>('[data-slot="field"]');
779
+
780
+ expect(screen.getByText("Shape")).toBeTruthy();
781
+ expect(trigger.textContent).toContain("Pill");
782
+ expect(field?.dataset.orientation).toBe("vertical");
783
+ expect(field?.className).toContain("gap-2");
784
+ expect(trigger.closest("div")?.className).toContain("w-full");
785
+ });
786
+
720
787
  it("renders compact select pairs inline with stacked full-width fields", () => {
721
788
  const schema = defineToolcraft({
722
789
  canvas: { enabled: false },
@@ -1691,6 +1758,67 @@ describe("ControlsPanel", () => {
1691
1758
  expect(values["geometry.position"]).toEqual({ x: "-1.00", y: "-1.00" });
1692
1759
  });
1693
1760
 
1761
+ it("resets a vector pad to its default value on double click", () => {
1762
+ const schema = defineToolcraft({
1763
+ canvas: { enabled: true },
1764
+ panels: {
1765
+ controls: {
1766
+ sections: [
1767
+ {
1768
+ controls: {
1769
+ position: {
1770
+ defaultValue: { x: "0.48", y: "-0.34" },
1771
+ label: "Position",
1772
+ target: "geometry.position",
1773
+ type: "vector",
1774
+ },
1775
+ },
1776
+ title: "Geometry",
1777
+ },
1778
+ ],
1779
+ title: "Controls",
1780
+ },
1781
+ },
1782
+ });
1783
+
1784
+ renderControlsPanelWithSchema(schema);
1785
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1786
+
1787
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1788
+ bottom: 100,
1789
+ height: 100,
1790
+ left: 0,
1791
+ right: 100,
1792
+ toJSON: () => undefined,
1793
+ top: 0,
1794
+ width: 100,
1795
+ x: 0,
1796
+ y: 0,
1797
+ } as DOMRect);
1798
+ Object.defineProperty(pad, "setPointerCapture", {
1799
+ configurable: true,
1800
+ value: vi.fn(),
1801
+ });
1802
+
1803
+ fireEvent.pointerDown(pad, {
1804
+ buttons: 1,
1805
+ clientX: 0,
1806
+ clientY: 0,
1807
+ pointerId: 1,
1808
+ });
1809
+ expect(
1810
+ JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")[
1811
+ "geometry.position"
1812
+ ],
1813
+ ).toEqual({ x: "-1.00", y: "-1.00" });
1814
+
1815
+ fireEvent.doubleClick(pad);
1816
+
1817
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1818
+ expect(values["geometry.position"]).toEqual({ x: "0.48", y: "-0.34" });
1819
+ expect(screen.getByText("0.48, -0.34")).toBeTruthy();
1820
+ });
1821
+
1694
1822
  it("locks vector pad movement to the dominant axis while shift is held", () => {
1695
1823
  const schema = defineToolcraft({
1696
1824
  canvas: { enabled: true },
@@ -1811,6 +1939,93 @@ describe("ControlsPanel", () => {
1811
1939
  expect(values["geometry.position"]).toEqual({ x: "0.00", y: "0.80" });
1812
1940
  });
1813
1941
 
1942
+ it("renders vector pad values as compact rounded coordinates", () => {
1943
+ const schema = defineToolcraft({
1944
+ canvas: { enabled: true },
1945
+ panels: {
1946
+ controls: {
1947
+ sections: [
1948
+ {
1949
+ controls: {
1950
+ center: {
1951
+ defaultValue: {
1952
+ x: "-0.07070312499999998",
1953
+ y: "-0.00392795138",
1954
+ },
1955
+ label: "Center",
1956
+ target: "geometry.center",
1957
+ type: "vector",
1958
+ },
1959
+ },
1960
+ title: "Geometry",
1961
+ },
1962
+ ],
1963
+ title: "Controls",
1964
+ },
1965
+ },
1966
+ });
1967
+
1968
+ renderControlsPanelWithSchema(schema);
1969
+
1970
+ expect(screen.getByText("-0.07, 0.00")).toBeTruthy();
1971
+ expect(screen.queryByText("-0.07070312499999998, -0.00392795138")).toBeNull();
1972
+ });
1973
+
1974
+ it("prevents native text selection while dragging a vector pad", () => {
1975
+ const schema = defineToolcraft({
1976
+ canvas: { enabled: true },
1977
+ panels: {
1978
+ controls: {
1979
+ sections: [
1980
+ {
1981
+ controls: {
1982
+ center: {
1983
+ defaultValue: { x: "0.00", y: "0.00" },
1984
+ label: "Center",
1985
+ target: "geometry.center",
1986
+ type: "vector",
1987
+ },
1988
+ },
1989
+ title: "Geometry",
1990
+ },
1991
+ ],
1992
+ title: "Controls",
1993
+ },
1994
+ },
1995
+ });
1996
+
1997
+ renderControlsPanelWithSchema(schema);
1998
+ const pad = screen.getByRole("button", { name: "Center X/Y pad" });
1999
+
2000
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
2001
+ bottom: 100,
2002
+ height: 100,
2003
+ left: 0,
2004
+ right: 100,
2005
+ toJSON: () => undefined,
2006
+ top: 0,
2007
+ width: 100,
2008
+ x: 0,
2009
+ y: 0,
2010
+ } as DOMRect);
2011
+ Object.defineProperty(pad, "setPointerCapture", {
2012
+ configurable: true,
2013
+ value: vi.fn(),
2014
+ });
2015
+
2016
+ const pointerDown = createEvent.pointerDown(pad, {
2017
+ buttons: 1,
2018
+ clientX: 50,
2019
+ clientY: 50,
2020
+ pointerId: 1,
2021
+ });
2022
+
2023
+ fireEvent(pad, pointerDown);
2024
+
2025
+ expect(pointerDown.defaultPrevented).toBe(true);
2026
+ expect(pad.className).toContain("select-none");
2027
+ });
2028
+
1814
2029
  it("allows cartesian vector pads when a product intentionally uses y-up math", () => {
1815
2030
  const schema = defineToolcraft({
1816
2031
  canvas: { enabled: true },
@@ -2567,9 +2782,16 @@ describe("ControlsPanel", () => {
2567
2782
 
2568
2783
  for (const [draftValue, expectedValue, expectedLabel] of [
2569
2784
  ["0/1", '"shape.range":[0,1]', "0% – 1%"],
2785
+ ["3/5", '"shape.range":[3,5]', "3% – 5%"],
2786
+ ["1-6", '"shape.range":[1,6]', "1% – 6%"],
2570
2787
  ["2-3", '"shape.range":[2,3]', "2% – 3%"],
2571
2788
  ["4 - 5", '"shape.range":[4,5]', "4% – 5%"],
2789
+ ["30%-150%", '"shape.range":[30,100]', "30% – 100%"],
2790
+ ["30% - 90%", '"shape.range":[30,90]', "30% – 90%"],
2791
+ ["30 % - 90 %", '"shape.range":[30,90]', "30% – 90%"],
2572
2792
  ["6–7", '"shape.range":[6,7]', "6% – 7%"],
2793
+ ["7—8", '"shape.range":[7,8]', "7% – 8%"],
2794
+ ["8−9", '"shape.range":[8,9]', "8% – 9%"],
2573
2795
  ] as const) {
2574
2796
  fireEvent.click(screen.getByRole("button", { name: "Edit Range value" }));
2575
2797
  const editor = screen.getByRole("textbox", { name: "Range value" });
@@ -2708,7 +2930,7 @@ describe("ControlsPanel", () => {
2708
2930
  },
2709
2931
  });
2710
2932
 
2711
- renderControlsPanelWithSchema(schema, undefined, {
2933
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2712
2934
  layers: [
2713
2935
  {
2714
2936
  id: "layer-1",
@@ -2733,9 +2955,66 @@ describe("ControlsPanel", () => {
2733
2955
  });
2734
2956
 
2735
2957
  expect(screen.getByRole("img", { name: "material.svg" })).toBeTruthy();
2958
+ const transformActions = container.querySelector(
2959
+ '[data-slot="actions-control"][aria-label="Image transforms"]',
2960
+ );
2961
+ const transformActionButtons = transformActions?.querySelector(
2962
+ '[data-slot="actions-control-buttons"]',
2963
+ );
2964
+
2965
+ expect(transformActions).toBeTruthy();
2966
+ expect(transformActions?.parentElement?.style.gap).toBe("6px");
2967
+ expect(transformActionButtons?.className).toContain("grid-cols-3");
2968
+ expect(transformActionButtons?.getAttribute("data-actions-columns")).toBe("3");
2969
+ expect(screen.queryByRole("button", { name: "90° Left" })).toBeNull();
2970
+
2971
+ for (const [name, visibleLabel] of [
2972
+ ["90° Right", "90°"],
2973
+ ["Flip horizontal", "Flip H"],
2974
+ ["Flip vertical", "Flip V"],
2975
+ ] as const) {
2976
+ const button = screen.getByRole("button", { name });
2977
+
2978
+ expect(button).toBeTruthy();
2979
+ expect(button.textContent).toContain(visibleLabel);
2980
+ }
2736
2981
  expect(
2737
2982
  screen.queryByText("Click to upload an image"),
2738
2983
  ).toBeNull();
2984
+ const initialPreviewFrameStyle = container
2985
+ .querySelector('[data-slot="file-upload-preview-frame"]')
2986
+ ?.getAttribute("style");
2987
+
2988
+ expect(initialPreviewFrameStyle).toBe(
2989
+ "aspect-ratio: 96 / 64; max-height: 196px; width: 100%;",
2990
+ );
2991
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
2992
+ "transform: translate(-50%, -50%);",
2993
+ );
2994
+
2995
+ fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
2996
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
2997
+ '[{"rotationDeg":90}]',
2998
+ );
2999
+ expect(
3000
+ container.querySelector('[data-slot="file-upload-preview-frame"]')?.getAttribute("style"),
3001
+ ).toBe(initialPreviewFrameStyle);
3002
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
3003
+ "transform: translate(-50%, -50%) rotate(90deg); width: 66.6667%;",
3004
+ );
3005
+
3006
+ fireEvent.click(screen.getByRole("button", { name: "Flip horizontal" }));
3007
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3008
+ '[{"rotationDeg":90,"flipHorizontal":true}]',
3009
+ );
3010
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
3011
+ "transform: translate(-50%, -50%) rotate(90deg) scale(-1, 1); width: 66.6667%;",
3012
+ );
3013
+
3014
+ fireEvent.click(screen.getByRole("button", { name: "Flip vertical" }));
3015
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3016
+ '[{"rotationDeg":90,"flipHorizontal":true,"flipVertical":true}]',
3017
+ );
2739
3018
 
2740
3019
  fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
2741
3020
 
@@ -2744,6 +3023,113 @@ describe("ControlsPanel", () => {
2744
3023
  expect(screen.getByText("Click to upload an image")).toBeTruthy();
2745
3024
  });
2746
3025
 
3026
+ it("renders schema default media as a removable fileDrop attachment", () => {
3027
+ const schema = defineToolcraft({
3028
+ canvas: { enabled: true, upload: true },
3029
+ media: {
3030
+ defaultAssets: [
3031
+ {
3032
+ dataUrl:
3033
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64' viewBox='0 0 96 64'%3E%3Crect width='96' height='64' fill='%23777'/%3E%3C/svg%3E",
3034
+ fileName: "default-background.svg",
3035
+ id: "default-background",
3036
+ layerId: "default-background-layer",
3037
+ mimeType: "image/svg+xml",
3038
+ size: { height: 64, unit: "px", width: 96 },
3039
+ sourceTarget: "input.source",
3040
+ },
3041
+ ],
3042
+ },
3043
+ panels: {
3044
+ controls: {
3045
+ sections: [
3046
+ {
3047
+ controls: {
3048
+ source: {
3049
+ accept: "PNG, SVG",
3050
+ label: "Image",
3051
+ target: "input.source",
3052
+ type: "fileDrop",
3053
+ },
3054
+ },
3055
+ layout: "standalone",
3056
+ title: "Input",
3057
+ },
3058
+ ],
3059
+ title: "Generation Controls",
3060
+ },
3061
+ },
3062
+ });
3063
+
3064
+ renderControlsPanelWithSchema(schema);
3065
+
3066
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3067
+ expect(screen.getByTestId("media-ids").textContent).toBe("default-background");
3068
+
3069
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
3070
+
3071
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
3072
+ expect(screen.queryByRole("img", { name: "default-background.svg" })).toBeNull();
3073
+ expect(screen.getByText("Click to upload an image")).toBeTruthy();
3074
+
3075
+ fireEvent.click(screen.getByRole("button", { name: "Reset Input section" }));
3076
+
3077
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3078
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3079
+
3080
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
3081
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3082
+
3083
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3084
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3085
+ });
3086
+
3087
+ it("keeps the current editable canvas size when uploading an image through fileDrop", async () => {
3088
+ const schema = defineToolcraft({
3089
+ canvas: {
3090
+ enabled: true,
3091
+ size: { height: 720, unit: "px", width: 1280 },
3092
+ upload: true,
3093
+ },
3094
+ panels: {
3095
+ controls: {
3096
+ sections: [
3097
+ {
3098
+ controls: {
3099
+ source: {
3100
+ label: "Image",
3101
+ target: "input.source",
3102
+ type: "fileDrop",
3103
+ },
3104
+ },
3105
+ layout: "standalone",
3106
+ title: "Input",
3107
+ },
3108
+ ],
3109
+ title: "Generation Controls",
3110
+ },
3111
+ },
3112
+ });
3113
+ const { container } = renderControlsPanelWithSchema(schema);
3114
+ const input = container.querySelector<HTMLInputElement>('input[type="file"]');
3115
+
3116
+ expect(input).toBeTruthy();
3117
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1280,720");
3118
+
3119
+ fireEvent.change(input as HTMLInputElement, {
3120
+ target: {
3121
+ files: [new File(["image"], "wide.png", { type: "image/png" })],
3122
+ },
3123
+ });
3124
+
3125
+ await waitFor(() => {
3126
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3127
+ });
3128
+
3129
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1280,720");
3130
+ expect(screen.getByTestId("media-sizes").textContent).toBe("1280x720");
3131
+ });
3132
+
2747
3133
  it("removes single-layer file uploads from the canvas and returns the file control to empty", () => {
2748
3134
  const schema = defineToolcraft({
2749
3135
  canvas: { enabled: true, upload: true },
@@ -2878,6 +3264,7 @@ describe("ControlsPanel", () => {
2878
3264
  expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2879
3265
  expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2880
3266
  expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2");
3267
+ expect(screen.queryByRole("button", { name: "90° Left" })).toBeNull();
2881
3268
 
2882
3269
  const firstPreview = screen
2883
3270
  .getByRole("img", { name: "one.svg" })
@@ -2894,6 +3281,15 @@ describe("ControlsPanel", () => {
2894
3281
  expect(firstPreview.getAttribute("tabindex")).toBe("0");
2895
3282
  expect(firstPreview.className).toContain("cursor-grab");
2896
3283
 
3284
+ fireEvent.click(firstPreview);
3285
+ expect(firstPreview.getAttribute("data-selected")).toBe("true");
3286
+ expect(screen.getByRole("button", { name: "90° Right" })).toBeTruthy();
3287
+
3288
+ fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
3289
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3290
+ '[{"rotationDeg":90},null]',
3291
+ );
3292
+
2897
3293
  fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2898
3294
 
2899
3295
  expect(screen.getByTestId("media-count").textContent).toBe("1");
@@ -3863,7 +4259,10 @@ describe("ControlsPanel", () => {
3863
4259
  fireEvent.change(promptInput, { target: { value: "Updated prompt" } });
3864
4260
  expect(screen.getByTestId("prompt-value").textContent).toBe("Updated prompt");
3865
4261
 
3866
- fireEvent.click(screen.getByRole("switch"));
4262
+ const enabledField = screen.getByText("Enabled").closest('[role="group"]');
4263
+ const enabledSwitch = enabledField?.querySelector<HTMLElement>('[role="switch"]');
4264
+
4265
+ fireEvent.click(enabledSwitch as HTMLElement);
3867
4266
  expect(screen.getByTestId("enabled-value").textContent).toBe("false");
3868
4267
 
3869
4268
  fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
@@ -4313,6 +4712,54 @@ describe("ControlsPanel", () => {
4313
4712
  expect(values["canvas.renderScale"]).toBe(2);
4314
4713
  });
4315
4714
 
4715
+ it("renders timeline extended mode as runtime panel state instead of product values", () => {
4716
+ const schema = defineToolcraft({
4717
+ canvas: {
4718
+ enabled: true,
4719
+ renderScale: true,
4720
+ size: { height: 1080, unit: "px", width: 1920 },
4721
+ sizing: { mode: "editable-output" },
4722
+ },
4723
+ panels: {
4724
+ controls: {
4725
+ sections: [],
4726
+ title: "Controls",
4727
+ },
4728
+ timeline: { mode: "playback" },
4729
+ },
4730
+ });
4731
+
4732
+ renderControlsPanelWithSchema(schema, undefined, {
4733
+ timeline: { currentTimeSeconds: 2, expanded: true, isPlaying: true },
4734
+ });
4735
+
4736
+ const setupText = screen.getByText("Timeline").closest("section")?.textContent ?? "";
4737
+
4738
+ expect(setupText.indexOf("Resolution scale")).toBeLessThan(setupText.indexOf("Timeline"));
4739
+ const timelineField = screen.getByText("Timeline").closest('[role="group"]');
4740
+ const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
4741
+
4742
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("false");
4743
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("undefined");
4744
+ expect(screen.getByTestId("timeline-panel-hidden").textContent).toBe("undefined");
4745
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
4746
+
4747
+ fireEvent.click(timelineSwitch as HTMLElement);
4748
+
4749
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
4750
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("true");
4751
+ expect(screen.getByTestId("timeline-panel-hidden").textContent).toBe("undefined");
4752
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
4753
+ expect(JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")).not.toHaveProperty(
4754
+ "panels.timeline.extended",
4755
+ );
4756
+
4757
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
4758
+
4759
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
4760
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("true");
4761
+ });
4762
+
4316
4763
  it("applies empty text input values while typing and reset restores defaults", () => {
4317
4764
  renderControlsPanel();
4318
4765
 
@@ -4549,7 +4996,7 @@ describe("ControlsPanel", () => {
4549
4996
  expect(handledActions).toEqual(["download:Generation Controls"]);
4550
4997
  });
4551
4998
 
4552
- it("renders runtime settings transfer as a body section instead of sticky footer actions", () => {
4999
+ it("renders runtime settings transfer as a headerless body section instead of sticky footer actions", () => {
4553
5000
  const schema = defineToolcraft({
4554
5001
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
4555
5002
  panels: {
@@ -4613,10 +5060,12 @@ describe("ControlsPanel", () => {
4613
5060
  ).not.toContain("Setup");
4614
5061
  expect(firstSection?.querySelector('[data-slot="panel-title"]')).toBeNull();
4615
5062
  expect(firstSection?.querySelector("[data-control-section-collapse-button]")).toBeNull();
5063
+ expect(firstSection?.querySelector('[data-slot="control-section-header"]')).toBeNull();
5064
+ expect(firstSection?.querySelector('[data-slot="panel-section-collapsible-body"]')).toBeNull();
5065
+ expect(firstSection?.getAttribute("data-collapsed")).toBeNull();
4616
5066
  expect(firstSection?.className).toContain("py-0");
4617
- expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("py-3");
4618
- expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pt-2");
4619
- expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pb-5");
5067
+ expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("pt-2");
5068
+ expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("pb-6");
4620
5069
  expect(firstInlineGroup?.textContent).toContain("Canvas width");
4621
5070
  expect(firstInlineGroup?.textContent).toContain("Canvas height");
4622
5071
  expect(firstSection?.textContent).toContain("Aspect ratio");
@@ -4858,10 +5307,12 @@ describe("ControlsPanel", () => {
4858
5307
  const stickyFooter = container.querySelector(
4859
5308
  '[data-slot="toolcraft-panel-sticky-actions"]',
4860
5309
  );
5310
+ const exportPngButton = screen.getByRole("button", { name: "Export PNG" });
4861
5311
 
4862
5312
  expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBeNull();
5313
+ expect(exportPngButton.querySelector('[data-icon-name="upload-simple"]')).toBeTruthy();
4863
5314
 
4864
- fireEvent.click(screen.getByRole("button", { name: "Export PNG" }));
5315
+ fireEvent.click(exportPngButton);
4865
5316
 
4866
5317
  expect(onPanelAction).toHaveBeenCalledTimes(1);
4867
5318
  await waitFor(() => {