@pixel-point/toolcraft 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -19,13 +19,33 @@ import {
19
19
  useSortable,
20
20
  } from "@dnd-kit/sortable";
21
21
  import { CSS } from "@dnd-kit/utilities";
22
- import { CloudArrowUpIcon, PaperclipIcon, XIcon } from "@phosphor-icons/react";
22
+ import {
23
+ CloudArrowUpIcon,
24
+ PaperclipIcon,
25
+ XIcon,
26
+ } from "@phosphor-icons/react";
23
27
 
24
28
  import { cn } from "../../../lib/utils";
29
+ import {
30
+ ActionsControl,
31
+ type ActionControlOption,
32
+ } from "../actions/actions-control";
25
33
  import { Button, Field } from "../../primitives";
26
34
 
27
35
  export type FileDropAssetKind = "file" | "image";
28
36
 
37
+ export type FileDropImageTransform = {
38
+ flipHorizontal?: boolean;
39
+ flipVertical?: boolean;
40
+ rotationDeg?: number;
41
+ };
42
+
43
+ export type FileDropImageTransformOperation =
44
+ | "flip-horizontal"
45
+ | "flip-vertical"
46
+ | "rotate-left"
47
+ | "rotate-right";
48
+
29
49
  export type FileDropPreview = {
30
50
  alt?: string;
31
51
  assetKind?: FileDropAssetKind;
@@ -36,6 +56,7 @@ export type FileDropPreview = {
36
56
  width: number;
37
57
  };
38
58
  src: string;
59
+ transform?: FileDropImageTransform;
39
60
  };
40
61
 
41
62
  export type FileDropControlProps = {
@@ -47,10 +68,16 @@ export type FileDropControlProps = {
47
68
  onFilesSelect?: (files: File[]) => void;
48
69
  onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
49
70
  onPreviewReorder?: (orderedPreviews: FileDropPreview[]) => void;
71
+ onPreviewTransform?: (
72
+ preview: FileDropPreview,
73
+ operation: FileDropImageTransformOperation,
74
+ ) => void;
50
75
  preview?: FileDropPreview;
51
76
  previews?: readonly FileDropPreview[];
52
77
  };
53
78
 
79
+ const singleImagePreviewMaxHeight = 196;
80
+
54
81
  function isDragLeavingCurrentTarget(event: React.DragEvent<HTMLElement>): boolean {
55
82
  const nextTarget = event.relatedTarget;
56
83
 
@@ -88,15 +115,76 @@ function getFileInputAccept(accept: string): string {
88
115
  .join(",");
89
116
  }
90
117
 
91
- function getPreviewImageStyle(size: FileDropPreview["size"]): React.CSSProperties {
118
+ function normalizeImageRotation(rotationDeg: number | undefined): 0 | 90 | 180 | 270 {
119
+ const normalized = ((Math.round((rotationDeg ?? 0) / 90) * 90) % 360 + 360) % 360;
120
+
121
+ return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
122
+ }
123
+
124
+ function getImageTransformStyle(
125
+ transform: FileDropImageTransform | undefined,
126
+ options: { center?: boolean; includeRotation?: boolean } = {},
127
+ ): React.CSSProperties {
128
+ const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
129
+ const scaleX = transform?.flipHorizontal ? -1 : 1;
130
+ const scaleY = transform?.flipVertical ? -1 : 1;
131
+ const transformSteps: string[] = [];
132
+
133
+ if (options.center) {
134
+ transformSteps.push("translate(-50%, -50%)");
135
+ }
136
+
137
+ if (options.includeRotation !== false && rotationDeg !== 0) {
138
+ transformSteps.push(`rotate(${rotationDeg}deg)`);
139
+ }
140
+
141
+ if (scaleX !== 1 || scaleY !== 1) {
142
+ transformSteps.push(`scale(${scaleX}, ${scaleY})`);
143
+ }
144
+
145
+ if (transformSteps.length === 0) {
146
+ return {};
147
+ }
148
+
149
+ return {
150
+ transform: transformSteps.join(" "),
151
+ };
152
+ }
153
+
154
+ function getPreviewFrameStyle(
155
+ size: FileDropPreview["size"],
156
+ ): React.CSSProperties {
92
157
  if (!size || size.height <= 0 || size.width <= 0) {
93
158
  return { width: "100%" };
94
159
  }
95
160
 
96
- const maxWidth = Math.max(1, (196 * size.width) / size.height);
161
+ return {
162
+ aspectRatio: `${size.width} / ${size.height}`,
163
+ maxHeight: `${singleImagePreviewMaxHeight}px`,
164
+ width: "100%",
165
+ };
166
+ }
167
+
168
+ function isPreviewQuarterTurn(transform?: FileDropImageTransform): boolean {
169
+ const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
170
+
171
+ return rotationDeg === 90 || rotationDeg === 270;
172
+ }
173
+
174
+ function getPreviewImageStyle(
175
+ size: FileDropPreview["size"],
176
+ transform?: FileDropImageTransform,
177
+ ): React.CSSProperties {
178
+ const rotationDeg = normalizeImageRotation(transform?.rotationDeg);
179
+ const rotatedQuarterTurn = rotationDeg === 90 || rotationDeg === 270;
180
+ const rotatedWidth =
181
+ size && size.height > 0 && size.width > 0 && rotatedQuarterTurn
182
+ ? `${(Math.min(size.width / size.height, size.height / size.width) * 100).toFixed(4)}%`
183
+ : "100%";
97
184
 
98
185
  return {
99
- width: `min(100%, ${maxWidth.toFixed(2)}px)`,
186
+ ...getImageTransformStyle(transform, { center: true }),
187
+ ...(rotatedQuarterTurn ? { width: rotatedWidth } : {}),
100
188
  };
101
189
  }
102
190
 
@@ -119,12 +207,30 @@ function FileDropPlusGlyph({ className }: { className?: string }): React.JSX.Ele
119
207
  );
120
208
  }
121
209
 
210
+ const imageTransformActions = [
211
+ { ariaLabel: "90° Right", icon: "rotate-cw", label: "90°", value: "rotate-right" },
212
+ {
213
+ ariaLabel: "Flip horizontal",
214
+ icon: "flip-horizontal",
215
+ label: "Flip H",
216
+ value: "flip-horizontal",
217
+ },
218
+ {
219
+ ariaLabel: "Flip vertical",
220
+ icon: "flip-vertical",
221
+ label: "Flip V",
222
+ value: "flip-vertical",
223
+ },
224
+ ] satisfies readonly ActionControlOption[];
225
+
122
226
  type SortablePreviewTileProps = {
123
227
  index: number;
124
228
  isSortable: boolean;
125
229
  item: FileDropPreview;
126
230
  itemKey: string;
127
231
  onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
232
+ onPreviewSelect?: (preview: FileDropPreview) => void;
233
+ selected?: boolean;
128
234
  };
129
235
 
130
236
  function SortablePreviewTile({
@@ -133,6 +239,8 @@ function SortablePreviewTile({
133
239
  item,
134
240
  itemKey,
135
241
  onPreviewRemove,
242
+ onPreviewSelect,
243
+ selected = false,
136
244
  }: SortablePreviewTileProps): React.JSX.Element {
137
245
  const { attributes, isDragging, listeners, setNodeRef, transform, transition } =
138
246
  useSortable({
@@ -157,14 +265,18 @@ function SortablePreviewTile({
157
265
  "relative aspect-square min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)-4px)] bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] transition-[transform,box-shadow,opacity,border-color] duration-180 ease-out motion-reduce:transition-none",
158
266
  isSortable &&
159
267
  "cursor-grab touch-none select-none will-change-transform hover:shadow-[0_8px_18px_color-mix(in_oklab,var(--background)_30%,transparent)]",
268
+ selected &&
269
+ "ring-2 ring-[color:color-mix(in_oklab,var(--link)_72%,transparent)] ring-offset-2 ring-offset-[color:var(--background)]",
160
270
  isDragging &&
161
271
  "z-10 cursor-grabbing opacity-90 shadow-[0_12px_24px_color-mix(in_oklab,var(--background)_55%,transparent)]",
162
272
  )}
163
273
  data-file-upload-preview-key={itemKey}
164
274
  data-preview-dragging={isDragging ? "true" : undefined}
275
+ data-selected={selected ? "true" : undefined}
165
276
  data-slot="file-upload-preview-item"
166
277
  onClick={(event) => {
167
278
  event.stopPropagation();
279
+ onPreviewSelect?.(item);
168
280
  }}
169
281
  ref={setNodeRef}
170
282
  style={style}
@@ -177,6 +289,7 @@ function SortablePreviewTile({
177
289
  draggable={false}
178
290
  height={item.size?.height}
179
291
  src={item.src}
292
+ style={getImageTransformStyle(item.transform)}
180
293
  width={item.size?.width}
181
294
  />
182
295
  {onPreviewRemove ? (
@@ -288,11 +401,13 @@ export function FileDropControl({
288
401
  onFilesSelect,
289
402
  onPreviewRemove,
290
403
  onPreviewReorder,
404
+ onPreviewTransform,
291
405
  preview,
292
406
  previews,
293
407
  }: FileDropControlProps): React.JSX.Element {
294
408
  const inputRef = React.useRef<HTMLInputElement>(null);
295
409
  const [dragOver, setDragOver] = React.useState(false);
410
+ const [selectedPreviewKey, setSelectedPreviewKey] = React.useState<string | null>(null);
296
411
  const sensors = useSensors(
297
412
  useSensor(PointerSensor, {
298
413
  activationConstraint: {
@@ -312,6 +427,26 @@ export function FileDropControl({
312
427
  key: getPreviewKey(item, index),
313
428
  }));
314
429
  const previewKeys = previewEntries.map((entry) => entry.key);
430
+ const requiresImageSelection = assetKind === "image" && multiple && previewEntries.length > 1;
431
+ const selectedPreviewEntry = requiresImageSelection
432
+ ? previewEntries.find((entry) => entry.key === selectedPreviewKey)
433
+ : previewEntries[0];
434
+ const shouldRenderImageActions =
435
+ assetKind === "image" &&
436
+ hasPreview &&
437
+ Boolean(onPreviewTransform) &&
438
+ Boolean(selectedPreviewEntry) &&
439
+ (!requiresImageSelection || selectedPreviewKey !== null);
440
+
441
+ React.useEffect(() => {
442
+ if (!selectedPreviewKey) {
443
+ return;
444
+ }
445
+
446
+ if (!requiresImageSelection || !previewKeys.includes(selectedPreviewKey)) {
447
+ setSelectedPreviewKey(null);
448
+ }
449
+ }, [previewKeys, requiresImageSelection, selectedPreviewKey]);
315
450
 
316
451
  function handleFiles(fileList: FileList | readonly File[] | undefined): void {
317
452
  const files = Array.from(fileList ?? []);
@@ -371,8 +506,16 @@ export function FileDropControl({
371
506
  );
372
507
  }
373
508
 
509
+ function runImageTransform(operation: FileDropImageTransformOperation): void {
510
+ if (!selectedPreviewEntry) {
511
+ return;
512
+ }
513
+
514
+ onPreviewTransform?.(selectedPreviewEntry.item, operation);
515
+ }
516
+
374
517
  return (
375
- <Field className="min-w-0 gap-2">
518
+ <Field className="min-w-0" style={{ gap: "6px" }}>
376
519
  <input
377
520
  accept={getFileInputAccept(accept)}
378
521
  aria-hidden="true"
@@ -497,6 +640,13 @@ export function FileDropControl({
497
640
  itemKey={key}
498
641
  key={key}
499
642
  onPreviewRemove={onPreviewRemove}
643
+ onPreviewSelect={(preview) => {
644
+ const nextKey = getPreviewKey(preview, index);
645
+ setSelectedPreviewKey((currentKey) =>
646
+ currentKey === nextKey ? null : nextKey,
647
+ );
648
+ }}
649
+ selected={selectedPreviewKey === key}
500
650
  />
501
651
  ))}
502
652
  </SortableContext>
@@ -517,15 +667,26 @@ export function FileDropControl({
517
667
  ) : hasPreview ? (
518
668
  <>
519
669
  {previewItems[0] ? (
520
- <img
521
- alt={previewItems[0].alt ?? ""}
522
- className="block h-auto max-h-[196px] max-w-full rounded-[calc(var(--radius-lg)-4px)] object-contain"
523
- draggable={false}
524
- height={previewItems[0].size?.height}
525
- src={previewItems[0].src}
526
- style={getPreviewImageStyle(previewItems[0].size)}
527
- width={previewItems[0].size?.width}
528
- />
670
+ <div
671
+ className="relative w-full max-w-full overflow-hidden rounded-[calc(var(--radius-lg)-4px)]"
672
+ data-slot="file-upload-preview-frame"
673
+ style={getPreviewFrameStyle(previewItems[0].size)}
674
+ >
675
+ <img
676
+ alt={previewItems[0].alt ?? ""}
677
+ className={cn(
678
+ "absolute top-1/2 left-1/2 block h-auto object-contain",
679
+ isPreviewQuarterTurn(previewItems[0].transform)
680
+ ? "max-w-none"
681
+ : "max-h-full max-w-full",
682
+ )}
683
+ draggable={false}
684
+ height={previewItems[0].size?.height}
685
+ src={previewItems[0].src}
686
+ style={getPreviewImageStyle(previewItems[0].size, previewItems[0].transform)}
687
+ width={previewItems[0].size?.width}
688
+ />
689
+ </div>
529
690
  ) : null}
530
691
  {onClear ? (
531
692
  <Button
@@ -565,6 +726,17 @@ export function FileDropControl({
565
726
  </>
566
727
  )}
567
728
  </div>
729
+ {shouldRenderImageActions ? (
730
+ <ActionsControl
731
+ actions={imageTransformActions}
732
+ buttonColumns={3}
733
+ name="Image transforms"
734
+ onAction={(operation) =>
735
+ runImageTransform(operation as FileDropImageTransformOperation)
736
+ }
737
+ showLabel={false}
738
+ />
739
+ ) : null}
568
740
  </Field>
569
741
  );
570
742
  }
@@ -2,4 +2,9 @@
2
2
 
3
3
  export { FileDropControl } from "./file-drop-control";
4
4
  export type { FileDropControlProps } from "./file-drop-control";
5
- export type { FileDropAssetKind, FileDropPreview } from "./file-drop-control";
5
+ export type {
6
+ FileDropAssetKind,
7
+ FileDropImageTransform,
8
+ FileDropImageTransformOperation,
9
+ FileDropPreview,
10
+ } from "./file-drop-control";
@@ -119,6 +119,8 @@ export type {
119
119
  FileDropAssetKind,
120
120
  FileDropControlProps,
121
121
  FileDropControlProps as FileDropProps,
122
+ FileDropImageTransform,
123
+ FileDropImageTransformOperation,
122
124
  FileDropPreview,
123
125
  } from "./file-drop";
124
126
  export {
@@ -1,5 +1,7 @@
1
1
  import { applySliderValueLabelUnit } from "../slider/slider-value";
2
2
 
3
+ const rangeSeparatorPattern = /(\d)\s*[-‐‑‒–—−]\s*(?=\d)/g;
4
+
3
5
  export function formatRangeSliderValue(
4
6
  value: readonly number[],
5
7
  unit?: string,
@@ -44,7 +46,7 @@ export function parseRangeSliderDraft(
44
46
  step: number;
45
47
  },
46
48
  ): [number, number] | null {
47
- const normalizedDraftValue = draftValue.replace(/(\d)\s*-\s*(?=\d)/g, "$1 ");
49
+ const normalizedDraftValue = draftValue.replace(rangeSeparatorPattern, "$1 ");
48
50
  const values = Array.from(
49
51
  normalizedDraftValue.matchAll(/-?\d+(?:[.,]\d+)?/g),
50
52
  ([match]) => Number(match.replace(",", ".")),
@@ -26,12 +26,10 @@ export type SelectControlInput = {
26
26
  };
27
27
 
28
28
  type StaticSelectSize = "sm" | "default" | "lg" | "xl";
29
- type SelectControlFieldLayout = "auto" | "stacked";
30
29
 
31
30
  type SelectControlSingleProps = SelectControlInput & {
32
31
  inputs?: never;
33
32
  inputsPerRow?: never;
34
- layout?: SelectControlFieldLayout;
35
33
  };
36
34
 
37
35
  type SelectControlGroupProps = {
@@ -44,7 +42,6 @@ export type SelectControlProps =
44
42
  | SelectControlSingleProps
45
43
  | SelectControlGroupProps;
46
44
 
47
- const compactHorizontalFieldClassName = "h-fit min-w-0 items-center justify-between gap-3";
48
45
  const WIDE_SELECT_OPTION_LABEL_LENGTH = 32;
49
46
 
50
47
  function hasWideSelectContent(options: readonly ControlOption[]): boolean {
@@ -142,22 +139,15 @@ export function StaticSelect({
142
139
  }
143
140
 
144
141
  function SelectControlField({
145
- layout = "auto",
146
142
  name,
147
143
  onValueChange,
148
144
  options,
149
145
  showLabel = true,
150
146
  value,
151
- }: SelectControlInput & {
152
- layout?: SelectControlFieldLayout;
153
- }): React.JSX.Element {
147
+ }: SelectControlInput): React.JSX.Element {
154
148
  const fieldRef = React.useRef<HTMLDivElement>(null);
155
149
  const popupMaxWidth = useMeasuredElementWidth(fieldRef);
156
150
  const [currentValue, setCurrentValue] = React.useState(value);
157
- const selected = options.find((option) => option.value === currentValue) ?? options[0];
158
- const hasLongSelectedValue =
159
- (selected?.label.length ?? 0) >= WIDE_SELECT_OPTION_LABEL_LENGTH;
160
- const isStacked = layout === "stacked" || hasLongSelectedValue;
161
151
 
162
152
  React.useEffect(() => {
163
153
  setCurrentValue(value);
@@ -170,23 +160,12 @@ function SelectControlField({
170
160
 
171
161
  return (
172
162
  <Field
173
- className={cn(
174
- !showLabel
175
- ? "h-fit min-w-0"
176
- : isStacked
177
- ? "h-fit min-w-0 gap-2"
178
- : compactHorizontalFieldClassName,
179
- )}
180
- orientation={!showLabel || isStacked ? "vertical" : "horizontal"}
163
+ className={cn("h-fit min-w-0", showLabel && "gap-2")}
164
+ orientation="vertical"
181
165
  ref={fieldRef}
182
166
  >
183
167
  {showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
184
- <div
185
- className={cn(
186
- "min-w-0",
187
- !showLabel || isStacked ? "w-full" : "w-1/2 shrink-0",
188
- )}
189
- >
168
+ <div className="min-w-0 w-full">
190
169
  <StaticSelect
191
170
  ariaLabel={showLabel ? undefined : name}
192
171
  onValueChange={updateValue}
@@ -216,7 +195,6 @@ export function SelectControl(
216
195
  {props.inputs.map((input, index) => (
217
196
  <SelectControlField
218
197
  key={`${input.name}-${index}`}
219
- layout="stacked"
220
198
  {...input}
221
199
  />
222
200
  ))}
@@ -123,13 +123,25 @@ function getVectorPoint(
123
123
  }
124
124
 
125
125
  function getVectorValueLabel(x: string, y: string): string {
126
- return `${x}, ${y}`;
126
+ return `${formatVectorPadCoordinate(x)}, ${formatVectorPadCoordinate(y)}`;
127
127
  }
128
128
 
129
129
  function normalizeVectorCoordinate(value: string | undefined): string {
130
130
  return typeof value === "string" && value.trim() ? value : "0.00";
131
131
  }
132
132
 
133
+ function formatVectorPadCoordinate(value: string | undefined): string {
134
+ const parsedValue = Number.parseFloat(normalizeVectorCoordinate(value));
135
+
136
+ if (!Number.isFinite(parsedValue)) {
137
+ return "0.00";
138
+ }
139
+
140
+ const roundedValue = Math.abs(parsedValue) < 0.005 ? 0 : clamp(parsedValue, -1, 1);
141
+
142
+ return roundedValue.toFixed(2);
143
+ }
144
+
133
145
  function parseVectorValueDraft(value: string): VectorControlValue | null {
134
146
  const matches = value.match(/[+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+)/g);
135
147
 
@@ -307,6 +319,7 @@ function VectorPadHandle({ isDragging }: { isDragging: boolean }): React.JSX.Ele
307
319
  }
308
320
 
309
321
  function VectorPadField({
322
+ defaultValue,
310
323
  name,
311
324
  onValueChange,
312
325
  padCoordinateMode,
@@ -316,8 +329,8 @@ function VectorPadField({
316
329
  y,
317
330
  }: VectorControlProps): React.JSX.Element {
318
331
  const [isPointerDragging, setIsPointerDragging] = React.useState(false);
319
- const normalizedX = normalizeVectorCoordinate(x);
320
- const normalizedY = normalizeVectorCoordinate(y);
332
+ const normalizedX = formatVectorPadCoordinate(x);
333
+ const normalizedY = formatVectorPadCoordinate(y);
321
334
  const coordinateMode =
322
335
  padCoordinateMode ?? getDefaultPadCoordinateMode(padVariant);
323
336
  const point = getVectorPoint(normalizedX, normalizedY, coordinateMode);
@@ -326,6 +339,8 @@ function VectorPadField({
326
339
  const axisLockRef = React.useRef<VectorPadAxisLock | null>(null);
327
340
  const dragStartRef = React.useRef<VectorPadDragStart | null>(null);
328
341
  const liveHistoryGroupRef = React.useRef<string | null>(null);
342
+ const resetX = formatVectorPadCoordinate(defaultValue?.x);
343
+ const resetY = formatVectorPadCoordinate(defaultValue?.y);
329
344
  const updateVector = (
330
345
  nextX: string,
331
346
  nextY: string,
@@ -418,15 +433,21 @@ function VectorPadField({
418
433
  <button
419
434
  aria-label={`${name} X/Y pad`}
420
435
  className={cn(
421
- "relative w-full cursor-default! touch-none overflow-hidden rounded-[calc(var(--radius)+2px)] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_4%,transparent),color-mix(in_oklab,var(--foreground)_1%,transparent))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_12%,transparent)] focus-visible:outline-none",
436
+ "relative w-full cursor-default! touch-none overflow-hidden rounded-[calc(var(--radius)+2px)] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_4%,transparent),color-mix(in_oklab,var(--foreground)_1%,transparent))] select-none focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_12%,transparent)] focus-visible:outline-none",
422
437
  padShape === "square" ? "aspect-square" : "h-[142px]",
423
438
  )}
424
439
  data-vector-pad-coordinate-mode={coordinateMode}
425
440
  data-vector-pad-shape={padShape}
426
441
  data-vector-pad-variant={padVariant}
442
+ onDoubleClick={(event) => {
443
+ event.preventDefault();
444
+ stopPointerDrag();
445
+ updateVector(resetX, resetY);
446
+ }}
427
447
  onLostPointerCapture={stopPointerDrag}
428
448
  onPointerCancel={stopPointerDrag}
429
449
  onPointerDown={(event) => {
450
+ event.preventDefault();
430
451
  setIsPointerDragging(true);
431
452
  axisLockRef.current = null;
432
453
  dragStartRef.current = {
@@ -85,7 +85,7 @@ function getPanelActionIcon(action: PanelActionOption): ReactNode {
85
85
 
86
86
  const Icon = panelActionIconComponents[action.icon];
87
87
 
88
- return <Icon data-icon="inline-start" />;
88
+ return <Icon data-icon="inline-start" data-icon-name={action.icon} />;
89
89
  }
90
90
 
91
91
  function getPanelActionAriaLabel(