@pixel-point/toolcraft 0.0.4 → 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 (39) 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 +59 -2
  6. package/templates/runtime/contracts/component-contracts.ts +23 -2
  7. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  8. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  9. package/templates/runtime/react/controls-panel.test.tsx +269 -0
  10. package/templates/runtime/react/controls-panel.tsx +133 -24
  11. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  12. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  13. package/templates/runtime/react/timeline-panel.tsx +98 -10
  14. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  15. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  16. package/templates/runtime/schema/define-toolcraft.test.ts +77 -1
  17. package/templates/runtime/schema/define-toolcraft.ts +138 -5
  18. package/templates/runtime/schema/runtime-targets.ts +1 -0
  19. package/templates/runtime/schema/types.ts +23 -1
  20. package/templates/runtime/state/canvas-zoom.ts +1 -1
  21. package/templates/runtime/state/create-template-state.test.ts +6 -6
  22. package/templates/runtime/state/reducer.test.ts +86 -8
  23. package/templates/runtime/state/reducer.ts +41 -22
  24. package/templates/runtime/state/types.ts +1 -0
  25. package/templates/starter/AGENTS.md +2 -2
  26. package/templates/starter/docs/toolcraft/README.md +1 -1
  27. package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
  28. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  29. package/templates/starter/docs/toolcraft/component-rules.md +13 -1
  30. package/templates/starter/docs/toolcraft/performance.md +5 -0
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  32. package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
  33. package/templates/starter/gitignore +36 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  35. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  36. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  37. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  38. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  39. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -42,6 +42,7 @@ import {
42
42
  type ColorOpacityValue,
43
43
  type CurveInterpolation,
44
44
  type FontPickerValue,
45
+ type FileDropPreview,
45
46
  type GradientStop,
46
47
  type GradientType,
47
48
  type ImagePickerItem,
@@ -1691,12 +1692,94 @@ export function ControlsPanel({
1691
1692
  );
1692
1693
  }
1693
1694
 
1694
- function getControlHelpText(
1695
- control: ToolcraftControlSchema,
1696
- ): string | null {
1695
+ function normalizeControlHelpContext(value: string): string {
1696
+ return value
1697
+ .toLowerCase()
1698
+ .replace(/[^a-z0-9]+/g, " ")
1699
+ .trim();
1700
+ }
1701
+
1702
+ function isColorSectionTitle(sectionTitle: string | undefined): boolean {
1703
+ return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1704
+ }
1705
+
1706
+ function isSequentialColorLabel(label: string): boolean {
1707
+ return /^colou?r\s+\d+$/i.test(label.trim());
1708
+ }
1709
+
1710
+ function isSimplePaletteDistributionLabel(label: string): boolean {
1711
+ return /^(spread|mix|distribution)$/i.test(label.trim());
1712
+ }
1713
+
1714
+ function isGenericControlHelpDescription(description: string): boolean {
1715
+ return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
1716
+ description.trim(),
1717
+ );
1718
+ }
1719
+
1720
+ function shouldSuppressObviousControlHelp({
1721
+ control,
1722
+ description,
1723
+ label,
1724
+ sectionTitle,
1725
+ }: {
1726
+ control: ToolcraftControlSchema;
1727
+ description: string;
1728
+ label: string;
1729
+ sectionTitle: string | undefined;
1730
+ }): boolean {
1731
+ const isColorControl = control.type === "color" || control.type === "colorOpacity";
1732
+
1733
+ if (isColorSectionTitle(sectionTitle)) {
1734
+ if (isColorControl && isSequentialColorLabel(label)) {
1735
+ return true;
1736
+ }
1737
+
1738
+ if (
1739
+ isSimplePaletteDistributionLabel(label) &&
1740
+ isGenericControlHelpDescription(description)
1741
+ ) {
1742
+ return true;
1743
+ }
1744
+ }
1745
+
1746
+ const normalizedDescription = normalizeControlHelpContext(description);
1747
+ const normalizedLabel = normalizeControlHelpContext(label);
1748
+
1749
+ return (
1750
+ Boolean(normalizedLabel) &&
1751
+ isGenericControlHelpDescription(description) &&
1752
+ normalizedDescription === normalizedLabel
1753
+ );
1754
+ }
1755
+
1756
+ function getControlHelpText({
1757
+ control,
1758
+ label,
1759
+ sectionTitle,
1760
+ }: {
1761
+ control: ToolcraftControlSchema;
1762
+ label: string;
1763
+ sectionTitle: string | undefined;
1764
+ }): string | null {
1697
1765
  const description = control.description?.trim();
1698
1766
 
1699
- return description || null;
1767
+ if (!description) {
1768
+ return null;
1769
+ }
1770
+
1771
+ if (
1772
+ shouldSuppressObviousControlHelp({
1773
+ control,
1774
+ description,
1775
+ label,
1776
+ sectionTitle,
1777
+ })
1778
+ ) {
1779
+ return null;
1780
+ }
1781
+
1782
+ return description;
1700
1783
  }
1701
1784
 
1702
1785
  function withControlLabelHelp({
@@ -1704,13 +1787,15 @@ export function ControlsPanel({
1704
1787
  control,
1705
1788
  label,
1706
1789
  providerKey,
1790
+ sectionTitle,
1707
1791
  }: {
1708
1792
  children: React.ReactNode;
1709
1793
  control: ToolcraftControlSchema;
1710
1794
  label: string;
1711
1795
  providerKey: string;
1796
+ sectionTitle: string | undefined;
1712
1797
  }): React.ReactNode {
1713
- const help = getControlHelpText(control);
1798
+ const help = getControlHelpText({ control, label, sectionTitle });
1714
1799
 
1715
1800
  if (!help) {
1716
1801
  return children;
@@ -2129,13 +2214,38 @@ export function ControlsPanel({
2129
2214
  }
2130
2215
 
2131
2216
  case "fileDrop": {
2132
- const previewMediaAsset = state.schema.panels.layers
2133
- ? undefined
2134
- : state.mediaAssets[0];
2217
+ const previewMediaAssets = state.schema.panels.layers ? [] : state.mediaAssets;
2218
+ const previewMediaAsset = previewMediaAssets[0];
2219
+ const previews = previewMediaAssets.map((asset): FileDropPreview => ({
2220
+ alt: asset.fileName,
2221
+ id: asset.id,
2222
+ size: asset.size,
2223
+ src: asset.dataUrl,
2224
+ }));
2225
+ const importFile = (file: File, replaceExisting: boolean): void => {
2226
+ void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2227
+ if (!importedImage) {
2228
+ return;
2229
+ }
2230
+
2231
+ dispatchCommand({
2232
+ asset: {
2233
+ dataUrl: importedImage.dataUrl,
2234
+ fileName: file.name,
2235
+ mimeType: file.type || "image/*",
2236
+ position: { x: 0, y: 0 },
2237
+ size: importedImage.size,
2238
+ },
2239
+ replaceExisting,
2240
+ type: "media.import",
2241
+ });
2242
+ });
2243
+ };
2135
2244
 
2136
2245
  return (
2137
2246
  <FileDrop
2138
2247
  accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
2248
+ multiple={control.multiple}
2139
2249
  key={id}
2140
2250
  onClear={
2141
2251
  previewMediaAsset
@@ -2147,33 +2257,31 @@ export function ControlsPanel({
2147
2257
  }
2148
2258
  : undefined
2149
2259
  }
2150
- onFileSelect={(file) => {
2151
- void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2152
- if (!importedImage) {
2153
- return;
2154
- }
2155
-
2156
- dispatchCommand({
2157
- asset: {
2158
- dataUrl: importedImage.dataUrl,
2159
- fileName: file.name,
2160
- mimeType: file.type || "image/*",
2161
- position: { x: 0, y: 0 },
2162
- size: importedImage.size,
2163
- },
2164
- type: "media.import",
2165
- });
2260
+ onFilesSelect={(files) => {
2261
+ files.forEach((file) => importFile(file, false));
2262
+ }}
2263
+ onFileSelect={(file) => importFile(file, true)}
2264
+ onPreviewRemove={(item) => {
2265
+ if (!item.id) {
2266
+ return;
2267
+ }
2268
+
2269
+ dispatchCommand({
2270
+ mediaId: item.id,
2271
+ type: "media.delete",
2166
2272
  });
2167
2273
  }}
2168
2274
  preview={
2169
2275
  previewMediaAsset
2170
2276
  ? {
2277
+ id: previewMediaAsset.id,
2171
2278
  alt: previewMediaAsset.fileName,
2172
2279
  size: previewMediaAsset.size,
2173
2280
  src: previewMediaAsset.dataUrl,
2174
2281
  }
2175
2282
  : undefined
2176
2283
  }
2284
+ previews={previews}
2177
2285
  />
2178
2286
  );
2179
2287
  }
@@ -2514,6 +2622,7 @@ export function ControlsPanel({
2514
2622
  control,
2515
2623
  label: name,
2516
2624
  providerKey: id,
2625
+ sectionTitle: section.title,
2517
2626
  }),
2518
2627
  control,
2519
2628
  }),
@@ -131,10 +131,10 @@ describe("settings transfer", () => {
131
131
  expect(state.values["generation.prompt"]).toBe("Imported prompt");
132
132
  expect(state.values["unknown.target"]).toBeUndefined();
133
133
  expect(state.values["canvas.aspectRatio"]).toEqual({
134
- height: 900,
134
+ height: 9,
135
135
  mode: "custom",
136
- value: "1600:900",
137
- width: 1600,
136
+ value: "16:9",
137
+ width: 16,
138
138
  });
139
139
  expect(state.canvas.size).toEqual({ height: 900, unit: "px", width: 1600 });
140
140
  expect(state.timeline).toMatchObject({
@@ -114,6 +114,75 @@ describe("TimelinePanel", () => {
114
114
  expect(screen.getByTestId("timeline-looping").textContent).toBe("false");
115
115
  });
116
116
 
117
+ it("keeps timeline playhead handles easy to grab without changing visual size", () => {
118
+ const { container } = renderTimelinePanel();
119
+
120
+ const compactHandle = container.querySelector<HTMLElement>(
121
+ '[data-slot="timeline-playback-handle"]',
122
+ );
123
+
124
+ expect(compactHandle?.className).toContain("size-2");
125
+ expect(compactHandle?.className).toContain("before:inset-[-4px]");
126
+
127
+ fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
128
+
129
+ const expandedHitArea = container.querySelector<HTMLElement>(
130
+ '[data-slot="timeline-expanded-playhead-hit-area"]',
131
+ );
132
+ const expandedHandle = container.querySelector<HTMLElement>(
133
+ '[data-slot="timeline-expanded-playhead-handle"]',
134
+ );
135
+
136
+ expect(expandedHandle?.className).toContain("size-[9px]");
137
+ expect(expandedHitArea?.getAttribute("style")).toContain("width: 15px;");
138
+ });
139
+
140
+ it("edits timeline duration from the highlighted duration value in collapsed and expanded states", () => {
141
+ const { container } = renderTimelinePanel();
142
+
143
+ const durationDisplay = container.querySelector<HTMLElement>(
144
+ '[data-slot="timeline-duration-display"]',
145
+ );
146
+
147
+ expect(durationDisplay?.className).toContain("!cursor-text");
148
+ expect(durationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
149
+ expect(durationDisplay?.className).toContain("var(--foreground)_8%");
150
+ expect(durationDisplay?.className).toContain("px-1");
151
+ expect(container.querySelector('[data-slot="timeline-duration-edit-trigger"]')).toBeNull();
152
+ expect(screen.queryByRole("textbox", { name: "timeline duration" })).toBeNull();
153
+
154
+ fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
155
+
156
+ const editor = screen.getByRole("textbox", { name: "timeline duration" });
157
+
158
+ expect(editor.className).toContain("var(--foreground)_8%");
159
+ expect(editor.className).toContain("px-1");
160
+ expect(editor.className).toContain("!cursor-text");
161
+ expect(screen.queryByRole("button", { name: "Edit timeline duration" })).toBeNull();
162
+
163
+ editor.textContent = "12s";
164
+ fireEvent.keyDown(editor, { key: "Enter" });
165
+
166
+ expect(screen.getByText("12s")).toBeTruthy();
167
+
168
+ fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
169
+
170
+ const expandedDurationDisplay = container.querySelector<HTMLElement>(
171
+ '[data-slot="timeline-duration-display"]',
172
+ );
173
+
174
+ expect(expandedDurationDisplay?.className).toContain("!cursor-text");
175
+ expect(expandedDurationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
176
+ expect(expandedDurationDisplay?.className).toContain("var(--foreground)_8%");
177
+
178
+ fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
179
+
180
+ const expandedEditor = screen.getByRole("textbox", { name: "timeline duration" });
181
+
182
+ expect(expandedEditor.className).toContain("var(--foreground)_8%");
183
+ expect(expandedEditor.className).toContain("!cursor-text");
184
+ });
185
+
117
186
  it("replays from the beginning when play is pressed at the non-looping end", () => {
118
187
  renderTimelinePanel(
119
188
  {},
@@ -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,
@@ -47,7 +47,7 @@ describe("defineToolcraft", () => {
47
47
  });
48
48
 
49
49
  expect(app.canvas.draggable).toBe(true);
50
- expect(app.canvas.size).toEqual({ height: 1024, unit: "px", width: 1024 });
50
+ expect(app.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
51
51
  expect(app.canvas.sizeSource).toBe("runtime-default");
52
52
  expect(app.canvas.sizing).toEqual({ mode: "intrinsic-media" });
53
53
  expect(app.toolbar).toEqual({ history: true, radar: true, theme: true, zoom: true });
@@ -612,11 +612,13 @@ describe("defineToolcraft", () => {
612
612
  it("publishes reserved targets for AI assembly boundaries", () => {
613
613
  expect(toolcraftRuntimeOwnedTargets).toEqual([
614
614
  "canvas.aspectRatio",
615
+ "canvas.renderScale",
615
616
  "canvas.size.width",
616
617
  "canvas.size.height",
617
618
  ]);
618
619
  expect(toolcraftReservedTargets).toEqual([
619
620
  "canvas.aspectRatio",
621
+ "canvas.renderScale",
620
622
  "canvas.size.width",
621
623
  "canvas.size.height",
622
624
  "selectedLayer.opacity",
@@ -792,6 +794,80 @@ describe("defineToolcraft", () => {
792
794
  expect(generationSection?.title).toBe("Generation");
793
795
  });
794
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
+
795
871
  it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
796
872
  const intrinsicApp = defineToolcraft({
797
873
  canvas: {