@pixel-point/toolcraft 0.0.8 → 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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -3,6 +3,10 @@
3
3
  import * as React from "react";
4
4
 
5
5
  import { cn } from "../../../lib/utils";
6
+ import {
7
+ createControlHistoryGroupId,
8
+ type ControlChangeMeta,
9
+ } from "../control-types";
6
10
  import {
7
11
  PALETTE_SHADE_STEPS,
8
12
  STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS,
@@ -21,7 +25,7 @@ export {
21
25
  };
22
26
  export type { PaletteColorFamily, PaletteControlValue, PaletteShadeStep };
23
27
 
24
- export type PaletteControlChangeMeta = {
28
+ export type PaletteControlChangeMeta = ControlChangeMeta & {
25
29
  stage: "live" | "commit";
26
30
  hex: string;
27
31
  };
@@ -91,6 +95,7 @@ export function PaletteControl({
91
95
  const pendingPersistRef = React.useRef<PaletteControlValue | null>(null);
92
96
  const clickCommitTimeoutRef = React.useRef<number | null>(null);
93
97
  const persistTimeoutRef = React.useRef<number | null>(null);
98
+ const liveHistoryGroupRef = React.useRef<string | null>(null);
94
99
  const isInteractingRef = React.useRef(false);
95
100
  const shadeTrackRef = React.useRef<HTMLDivElement | null>(null);
96
101
  const onValueChangeRef = React.useRef(onValueChange);
@@ -148,12 +153,34 @@ export function PaletteControl({
148
153
  persistTimeoutRef.current = null;
149
154
  }, []);
150
155
 
156
+ const getLiveHistoryMeta = React.useCallback((): ControlChangeMeta => {
157
+ liveHistoryGroupRef.current ??= createControlHistoryGroupId("palette");
158
+
159
+ return {
160
+ history: "merge",
161
+ historyGroup: liveHistoryGroupRef.current,
162
+ };
163
+ }, []);
164
+
165
+ const finishLiveHistoryGroup = React.useCallback(() => {
166
+ liveHistoryGroupRef.current = null;
167
+ }, []);
168
+
151
169
  const emitChange = React.useCallback(
152
170
  (nextValue: PaletteControlValue, stage: PaletteControlChangeMeta["stage"]) => {
153
171
  const hex = getPaletteHex(nextValue);
154
- onValueChangeRef.current?.(nextValue, { stage, hex });
172
+ const historyMeta =
173
+ stage === "live" || liveHistoryGroupRef.current
174
+ ? getLiveHistoryMeta()
175
+ : undefined;
176
+
177
+ onValueChangeRef.current?.(nextValue, {
178
+ ...historyMeta,
179
+ stage,
180
+ hex,
181
+ });
155
182
  },
156
- [],
183
+ [getLiveHistoryMeta],
157
184
  );
158
185
 
159
186
  const syncOptimisticValue = React.useCallback((nextValue: PaletteControlValue) => {
@@ -174,6 +201,7 @@ export function PaletteControl({
174
201
  if (options?.immediate) {
175
202
  pendingPersistRef.current = null;
176
203
  onCommitRef.current?.(pendingPersist, getPaletteHex(pendingPersist));
204
+ finishLiveHistoryGroup();
177
205
  setInteractionState(false);
178
206
  return true;
179
207
  }
@@ -184,17 +212,19 @@ export function PaletteControl({
184
212
  pendingPersistRef.current = null;
185
213
 
186
214
  if (!nextPersist) {
215
+ finishLiveHistoryGroup();
187
216
  setInteractionState(false);
188
217
  return;
189
218
  }
190
219
 
191
220
  onCommitRef.current?.(nextPersist, getPaletteHex(nextPersist));
221
+ finishLiveHistoryGroup();
192
222
  setInteractionState(false);
193
223
  }, PERSIST_SETTLE_MS);
194
224
 
195
225
  return true;
196
226
  },
197
- [clearPersistTimeout, setInteractionState],
227
+ [clearPersistTimeout, finishLiveHistoryGroup, setInteractionState],
198
228
  );
199
229
 
200
230
  const flushPendingCommit = React.useCallback((options?: { persistImmediately?: boolean }) => {
@@ -459,9 +459,14 @@ export function useHueHandlers({
459
459
 
460
460
  const handleHuePreviewChange = useCallback(
461
461
  (nextHue: number) => {
462
- applyOptimisticColor({ h: nextHue, s: latestHsvRef.current.s, v: latestHsvRef.current.v });
462
+ const nextHex = applyOptimisticColor({
463
+ h: nextHue,
464
+ s: latestHsvRef.current.s,
465
+ v: latestHsvRef.current.v,
466
+ });
467
+ emitChange(nextHex);
463
468
  },
464
- [applyOptimisticColor, latestHsvRef],
469
+ [applyOptimisticColor, emitChange, latestHsvRef],
465
470
  );
466
471
 
467
472
  const handleHueCommit = useCallback(
@@ -170,9 +170,9 @@ function useRgbBlueHandlers({
170
170
 
171
171
  const handleSliderPreviewChange = useCallback(
172
172
  (nextValue: number) => {
173
- applyBlueChannel(nextValue);
173
+ emitChange(applyBlueChannel(nextValue));
174
174
  },
175
- [applyBlueChannel],
175
+ [applyBlueChannel, emitChange],
176
176
  );
177
177
 
178
178
  const handleSliderCommit = useCallback(
@@ -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";
@@ -52,6 +52,7 @@ export {
52
52
  getSurfaceHsvColor,
53
53
  PaletteControl,
54
54
  PaletteControl as Palette,
55
+ StyleGuideColorPicker,
55
56
  } from "./color";
56
57
  export type {
57
58
  ColorFormatMode,
@@ -118,6 +119,8 @@ export type {
118
119
  FileDropAssetKind,
119
120
  FileDropControlProps,
120
121
  FileDropControlProps as FileDropProps,
122
+ FileDropImageTransform,
123
+ FileDropImageTransformOperation,
121
124
  FileDropPreview,
122
125
  } from "./file-drop";
123
126
  export {
@@ -197,5 +200,6 @@ export type {
197
200
  VectorControlProps as VectorProps,
198
201
  VectorControlValue,
199
202
  VectorControlValue as VectorValue,
203
+ VectorPadCoordinateMode,
200
204
  VectorPadVariant,
201
205
  } from "./vector";
@@ -12,6 +12,7 @@ export type RangeInputControlProps = {
12
12
  end: string;
13
13
  name: string;
14
14
  onValueChange?: ControlValueChangeHandler<{ start: string; end: string }>;
15
+ showLabel?: boolean;
15
16
  start: string;
16
17
  };
17
18
 
@@ -88,6 +89,7 @@ export function RangeInputControl({
88
89
  end,
89
90
  name,
90
91
  onValueChange,
92
+ showLabel = true,
91
93
  start,
92
94
  }: RangeInputControlProps): React.JSX.Element {
93
95
  const [currentValue, setCurrentValue] = React.useState({ end, start });
@@ -142,7 +144,7 @@ export function RangeInputControl({
142
144
  if (shouldUseFullWidthRangeInputs(start, end)) {
143
145
  return (
144
146
  <Field className="h-fit min-w-0 gap-2">
145
- <ControlFieldLabel>{name}</ControlFieldLabel>
147
+ {showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
146
148
  <RangeInputs
147
149
  className="w-full"
148
150
  end={currentValue.end}
@@ -157,10 +159,16 @@ export function RangeInputControl({
157
159
  }
158
160
 
159
161
  return (
160
- <Field className="h-fit min-w-0 items-center justify-between gap-3" orientation="horizontal">
161
- <ControlFieldLabel>{name}</ControlFieldLabel>
162
+ <Field
163
+ className={cn(
164
+ "h-fit min-w-0",
165
+ showLabel ? "items-center justify-between gap-3" : undefined,
166
+ )}
167
+ orientation={showLabel ? "horizontal" : "vertical"}
168
+ >
169
+ {showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
162
170
  <RangeInputs
163
- className="w-1/2 shrink-0"
171
+ className={showLabel ? "w-1/2 shrink-0" : "w-full"}
164
172
  end={currentValue.end}
165
173
  name={name}
166
174
  onCancel={cancelDraft}
@@ -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(",", ".")),
@@ -21,16 +21,15 @@ export type SelectControlInput = {
21
21
  name: string;
22
22
  onValueChange?: (value: string) => void;
23
23
  options: readonly ControlOption[];
24
+ showLabel?: boolean;
24
25
  value: string;
25
26
  };
26
27
 
27
28
  type StaticSelectSize = "sm" | "default" | "lg" | "xl";
28
- type SelectControlFieldLayout = "auto" | "stacked";
29
29
 
30
30
  type SelectControlSingleProps = SelectControlInput & {
31
31
  inputs?: never;
32
32
  inputsPerRow?: never;
33
- layout?: SelectControlFieldLayout;
34
33
  };
35
34
 
36
35
  type SelectControlGroupProps = {
@@ -43,7 +42,6 @@ export type SelectControlProps =
43
42
  | SelectControlSingleProps
44
43
  | SelectControlGroupProps;
45
44
 
46
- const compactHorizontalFieldClassName = "h-fit min-w-0 items-center justify-between gap-3";
47
45
  const WIDE_SELECT_OPTION_LABEL_LENGTH = 32;
48
46
 
49
47
  function hasWideSelectContent(options: readonly ControlOption[]): boolean {
@@ -141,21 +139,15 @@ export function StaticSelect({
141
139
  }
142
140
 
143
141
  function SelectControlField({
144
- layout = "auto",
145
142
  name,
146
143
  onValueChange,
147
144
  options,
145
+ showLabel = true,
148
146
  value,
149
- }: SelectControlInput & {
150
- layout?: SelectControlFieldLayout;
151
- }): React.JSX.Element {
147
+ }: SelectControlInput): React.JSX.Element {
152
148
  const fieldRef = React.useRef<HTMLDivElement>(null);
153
149
  const popupMaxWidth = useMeasuredElementWidth(fieldRef);
154
150
  const [currentValue, setCurrentValue] = React.useState(value);
155
- const selected = options.find((option) => option.value === currentValue) ?? options[0];
156
- const hasLongSelectedValue =
157
- (selected?.label.length ?? 0) >= WIDE_SELECT_OPTION_LABEL_LENGTH;
158
- const isStacked = layout === "stacked" || hasLongSelectedValue;
159
151
 
160
152
  React.useEffect(() => {
161
153
  setCurrentValue(value);
@@ -168,22 +160,14 @@ function SelectControlField({
168
160
 
169
161
  return (
170
162
  <Field
171
- className={cn(
172
- isStacked
173
- ? "h-fit min-w-0 gap-2"
174
- : compactHorizontalFieldClassName,
175
- )}
176
- orientation={isStacked ? "vertical" : "horizontal"}
163
+ className={cn("h-fit min-w-0", showLabel && "gap-2")}
164
+ orientation="vertical"
177
165
  ref={fieldRef}
178
166
  >
179
- <ControlFieldLabel>{name}</ControlFieldLabel>
180
- <div
181
- className={cn(
182
- "min-w-0",
183
- isStacked ? "w-full" : "w-1/2 shrink-0",
184
- )}
185
- >
167
+ {showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
168
+ <div className="min-w-0 w-full">
186
169
  <StaticSelect
170
+ ariaLabel={showLabel ? undefined : name}
187
171
  onValueChange={updateValue}
188
172
  options={options}
189
173
  popupMaxWidth={popupMaxWidth}
@@ -211,7 +195,6 @@ export function SelectControl(
211
195
  {props.inputs.map((input, index) => (
212
196
  <SelectControlField
213
197
  key={`${input.name}-${index}`}
214
- layout="stacked"
215
198
  {...input}
216
199
  />
217
200
  ))}
@@ -27,7 +27,6 @@ const compactSliderValueUnits = new Set([
27
27
  "vh",
28
28
  "vmin",
29
29
  "vmax",
30
- "x",
31
30
  "s",
32
31
  "ms",
33
32
  ]);
@@ -15,6 +15,7 @@ export type TextInputControlInput = {
15
15
  defaultValue?: string;
16
16
  name: string;
17
17
  onValueChange?: ControlValueChangeHandler<string>;
18
+ showLabel?: boolean;
18
19
  value: string;
19
20
  };
20
21
 
@@ -51,6 +52,7 @@ function TextInputControlField({
51
52
  defaultValue,
52
53
  name,
53
54
  onValueChange,
55
+ showLabel = true,
54
56
  value,
55
57
  }: TextInputControlInput): React.JSX.Element {
56
58
  const [currentValue, setCurrentValue] = React.useState(value);
@@ -101,8 +103,9 @@ function TextInputControlField({
101
103
 
102
104
  return (
103
105
  <Field className="min-w-0 gap-2">
104
- <ControlFieldLabel>{name}</ControlFieldLabel>
106
+ {showLabel ? <ControlFieldLabel>{name}</ControlFieldLabel> : null}
105
107
  <Input
108
+ aria-label={showLabel ? undefined : name}
106
109
  className="font-mono"
107
110
  onBlur={commitOnBlur ? () => commitValue() : finishLiveHistoryGroup}
108
111
  onChange={(event) => updateValue(event.target.value)}
@@ -4,5 +4,6 @@ export { VectorControl } from "./vector-control";
4
4
  export type {
5
5
  VectorControlProps,
6
6
  VectorControlValue,
7
+ VectorPadCoordinateMode,
7
8
  VectorPadVariant,
8
9
  } from "./vector-control";