@pixel-point/toolcraft 0.0.3 → 0.0.6

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 (47) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +86 -8
  6. package/templates/runtime/contracts/component-contracts.ts +36 -8
  7. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  11. package/templates/runtime/react/controls-panel.test.tsx +323 -6
  12. package/templates/runtime/react/controls-panel.tsx +349 -24
  13. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  14. package/templates/runtime/react/settings-transfer.ts +28 -2
  15. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  16. package/templates/runtime/react/timeline-panel.tsx +98 -10
  17. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  18. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  19. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  20. package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
  21. package/templates/runtime/schema/define-toolcraft.ts +197 -6
  22. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  23. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  24. package/templates/runtime/schema/runtime-targets.ts +6 -0
  25. package/templates/runtime/schema/types.ts +23 -1
  26. package/templates/runtime/state/canvas-zoom.ts +1 -1
  27. package/templates/runtime/state/create-template-state.test.ts +9 -3
  28. package/templates/runtime/state/reducer.test.ts +135 -2
  29. package/templates/runtime/state/reducer.ts +236 -12
  30. package/templates/runtime/state/types.ts +1 -0
  31. package/templates/starter/AGENTS.md +6 -4
  32. package/templates/starter/docs/toolcraft/README.md +1 -1
  33. package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
  34. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
  35. package/templates/starter/docs/toolcraft/component-rules.md +24 -5
  36. package/templates/starter/docs/toolcraft/performance.md +5 -0
  37. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  38. package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
  39. package/templates/starter/gitignore +36 -0
  40. package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
  41. package/templates/starter/src/app/starter-acceptance.ts +357 -4
  42. package/templates/ui/components/control-layout/index.tsx +4 -4
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  45. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  46. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  47. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -11,7 +11,6 @@ import {
11
11
  } from 'react';
12
12
  import {
13
13
  Button,
14
- EditableSliderValueLabel,
15
14
  PanelSurface,
16
15
  Popover,
17
16
  PopoverContent,
@@ -74,7 +73,7 @@ const timelineRulerRightInsetPx = timelineTrackColumnBorderWidthPx / 2;
74
73
  const timelineRowActionColumnWidthPx = 36;
75
74
  const timelineExpandedTrackEndOffsetPx =
76
75
  timelineTrackEndInsetPx + timelineRowActionColumnWidthPx + timelineTrackColumnBorderWidthPx;
77
- const timelinePlayheadSafeZonePx = 3;
76
+ const timelinePlayheadSafeZonePx = 7;
78
77
  const timelinePlayheadHitAreaWidthPx =
79
78
  timelineTrackColumnBorderWidthPx + timelinePlayheadSafeZonePx * 2;
80
79
  const maxTimelineDurationSeconds = 60;
@@ -1667,7 +1666,7 @@ function TimelinePlaybackStrip({
1667
1666
  <span
1668
1667
  aria-hidden="true"
1669
1668
  className={cn(
1670
- 'absolute bottom-px size-2 -translate-x-1/2 translate-y-1/2 rounded-full bg-[color:var(--link)] opacity-0 shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_70%,transparent)] transition-[opacity,transform] duration-[120ms] ease-out group-hover/timeline-panel-surface:opacity-100 group-hover/timeline-strip:opacity-100 group-focus-visible/timeline-strip:opacity-100',
1669
+ 'absolute bottom-px size-2 -translate-x-1/2 translate-y-1/2 rounded-full bg-[color:var(--link)] opacity-0 shadow-[0_2px_2px_color-mix(in_oklab,var(--background)_70%,transparent)] transition-[opacity,transform] duration-[120ms] ease-out before:absolute before:inset-[-4px] before:content-[""] group-hover/timeline-panel-surface:opacity-100 group-hover/timeline-strip:opacity-100 group-focus-visible/timeline-strip:opacity-100',
1671
1670
  isScrubbing && 'scale-110 opacity-100',
1672
1671
  )}
1673
1672
  data-slot="timeline-playback-handle"
@@ -2498,6 +2497,99 @@ function useTimelineScrubber({
2498
2497
  };
2499
2498
  }
2500
2499
 
2500
+ function selectTimelineEditableText(node: HTMLElement): void {
2501
+ const selection = window.getSelection();
2502
+
2503
+ if (!selection) {
2504
+ return;
2505
+ }
2506
+
2507
+ const range = document.createRange();
2508
+ range.selectNodeContents(node);
2509
+ selection.removeAllRanges();
2510
+ selection.addRange(range);
2511
+ }
2512
+
2513
+ function TimelineDurationValue({
2514
+ durationSeconds,
2515
+ onCommit,
2516
+ }: {
2517
+ durationSeconds: number;
2518
+ onCommit: (value: string) => void;
2519
+ }): React.JSX.Element {
2520
+ const [isEditing, setIsEditing] = useState(false);
2521
+ const editorRef = useRef<HTMLSpanElement>(null);
2522
+ const valueLabel = formatDurationValueLabel(durationSeconds);
2523
+
2524
+ useEffect(() => {
2525
+ if (!isEditing) {
2526
+ return;
2527
+ }
2528
+
2529
+ const editor = editorRef.current;
2530
+
2531
+ if (!editor) {
2532
+ return;
2533
+ }
2534
+
2535
+ editor.textContent = valueLabel;
2536
+ editor.focus();
2537
+ selectTimelineEditableText(editor);
2538
+ }, [isEditing, valueLabel]);
2539
+
2540
+ function commitDraft(): void {
2541
+ onCommit(editorRef.current?.textContent ?? valueLabel);
2542
+ setIsEditing(false);
2543
+ }
2544
+
2545
+ if (isEditing) {
2546
+ return (
2547
+ <span
2548
+ aria-label="timeline duration"
2549
+ className="block h-5 min-w-[3ch] !cursor-text overflow-hidden whitespace-nowrap rounded bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] px-1 font-sans text-xs leading-5 text-[color:var(--foreground)] outline-none tabular-nums"
2550
+ contentEditable
2551
+ data-slot="timeline-duration-editor"
2552
+ key="duration-editor"
2553
+ onBlur={commitDraft}
2554
+ onFocus={(event) => selectTimelineEditableText(event.currentTarget)}
2555
+ onKeyDown={(event) => {
2556
+ if (event.key === 'Enter') {
2557
+ event.preventDefault();
2558
+ commitDraft();
2559
+ }
2560
+
2561
+ if (event.key === 'Escape') {
2562
+ event.preventDefault();
2563
+ setIsEditing(false);
2564
+ }
2565
+ }}
2566
+ onPointerDown={(event) => event.stopPropagation()}
2567
+ ref={editorRef}
2568
+ role="textbox"
2569
+ suppressContentEditableWarning
2570
+ tabIndex={0}
2571
+ />
2572
+ );
2573
+ }
2574
+
2575
+ return (
2576
+ <button
2577
+ aria-label="Edit timeline duration"
2578
+ className="block h-5 min-w-[3ch] shrink-0 !cursor-text overflow-hidden rounded px-1 font-sans text-xs leading-5 text-[color:var(--muted-foreground)] tabular-nums transition-colors duration-150 ease-out group-hover/timeline-panel-header:bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] focus-visible:bg-[color:color-mix(in_oklab,var(--foreground)_8%,transparent)] focus-visible:outline-none"
2579
+ data-slot="timeline-duration-display"
2580
+ key="duration-display"
2581
+ onClick={(event) => {
2582
+ event.stopPropagation();
2583
+ setIsEditing(true);
2584
+ }}
2585
+ onPointerDown={(event) => event.stopPropagation()}
2586
+ type="button"
2587
+ >
2588
+ {valueLabel}
2589
+ </button>
2590
+ );
2591
+ }
2592
+
2501
2593
  function TimelinePanelHeader({
2502
2594
  canExpand,
2503
2595
  currentTimeSeconds,
@@ -2538,7 +2630,7 @@ function TimelinePanelHeader({
2538
2630
  return (
2539
2631
  <div
2540
2632
  className={cn(
2541
- 'relative flex min-w-0 shrink-0 items-center gap-1',
2633
+ 'group/timeline-panel-header relative flex min-w-0 shrink-0 items-center gap-1',
2542
2634
  isExpanded
2543
2635
  ? 'h-9 border-b border-[color:color-mix(in_oklab,var(--border)_8%,transparent)] p-1'
2544
2636
  : 'h-full',
@@ -2566,13 +2658,9 @@ function TimelinePanelHeader({
2566
2658
  <TimelinePanelDivider />
2567
2659
  <div className="ml-2 inline-flex shrink-0 items-center gap-1 text-xs leading-5 text-[color:color-mix(in_oklab,var(--foreground)_90%,transparent)]">
2568
2660
  <span>{isExpanded ? 'Duration:' : 'Dur:'}</span>
2569
- <EditableSliderValueLabel
2570
- ariaLabel="timeline duration"
2571
- layout="content"
2572
- maxValueLabel={`${maxTimelineDurationSeconds}s`}
2661
+ <TimelineDurationValue
2662
+ durationSeconds={durationSeconds}
2573
2663
  onCommit={onDurationCommit}
2574
- textAlign="left"
2575
- valueLabel={formatDurationValueLabel(durationSeconds)}
2576
2664
  />
2577
2665
  </div>
2578
2666
  <span
@@ -92,25 +92,25 @@ describe("ToolbarPanel", () => {
92
92
  it("dispatches zoom commands", () => {
93
93
  renderToolbar();
94
94
 
95
- expect(screen.getByText("70%")).toBeTruthy();
95
+ expect(screen.getByText("100%")).toBeTruthy();
96
96
 
97
97
  fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
98
- expect(screen.getByText("60%")).toBeTruthy();
98
+ expect(screen.getByText("90%")).toBeTruthy();
99
99
 
100
100
  fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
101
101
  fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
102
- expect(screen.getByText("80%")).toBeTruthy();
102
+ expect(screen.getByText("110%")).toBeTruthy();
103
103
  });
104
104
 
105
105
  it("resets zoom from the zoom label double click", () => {
106
106
  renderToolbar();
107
107
 
108
108
  fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
109
- expect(screen.getByText("80%")).toBeTruthy();
109
+ expect(screen.getByText("110%")).toBeTruthy();
110
110
 
111
- fireEvent.doubleClick(screen.getByText("80%"));
111
+ fireEvent.doubleClick(screen.getByText("110%"));
112
112
 
113
- expect(screen.getByText("70%")).toBeTruthy();
113
+ expect(screen.getByText("100%")).toBeTruthy();
114
114
  });
115
115
 
116
116
  it("centers the canvas through the radar command", () => {
@@ -268,7 +268,7 @@ describe("assembled Toolcraft template runtime", () => {
268
268
  expect(screen.getByText("Layers")).toBeTruthy();
269
269
  expect(screen.getByText("Dur:")).toBeTruthy();
270
270
  expect(screen.queryByText("Duration:")).toBeNull();
271
- expect(toolbar?.textContent).toContain("70%");
271
+ expect(toolbar?.textContent).toContain("100%");
272
272
  expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled();
273
273
  expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled();
274
274
  expect(themeScope?.dataset.toolcraftTheme).toBe("dark");
@@ -305,7 +305,7 @@ describe("assembled Toolcraft template runtime", () => {
305
305
  ).toBe("320px");
306
306
 
307
307
  fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
308
- expect(screen.getByText("80%")).toBeTruthy();
308
+ expect(screen.getByText("110%")).toBeTruthy();
309
309
 
310
310
  const wheelEvent = new WheelEvent("wheel", {
311
311
  bubbles: true,
@@ -0,0 +1,50 @@
1
+ import type { ToolcraftCanvasSize } from "./types";
2
+
3
+ export type ToolcraftCanvasAspectRatioPresetValue =
4
+ | "1:1"
5
+ | "3:2"
6
+ | "16:9"
7
+ | "3:4"
8
+ | "9:16"
9
+ | "2:3"
10
+ | "4:3";
11
+
12
+ export type ToolcraftCanvasAspectRatioPreset = {
13
+ height: number;
14
+ ratioHeight: number;
15
+ ratioWidth: number;
16
+ value: ToolcraftCanvasAspectRatioPresetValue;
17
+ width: number;
18
+ };
19
+
20
+ export const toolcraftCanvasAspectRatioPresets = [
21
+ { height: 1080, ratioHeight: 1, ratioWidth: 1, value: "1:1", width: 1080 },
22
+ { height: 1080, ratioHeight: 2, ratioWidth: 3, value: "3:2", width: 1620 },
23
+ { height: 1080, ratioHeight: 9, ratioWidth: 16, value: "16:9", width: 1920 },
24
+ { height: 1440, ratioHeight: 4, ratioWidth: 3, value: "3:4", width: 1080 },
25
+ { height: 1920, ratioHeight: 16, ratioWidth: 9, value: "9:16", width: 1080 },
26
+ { height: 1620, ratioHeight: 3, ratioWidth: 2, value: "2:3", width: 1080 },
27
+ { height: 1080, ratioHeight: 3, ratioWidth: 4, value: "4:3", width: 1440 },
28
+ ] as const satisfies readonly ToolcraftCanvasAspectRatioPreset[];
29
+
30
+ export const toolcraftCanvasAspectRatioPresetValues = new Set<string>(
31
+ toolcraftCanvasAspectRatioPresets.map((preset) => preset.value),
32
+ );
33
+
34
+ export function getToolcraftCanvasAspectRatioPreset(
35
+ value: string,
36
+ ): ToolcraftCanvasAspectRatioPreset | null {
37
+ return (
38
+ toolcraftCanvasAspectRatioPresets.find((preset) => preset.value === value) ?? null
39
+ );
40
+ }
41
+
42
+ export function getToolcraftCanvasAspectRatioPresetBySize(
43
+ size: ToolcraftCanvasSize,
44
+ ): ToolcraftCanvasAspectRatioPreset | null {
45
+ return (
46
+ toolcraftCanvasAspectRatioPresets.find(
47
+ (preset) => preset.width === size.width && preset.height === size.height,
48
+ ) ?? null
49
+ );
50
+ }
@@ -10,6 +10,7 @@ import {
10
10
  toolcraftRuntimeOwnedTargets,
11
11
  getToolcraftCanvasSizeTargetDimension,
12
12
  isToolcraftReservedTarget,
13
+ isToolcraftRuntimeOwnedTarget,
13
14
  } from "./runtime-targets";
14
15
 
15
16
  describe("defineToolcraft", () => {
@@ -46,7 +47,7 @@ describe("defineToolcraft", () => {
46
47
  });
47
48
 
48
49
  expect(app.canvas.draggable).toBe(true);
49
- expect(app.canvas.size).toEqual({ height: 1024, unit: "px", width: 1024 });
50
+ expect(app.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
50
51
  expect(app.canvas.sizeSource).toBe("runtime-default");
51
52
  expect(app.canvas.sizing).toEqual({ mode: "intrinsic-media" });
52
53
  expect(app.toolbar).toEqual({ history: true, radar: true, theme: true, zoom: true });
@@ -111,6 +112,10 @@ describe("defineToolcraft", () => {
111
112
  mode: "auto",
112
113
  });
113
114
  expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
115
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
116
+ target: "canvas.aspectRatio",
117
+ type: "aspectRatio",
118
+ });
114
119
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
115
120
  });
116
121
 
@@ -231,7 +236,7 @@ describe("defineToolcraft", () => {
231
236
  });
232
237
  });
233
238
 
234
- it("ignores runtime canvas size controls in settings transfer eligibility", () => {
239
+ it("ignores runtime canvas setup controls in settings transfer eligibility", () => {
235
240
  const app = defineToolcraft({
236
241
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
237
242
  panels: {
@@ -248,6 +253,7 @@ describe("defineToolcraft", () => {
248
253
  settingsTransfer: false,
249
254
  });
250
255
 
256
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toBeTruthy();
251
257
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
252
258
  expect(app.panels.controls?.sections[0]?.controls.canvasHeight).toBeTruthy();
253
259
  expect(getToolcraftSettingsTransferEligibility({ panels: app.panels })).toEqual({
@@ -326,9 +332,20 @@ describe("defineToolcraft", () => {
326
332
  expect(runtimeSettingsSection?.title).toBe("Setup");
327
333
  expect(Object.keys(runtimeSettingsSection?.controls ?? {})).toEqual([
328
334
  "settingsTransfer",
335
+ "canvasAspectRatio",
329
336
  "canvasWidth",
330
337
  "canvasHeight",
331
338
  ]);
339
+ expect(runtimeSettingsSection?.controls.canvasAspectRatio).toMatchObject({
340
+ defaultValue: {
341
+ height: 9,
342
+ mode: "preset",
343
+ value: "16:9",
344
+ width: 16,
345
+ },
346
+ target: "canvas.aspectRatio",
347
+ type: "aspectRatio",
348
+ });
332
349
  expect(runtimeSettingsSection?.controls.canvasWidth).toBeTruthy();
333
350
  expect(runtimeSettingsSection?.controls.canvasHeight).toBeTruthy();
334
351
  expect(runtimeSettingsSection?.controls.settingsTransfer?.type).toBe(
@@ -594,10 +611,14 @@ describe("defineToolcraft", () => {
594
611
 
595
612
  it("publishes reserved targets for AI assembly boundaries", () => {
596
613
  expect(toolcraftRuntimeOwnedTargets).toEqual([
614
+ "canvas.aspectRatio",
615
+ "canvas.renderScale",
597
616
  "canvas.size.width",
598
617
  "canvas.size.height",
599
618
  ]);
600
619
  expect(toolcraftReservedTargets).toEqual([
620
+ "canvas.aspectRatio",
621
+ "canvas.renderScale",
601
622
  "canvas.size.width",
602
623
  "canvas.size.height",
603
624
  "selectedLayer.opacity",
@@ -606,6 +627,7 @@ describe("defineToolcraft", () => {
606
627
  expect(getToolcraftCanvasSizeTargetDimension("canvas.size.width")).toBe("width");
607
628
  expect(getToolcraftCanvasSizeTargetDimension("canvas.size.height")).toBe("height");
608
629
  expect(getToolcraftCanvasSizeTargetDimension("generation.prompt")).toBeNull();
630
+ expect(isToolcraftRuntimeOwnedTarget("canvas.aspectRatio")).toBe(true);
609
631
  expect(isToolcraftReservedTarget("selectedLayer.opacity")).toBe(true);
610
632
  expect(isToolcraftReservedTarget("generation.prompt")).toBe(false);
611
633
  });
@@ -674,6 +696,18 @@ describe("defineToolcraft", () => {
674
696
  expect(app.canvas.sizeSource).toBe("app");
675
697
  expect(app.canvas.sizing).toEqual({ mode: "editable-output" });
676
698
  expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
699
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
700
+ defaultValue: {
701
+ height: 256,
702
+ mode: "custom",
703
+ value: "455:256",
704
+ width: 455,
705
+ },
706
+ label: "Aspect ratio",
707
+ performanceRole: "workload",
708
+ target: "canvas.aspectRatio",
709
+ type: "aspectRatio",
710
+ });
677
711
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toMatchObject({
678
712
  defaultValue: 1365,
679
713
  performanceRole: "workload",
@@ -724,6 +758,18 @@ describe("defineToolcraft", () => {
724
758
  const [canvasSection, generationSection] = app.panels.controls?.sections ?? [];
725
759
 
726
760
  expect(canvasSection?.title).toBe("Setup");
761
+ expect(canvasSection?.controls.canvasAspectRatio).toMatchObject({
762
+ defaultValue: {
763
+ height: 5,
764
+ mode: "custom",
765
+ value: "8:5",
766
+ width: 8,
767
+ },
768
+ label: "Aspect ratio",
769
+ performanceRole: "workload",
770
+ target: "canvas.aspectRatio",
771
+ type: "aspectRatio",
772
+ });
727
773
  expect(canvasSection?.controls.canvasWidth).toMatchObject({
728
774
  defaultValue: 1440,
729
775
  label: "Canvas width",
@@ -748,6 +794,80 @@ describe("defineToolcraft", () => {
748
794
  expect(generationSection?.title).toBe("Generation");
749
795
  });
750
796
 
797
+ it("appends raster render scale to the technical setup controls when enabled", () => {
798
+ const app = defineToolcraft({
799
+ canvas: {
800
+ enabled: true,
801
+ renderScale: true,
802
+ size: { height: 900, unit: "px", width: 1440 },
803
+ sizing: { mode: "editable-output" },
804
+ },
805
+ panels: {
806
+ controls: {
807
+ sections: [],
808
+ title: "Controls",
809
+ },
810
+ },
811
+ });
812
+
813
+ const setupSection = app.panels.controls?.sections[0];
814
+
815
+ expect(app.canvas.renderScale).toEqual({
816
+ defaultValue: 2,
817
+ enabled: true,
818
+ max: 2,
819
+ min: 1,
820
+ step: 0.25,
821
+ });
822
+ expect(app.assembly.capabilities).toContain("canvas.renderScale");
823
+ expect(Object.keys(setupSection?.controls ?? {})).toEqual([
824
+ "canvasAspectRatio",
825
+ "canvasWidth",
826
+ "canvasHeight",
827
+ "canvasRenderScale",
828
+ ]);
829
+ expect(setupSection?.controls.canvasRenderScale).toMatchObject({
830
+ defaultValue: 2,
831
+ label: "Resolution scale",
832
+ markerCount: 5,
833
+ max: 2,
834
+ min: 1,
835
+ step: 0.25,
836
+ target: "canvas.renderScale",
837
+ type: "slider",
838
+ unit: "x",
839
+ variant: "discrete",
840
+ });
841
+ });
842
+
843
+ it("can prepend render scale without editable output sizing", () => {
844
+ const app = defineToolcraft({
845
+ canvas: {
846
+ enabled: true,
847
+ renderScale: { defaultValue: 1.5 },
848
+ size: { height: 900, unit: "px", width: 1440 },
849
+ sizing: { mode: "fixed-output" },
850
+ },
851
+ panels: {
852
+ controls: {
853
+ sections: [],
854
+ title: "Controls",
855
+ },
856
+ },
857
+ });
858
+
859
+ expect(app.panels.controls?.sections[0]).toMatchObject({
860
+ controls: {
861
+ canvasRenderScale: {
862
+ defaultValue: 1.5,
863
+ target: "canvas.renderScale",
864
+ type: "slider",
865
+ },
866
+ },
867
+ title: "Setup",
868
+ });
869
+ });
870
+
751
871
  it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
752
872
  const intrinsicApp = defineToolcraft({
753
873
  canvas: {