@pixel-point/toolcraft 0.0.4 → 0.0.7

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 (49) 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 +7 -1
  5. package/templates/runtime/contracts/component-contracts.test.ts +96 -3
  6. package/templates/runtime/contracts/component-contracts.ts +75 -3
  7. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  8. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  9. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  10. package/templates/runtime/react/controls-panel.test.tsx +455 -1
  11. package/templates/runtime/react/controls-panel.tsx +515 -30
  12. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  13. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  14. package/templates/runtime/react/timeline-panel.tsx +98 -10
  15. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  16. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  17. package/templates/runtime/schema/define-toolcraft.test.ts +78 -1
  18. package/templates/runtime/schema/define-toolcraft.ts +145 -6
  19. package/templates/runtime/schema/runtime-targets.ts +1 -0
  20. package/templates/runtime/schema/types.ts +46 -1
  21. package/templates/runtime/state/canvas-zoom.ts +1 -1
  22. package/templates/runtime/state/create-template-state.test.ts +6 -6
  23. package/templates/runtime/state/reducer.test.ts +139 -8
  24. package/templates/runtime/state/reducer.ts +88 -22
  25. package/templates/runtime/state/types.ts +3 -0
  26. package/templates/starter/AGENTS.md +8 -8
  27. package/templates/starter/docs/toolcraft/README.md +4 -3
  28. package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
  29. package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
  30. package/templates/starter/docs/toolcraft/component-rules.md +17 -1
  31. package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
  32. package/templates/starter/docs/toolcraft/performance.md +8 -7
  33. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  34. package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
  35. package/templates/starter/docs/toolcraft/workflow.md +5 -8
  36. package/templates/starter/gitignore +36 -0
  37. package/templates/starter/package.json +1 -1
  38. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  39. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  40. package/templates/starter/src/app/starter-performance.test.ts +1 -1
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  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 -6
  45. package/templates/ui/components/controls/index.ts +8 -0
  46. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  47. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  48. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
  49. package/templates/ui/index.ts +1 -0
@@ -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 });
@@ -535,6 +535,7 @@ describe("defineToolcraft", () => {
535
535
  expect(app.assembly.commands).toEqual(
536
536
  expect.arrayContaining([
537
537
  "controls.reset",
538
+ "controls.resetTargets",
538
539
  "history.undo",
539
540
  "media.delete",
540
541
  "media.import",
@@ -612,11 +613,13 @@ describe("defineToolcraft", () => {
612
613
  it("publishes reserved targets for AI assembly boundaries", () => {
613
614
  expect(toolcraftRuntimeOwnedTargets).toEqual([
614
615
  "canvas.aspectRatio",
616
+ "canvas.renderScale",
615
617
  "canvas.size.width",
616
618
  "canvas.size.height",
617
619
  ]);
618
620
  expect(toolcraftReservedTargets).toEqual([
619
621
  "canvas.aspectRatio",
622
+ "canvas.renderScale",
620
623
  "canvas.size.width",
621
624
  "canvas.size.height",
622
625
  "selectedLayer.opacity",
@@ -792,6 +795,80 @@ describe("defineToolcraft", () => {
792
795
  expect(generationSection?.title).toBe("Generation");
793
796
  });
794
797
 
798
+ it("appends raster render scale to the technical setup controls when enabled", () => {
799
+ const app = defineToolcraft({
800
+ canvas: {
801
+ enabled: true,
802
+ renderScale: true,
803
+ size: { height: 900, unit: "px", width: 1440 },
804
+ sizing: { mode: "editable-output" },
805
+ },
806
+ panels: {
807
+ controls: {
808
+ sections: [],
809
+ title: "Controls",
810
+ },
811
+ },
812
+ });
813
+
814
+ const setupSection = app.panels.controls?.sections[0];
815
+
816
+ expect(app.canvas.renderScale).toEqual({
817
+ defaultValue: 2,
818
+ enabled: true,
819
+ max: 2,
820
+ min: 1,
821
+ step: 0.25,
822
+ });
823
+ expect(app.assembly.capabilities).toContain("canvas.renderScale");
824
+ expect(Object.keys(setupSection?.controls ?? {})).toEqual([
825
+ "canvasAspectRatio",
826
+ "canvasWidth",
827
+ "canvasHeight",
828
+ "canvasRenderScale",
829
+ ]);
830
+ expect(setupSection?.controls.canvasRenderScale).toMatchObject({
831
+ defaultValue: 2,
832
+ label: "Resolution scale",
833
+ markerCount: 5,
834
+ max: 2,
835
+ min: 1,
836
+ step: 0.25,
837
+ target: "canvas.renderScale",
838
+ type: "slider",
839
+ unit: "x",
840
+ variant: "discrete",
841
+ });
842
+ });
843
+
844
+ it("can prepend render scale without editable output sizing", () => {
845
+ const app = defineToolcraft({
846
+ canvas: {
847
+ enabled: true,
848
+ renderScale: { defaultValue: 1.5 },
849
+ size: { height: 900, unit: "px", width: 1440 },
850
+ sizing: { mode: "fixed-output" },
851
+ },
852
+ panels: {
853
+ controls: {
854
+ sections: [],
855
+ title: "Controls",
856
+ },
857
+ },
858
+ });
859
+
860
+ expect(app.panels.controls?.sections[0]).toMatchObject({
861
+ controls: {
862
+ canvasRenderScale: {
863
+ defaultValue: 1.5,
864
+ target: "canvas.renderScale",
865
+ type: "slider",
866
+ },
867
+ },
868
+ title: "Setup",
869
+ });
870
+ });
871
+
795
872
  it("does not prepend canvas size controls for intrinsic media or explicitly fixed output", () => {
796
873
  const intrinsicApp = defineToolcraft({
797
874
  canvas: {
@@ -26,9 +26,9 @@ import type {
26
26
  } from "./types";
27
27
 
28
28
  const defaultCanvasSize = {
29
- height: 1024,
29
+ height: 1080,
30
30
  unit: "px",
31
- width: 1024,
31
+ width: 1920,
32
32
  } satisfies ToolcraftCanvasSize;
33
33
 
34
34
  type ResolvedCanvas = ResolvedToolcraftAppSchema["canvas"];
@@ -46,12 +46,21 @@ const canvasSizeControlTargets = {
46
46
  width: "canvas.size.width",
47
47
  } as const;
48
48
  const canvasAspectRatioTarget = "canvas.aspectRatio";
49
+ const canvasRenderScaleTarget = "canvas.renderScale";
50
+ const defaultCanvasRenderScale = {
51
+ defaultValue: 2,
52
+ enabled: false,
53
+ max: 2,
54
+ min: 1,
55
+ step: 0.25,
56
+ } satisfies ResolvedToolcraftAppSchema["canvas"]["renderScale"];
49
57
  const maxAutoInlineControlLabelLength = 18;
50
58
  const settingsTransferTarget = "runtime.settingsTransfer";
51
59
  const runtimeSetupSectionTitle = "Setup";
52
60
  const settingsTransferHeavyControlTypes = new Set([
53
61
  "channelMixer",
54
62
  "code",
63
+ "collectionActions",
55
64
  "colorOpacity",
56
65
  "curves",
57
66
  "fileDrop",
@@ -190,6 +199,54 @@ function resolveCanvasSizing(
190
199
  return { mode: "intrinsic-media" };
191
200
  }
192
201
 
202
+ function clampCanvasRenderScale(value: number | undefined, fallback: number): number {
203
+ if (typeof value !== "number" || !Number.isFinite(value)) {
204
+ return fallback;
205
+ }
206
+
207
+ return Math.max(1, Math.min(2, value));
208
+ }
209
+
210
+ function resolveCanvasRenderScale(
211
+ renderScale: ToolcraftAppSchema["canvas"]["renderScale"],
212
+ ): ResolvedToolcraftAppSchema["canvas"]["renderScale"] {
213
+ if (renderScale === true) {
214
+ return {
215
+ ...defaultCanvasRenderScale,
216
+ enabled: true,
217
+ };
218
+ }
219
+
220
+ if (!renderScale) {
221
+ return defaultCanvasRenderScale;
222
+ }
223
+
224
+ const min = clampCanvasRenderScale(renderScale.min, defaultCanvasRenderScale.min);
225
+ const max = Math.max(
226
+ min,
227
+ clampCanvasRenderScale(renderScale.max, defaultCanvasRenderScale.max),
228
+ );
229
+ const step =
230
+ typeof renderScale.step === "number" && Number.isFinite(renderScale.step)
231
+ ? Math.max(0.01, Math.min(1, renderScale.step))
232
+ : defaultCanvasRenderScale.step;
233
+ const defaultValue = Math.max(
234
+ min,
235
+ Math.min(
236
+ max,
237
+ clampCanvasRenderScale(renderScale.defaultValue, defaultCanvasRenderScale.defaultValue),
238
+ ),
239
+ );
240
+
241
+ return {
242
+ defaultValue,
243
+ enabled: renderScale.enabled ?? true,
244
+ max,
245
+ min,
246
+ step,
247
+ };
248
+ }
249
+
193
250
  function resolveExport(
194
251
  exportSchema: ToolcraftAppSchema["export"],
195
252
  ): ResolvedExport {
@@ -264,6 +321,10 @@ function createToolcraftAssembly({
264
321
  commands.push("canvas.setSize");
265
322
  }
266
323
 
324
+ if (canvas.renderScale.enabled) {
325
+ capabilities.push("canvas.renderScale");
326
+ }
327
+
267
328
  if (canvas.draggable) {
268
329
  capabilities.push("canvas.draggable");
269
330
  commands.push("canvas.panBy", "canvas.setOffset", "canvas.setViewport");
@@ -278,7 +339,12 @@ function createToolcraftAssembly({
278
339
  const controlsPanel = panels.controls
279
340
  ? createPanelAssemblyContract({
280
341
  capabilities: ["controls.panel", "controls.defaults"],
281
- commands: ["controls.apply", "controls.reset", "controls.setValue"],
342
+ commands: [
343
+ "controls.apply",
344
+ "controls.reset",
345
+ "controls.resetTargets",
346
+ "controls.setValue",
347
+ ],
282
348
  contract: TOOLCRAFT_COMPONENT_CONTRACTS.controlsPanel,
283
349
  enabled: true,
284
350
  })
@@ -742,10 +808,12 @@ function getCanvasAspectRatioDefaultValue(size: ToolcraftCanvasSize): {
742
808
 
743
809
  function createCanvasSizeSection({
744
810
  aspectRatioControl,
811
+ renderScaleControl,
745
812
  sizeControlIds,
746
813
  sizeControls,
747
814
  }: {
748
815
  aspectRatioControl: ToolcraftControlSchema;
816
+ renderScaleControl?: ToolcraftControlSchema;
749
817
  sizeControlIds: readonly string[];
750
818
  sizeControls: ToolcraftControlSectionSchema["controls"];
751
819
  }): ToolcraftControlSectionSchema {
@@ -753,19 +821,33 @@ function createCanvasSizeSection({
753
821
  controls: {
754
822
  canvasAspectRatio: aspectRatioControl,
755
823
  ...sizeControls,
824
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
756
825
  },
757
826
  layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
758
827
  title: runtimeSetupSectionTitle,
759
828
  };
760
829
  }
761
830
 
831
+ function createCanvasRenderScaleSection(
832
+ renderScaleControl: ToolcraftControlSchema,
833
+ ): ToolcraftControlSectionSchema {
834
+ return {
835
+ controls: {
836
+ canvasRenderScale: renderScaleControl,
837
+ },
838
+ title: runtimeSetupSectionTitle,
839
+ };
840
+ }
841
+
762
842
  function mergeCanvasSizeControlsIntoSettingsTransferSection({
763
843
  aspectRatioControl,
844
+ renderScaleControl,
764
845
  settingsTransferSection,
765
846
  sizeControlIds,
766
847
  sizeControls,
767
848
  }: {
768
849
  aspectRatioControl: ToolcraftControlSchema;
850
+ renderScaleControl?: ToolcraftControlSchema;
769
851
  settingsTransferSection: ToolcraftControlSectionSchema;
770
852
  sizeControlIds: readonly string[];
771
853
  sizeControls: ToolcraftControlSectionSchema["controls"];
@@ -778,6 +860,7 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
778
860
  ...settingsTransferSection.controls,
779
861
  canvasAspectRatio: aspectRatioControl,
780
862
  ...sizeControls,
863
+ ...(renderScaleControl ? { canvasRenderScale: renderScaleControl } : {}),
781
864
  },
782
865
  layoutGroups:
783
866
  canvasSizeLayoutGroups.length > 0 || settingsTransferSection.layoutGroups?.length
@@ -789,6 +872,22 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
789
872
  };
790
873
  }
791
874
 
875
+ function mergeCanvasRenderScaleIntoSettingsTransferSection({
876
+ renderScaleControl,
877
+ settingsTransferSection,
878
+ }: {
879
+ renderScaleControl: ToolcraftControlSchema;
880
+ settingsTransferSection: ToolcraftControlSectionSchema;
881
+ }): ToolcraftControlSectionSchema {
882
+ return {
883
+ ...settingsTransferSection,
884
+ controls: {
885
+ ...settingsTransferSection.controls,
886
+ canvasRenderScale: renderScaleControl,
887
+ },
888
+ };
889
+ }
890
+
792
891
  function isPrimaryPanelAction(action: ToolcraftControlActionSchema): boolean {
793
892
  return typeof action !== "string" && action.variant !== "outline";
794
893
  }
@@ -1308,14 +1407,46 @@ function normalizePanels({
1308
1407
 
1309
1408
  const controls = { ...panels.controls };
1310
1409
  const settingsTransferSection = createSettingsTransferSection(settingsTransfer);
1410
+ const renderScaleControl: ToolcraftControlSchema | undefined =
1411
+ canvas.renderScale.enabled && !hasControlTarget(panels, canvasRenderScaleTarget)
1412
+ ? {
1413
+ defaultValue: canvas.renderScale.defaultValue,
1414
+ description:
1415
+ "Increases raster canvas backing resolution without changing the visible output size.",
1416
+ label: "Resolution scale",
1417
+ markerCount:
1418
+ Math.floor(
1419
+ (canvas.renderScale.max - canvas.renderScale.min) / canvas.renderScale.step,
1420
+ ) + 1,
1421
+ max: canvas.renderScale.max,
1422
+ min: canvas.renderScale.min,
1423
+ performanceReason:
1424
+ "Resolution scale changes raster, Canvas, WebGL, or WebGPU backing pixels.",
1425
+ performanceRole: "workload",
1426
+ step: canvas.renderScale.step,
1427
+ target: canvasRenderScaleTarget,
1428
+ type: "slider",
1429
+ unit: "x",
1430
+ variant: "discrete",
1431
+ }
1432
+ : undefined;
1311
1433
 
1312
1434
  if (!canvas.enabled || canvas.sizing.mode !== "editable-output") {
1435
+ const runtimeSetupSection =
1436
+ settingsTransferSection && renderScaleControl
1437
+ ? mergeCanvasRenderScaleIntoSettingsTransferSection({
1438
+ renderScaleControl,
1439
+ settingsTransferSection,
1440
+ })
1441
+ : (settingsTransferSection ??
1442
+ (renderScaleControl ? createCanvasRenderScaleSection(renderScaleControl) : null));
1443
+
1313
1444
  return {
1314
1445
  ...normalizedPanels,
1315
1446
  controls: normalizeControlsPanelLayout({
1316
1447
  ...controls,
1317
1448
  sections: [
1318
- ...(settingsTransferSection ? [settingsTransferSection] : []),
1449
+ ...(runtimeSetupSection ? [runtimeSetupSection] : []),
1319
1450
  ...controls.sections,
1320
1451
  ],
1321
1452
  }),
@@ -1357,7 +1488,7 @@ function normalizePanels({
1357
1488
  sizeControlIds.push("canvasHeight");
1358
1489
  }
1359
1490
 
1360
- if (Object.keys(sizeControls).length === 0) {
1491
+ if (Object.keys(sizeControls).length === 0 && !renderScaleControl) {
1361
1492
  return {
1362
1493
  ...normalizedPanels,
1363
1494
  controls: normalizeControlsPanelLayout({
@@ -1373,11 +1504,17 @@ function normalizePanels({
1373
1504
  const runtimeSettingsSection = settingsTransferSection
1374
1505
  ? mergeCanvasSizeControlsIntoSettingsTransferSection({
1375
1506
  aspectRatioControl,
1507
+ renderScaleControl,
1376
1508
  settingsTransferSection,
1377
1509
  sizeControlIds,
1378
1510
  sizeControls,
1379
1511
  })
1380
- : createCanvasSizeSection({ aspectRatioControl, sizeControlIds, sizeControls });
1512
+ : createCanvasSizeSection({
1513
+ aspectRatioControl,
1514
+ renderScaleControl,
1515
+ sizeControlIds,
1516
+ sizeControls,
1517
+ });
1381
1518
 
1382
1519
  return {
1383
1520
  ...normalizedPanels,
@@ -1453,6 +1590,7 @@ function assertPanelPersistenceContract({
1453
1590
  export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAppSchema {
1454
1591
  const canvasEnabled = schema.canvas.enabled;
1455
1592
  const canvasSize = schema.canvas.size;
1593
+ const canvasRenderScale = resolveCanvasRenderScale(schema.canvas.renderScale);
1456
1594
  const canvasSizing = resolveCanvasSizing(schema.canvas);
1457
1595
  const persistence = resolvePersistence(schema.persistence);
1458
1596
  const settingsTransfer = resolveSettingsTransfer({
@@ -1464,6 +1602,7 @@ export function defineToolcraft(schema: ToolcraftAppSchema): ResolvedToolcraftAp
1464
1602
  const canvas = {
1465
1603
  ...schema.canvas,
1466
1604
  draggable: canvasEnabled ? (schema.canvas.draggable ?? true) : false,
1605
+ renderScale: canvasRenderScale,
1467
1606
  size: canvasSize ?? defaultCanvasSize,
1468
1607
  sizeSource: canvasSize ? ("app" as const) : ("runtime-default" as const),
1469
1608
  sizing: canvasSizing,
@@ -1,5 +1,6 @@
1
1
  export const toolcraftRuntimeOwnedTargets = [
2
2
  "canvas.aspectRatio",
3
+ "canvas.renderScale",
3
4
  "canvas.size.width",
4
5
  "canvas.size.height",
5
6
  ] as const;