@pixel-point/toolcraft 0.0.6 → 0.0.8

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/generate.test.mjs +1 -1
  3. package/templates/runtime/contracts/component-contracts.test.ts +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  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 +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -1,13 +1,35 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { CloudArrowUpIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
4
+ import {
5
+ closestCenter,
6
+ DndContext,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ type DragEndEvent,
10
+ useSensor,
11
+ useSensors,
12
+ } from "@dnd-kit/core";
13
+ import {
14
+ arrayMove,
15
+ rectSortingStrategy,
16
+ SortableContext,
17
+ sortableKeyboardCoordinates,
18
+ verticalListSortingStrategy,
19
+ useSortable,
20
+ } from "@dnd-kit/sortable";
21
+ import { CSS } from "@dnd-kit/utilities";
22
+ import { CloudArrowUpIcon, PaperclipIcon, XIcon } from "@phosphor-icons/react";
5
23
 
6
24
  import { cn } from "../../../lib/utils";
7
25
  import { Button, Field } from "../../primitives";
8
26
 
27
+ export type FileDropAssetKind = "file" | "image";
28
+
9
29
  export type FileDropPreview = {
10
30
  alt?: string;
31
+ assetKind?: FileDropAssetKind;
32
+ fileName?: string;
11
33
  id?: string;
12
34
  size?: {
13
35
  height: number;
@@ -18,11 +40,13 @@ export type FileDropPreview = {
18
40
 
19
41
  export type FileDropControlProps = {
20
42
  accept: string;
43
+ assetKind?: FileDropAssetKind;
21
44
  multiple?: boolean;
22
45
  onClear?: () => void;
23
46
  onFileSelect?: (file: File) => void;
24
47
  onFilesSelect?: (files: File[]) => void;
25
48
  onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
49
+ onPreviewReorder?: (orderedPreviews: FileDropPreview[]) => void;
26
50
  preview?: FileDropPreview;
27
51
  previews?: readonly FileDropPreview[];
28
52
  };
@@ -76,21 +100,218 @@ function getPreviewImageStyle(size: FileDropPreview["size"]): React.CSSPropertie
76
100
  };
77
101
  }
78
102
 
103
+ function getPreviewKey(item: FileDropPreview, index: number): string {
104
+ return item.id ?? `${item.src}:${index}`;
105
+ }
106
+
107
+ function FileDropPlusGlyph({ className }: { className?: string }): React.JSX.Element {
108
+ return (
109
+ <svg
110
+ aria-hidden="true"
111
+ className={cn("flex-none", className)}
112
+ fill="none"
113
+ viewBox="0 0 14 14"
114
+ xmlns="http://www.w3.org/2000/svg"
115
+ >
116
+ <path d="M7 2.5V11.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1" />
117
+ <path d="M2.5 7H11.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1" />
118
+ </svg>
119
+ );
120
+ }
121
+
122
+ type SortablePreviewTileProps = {
123
+ index: number;
124
+ isSortable: boolean;
125
+ item: FileDropPreview;
126
+ itemKey: string;
127
+ onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
128
+ };
129
+
130
+ function SortablePreviewTile({
131
+ index,
132
+ isSortable,
133
+ item,
134
+ itemKey,
135
+ onPreviewRemove,
136
+ }: SortablePreviewTileProps): React.JSX.Element {
137
+ const { attributes, isDragging, listeners, setNodeRef, transform, transition } =
138
+ useSortable({
139
+ disabled: !isSortable,
140
+ id: itemKey,
141
+ });
142
+ const sortableTransform = transform
143
+ ? {
144
+ ...transform,
145
+ scaleX: isDragging ? 1.035 : transform.scaleX,
146
+ scaleY: isDragging ? 1.035 : transform.scaleY,
147
+ }
148
+ : null;
149
+ const style: React.CSSProperties = {
150
+ transform: sortableTransform ? CSS.Transform.toString(sortableTransform) : undefined,
151
+ transition,
152
+ };
153
+
154
+ return (
155
+ <div
156
+ className={cn(
157
+ "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
+ isSortable &&
159
+ "cursor-grab touch-none select-none will-change-transform hover:shadow-[0_8px_18px_color-mix(in_oklab,var(--background)_30%,transparent)]",
160
+ isDragging &&
161
+ "z-10 cursor-grabbing opacity-90 shadow-[0_12px_24px_color-mix(in_oklab,var(--background)_55%,transparent)]",
162
+ )}
163
+ data-file-upload-preview-key={itemKey}
164
+ data-preview-dragging={isDragging ? "true" : undefined}
165
+ data-slot="file-upload-preview-item"
166
+ onClick={(event) => {
167
+ event.stopPropagation();
168
+ }}
169
+ ref={setNodeRef}
170
+ style={style}
171
+ {...(isSortable ? attributes : {})}
172
+ {...(isSortable ? listeners : {})}
173
+ >
174
+ <img
175
+ alt={item.alt ?? ""}
176
+ className="size-full object-cover"
177
+ draggable={false}
178
+ height={item.size?.height}
179
+ src={item.src}
180
+ width={item.size?.width}
181
+ />
182
+ {onPreviewRemove ? (
183
+ <Button
184
+ aria-label={`Remove ${item.alt ?? "image"}`}
185
+ className="absolute top-0.5 right-0.5"
186
+ onClick={(event) => {
187
+ event.stopPropagation();
188
+ onPreviewRemove(item, index);
189
+ }}
190
+ onPointerDown={(event) => {
191
+ event.stopPropagation();
192
+ }}
193
+ size="icon-sm"
194
+ type="button"
195
+ variant="ghost"
196
+ >
197
+ <XIcon className="drop-shadow-[0_2px_1px_color-mix(in_oklab,var(--background)_80%,transparent)]" />
198
+ </Button>
199
+ ) : null}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ type SortableFileRowProps = {
205
+ index: number;
206
+ isSortable: boolean;
207
+ item: FileDropPreview;
208
+ itemKey: string;
209
+ onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
210
+ };
211
+
212
+ function SortableFileRow({
213
+ index,
214
+ isSortable,
215
+ item,
216
+ itemKey,
217
+ onPreviewRemove,
218
+ }: SortableFileRowProps): React.JSX.Element {
219
+ const { attributes, isDragging, listeners, setNodeRef, transform, transition } =
220
+ useSortable({
221
+ disabled: !isSortable,
222
+ id: itemKey,
223
+ });
224
+ const style: React.CSSProperties = {
225
+ transform: transform ? CSS.Transform.toString(transform) : undefined,
226
+ transition,
227
+ };
228
+ const fileName = item.fileName ?? item.alt ?? "Untitled file";
229
+
230
+ return (
231
+ <div
232
+ className={cn(
233
+ "mx-1 flex h-8 min-w-0 items-center gap-2 pr-1 pl-[7px] text-left text-sm text-[color:color-mix(in_oklab,var(--foreground)_86%,transparent)] transition-[background-color,opacity,transform] duration-150 ease-out hover:bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] motion-reduce:transition-none",
234
+ isSortable && "cursor-grab touch-none select-none",
235
+ isDragging &&
236
+ "z-10 cursor-grabbing bg-[color:color-mix(in_oklab,var(--foreground)_5%,transparent)] opacity-90",
237
+ )}
238
+ data-file-upload-preview-key={itemKey}
239
+ data-preview-dragging={isDragging ? "true" : undefined}
240
+ data-slot="file-upload-file-item"
241
+ onClick={(event) => {
242
+ event.stopPropagation();
243
+ }}
244
+ ref={setNodeRef}
245
+ style={style}
246
+ {...(isSortable ? attributes : {})}
247
+ {...(isSortable ? listeners : {})}
248
+ >
249
+ <PaperclipIcon
250
+ aria-hidden="true"
251
+ className="size-4 flex-none text-[color:color-mix(in_oklab,var(--foreground)_48%,transparent)]"
252
+ weight="regular"
253
+ />
254
+ <span
255
+ className="min-w-0 flex-1 overflow-hidden text-xs whitespace-nowrap [-webkit-mask-image:linear-gradient(to_right,black_calc(100%-20px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%-20px),transparent)]"
256
+ title={fileName}
257
+ >
258
+ {fileName}
259
+ </span>
260
+ {onPreviewRemove ? (
261
+ <Button
262
+ aria-label={`Remove ${fileName}`}
263
+ className="flex-none"
264
+ onClick={(event) => {
265
+ event.stopPropagation();
266
+ onPreviewRemove(item, index);
267
+ }}
268
+ onPointerDown={(event) => {
269
+ event.stopPropagation();
270
+ }}
271
+ size="icon-sm"
272
+ type="button"
273
+ variant="ghost"
274
+ >
275
+ <XIcon className="drop-shadow-[0_2px_1px_color-mix(in_oklab,var(--background)_80%,transparent)]" />
276
+ </Button>
277
+ ) : null}
278
+ </div>
279
+ );
280
+ }
281
+
79
282
  export function FileDropControl({
80
283
  accept,
284
+ assetKind = "image",
81
285
  multiple = false,
82
286
  onClear,
83
287
  onFileSelect,
84
288
  onFilesSelect,
85
289
  onPreviewRemove,
290
+ onPreviewReorder,
86
291
  preview,
87
292
  previews,
88
293
  }: FileDropControlProps): React.JSX.Element {
89
294
  const inputRef = React.useRef<HTMLInputElement>(null);
90
295
  const [dragOver, setDragOver] = React.useState(false);
296
+ const sensors = useSensors(
297
+ useSensor(PointerSensor, {
298
+ activationConstraint: {
299
+ distance: 6,
300
+ },
301
+ }),
302
+ useSensor(KeyboardSensor, {
303
+ coordinateGetter: sortableKeyboardCoordinates,
304
+ }),
305
+ );
91
306
  const previewItems = previews ?? (preview ? [preview] : []);
92
307
  const hasPreview = previewItems.some((item) => Boolean(item.src));
93
308
  const shouldRenderPreviewGrid = multiple && previewItems.length > 1;
309
+ const shouldRenderFileList = assetKind === "file" && hasPreview;
310
+ const previewEntries = previewItems.map((item, index) => ({
311
+ item,
312
+ key: getPreviewKey(item, index),
313
+ }));
314
+ const previewKeys = previewEntries.map((entry) => entry.key);
94
315
 
95
316
  function handleFiles(fileList: FileList | readonly File[] | undefined): void {
96
317
  const files = Array.from(fileList ?? []);
@@ -116,6 +337,11 @@ export function FileDropControl({
116
337
  inputRef.current?.click();
117
338
  }
118
339
 
340
+ function handleDropTargetClick(event: React.MouseEvent<HTMLDivElement>): void {
341
+ event.preventDefault();
342
+ openFileDialog();
343
+ }
344
+
119
345
  function handleDropTargetKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
120
346
  if (event.key !== "Enter" && event.key !== " ") {
121
347
  return;
@@ -125,6 +351,26 @@ export function FileDropControl({
125
351
  openFileDialog();
126
352
  }
127
353
 
354
+ function handlePreviewDragEnd(event: DragEndEvent): void {
355
+ const activeKey = String(event.active.id);
356
+ const overKey = event.over ? String(event.over.id) : null;
357
+
358
+ if (!onPreviewReorder || !overKey || activeKey === overKey) {
359
+ return;
360
+ }
361
+
362
+ const activeIndex = previewKeys.indexOf(activeKey);
363
+ const overIndex = previewKeys.indexOf(overKey);
364
+
365
+ if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) {
366
+ return;
367
+ }
368
+
369
+ onPreviewReorder(
370
+ arrayMove(previewEntries, activeIndex, overIndex).map((entry) => entry.item),
371
+ );
372
+ }
373
+
128
374
  return (
129
375
  <Field className="min-w-0 gap-2">
130
376
  <input
@@ -144,18 +390,32 @@ export function FileDropControl({
144
390
  aria-label={
145
391
  hasPreview
146
392
  ? multiple
147
- ? "Drop image files"
148
- : "Replace image file"
393
+ ? assetKind === "file"
394
+ ? "Drop files"
395
+ : "Drop image files"
396
+ : assetKind === "file"
397
+ ? "Replace file"
398
+ : "Replace image file"
149
399
  : multiple
150
- ? "Browse image files"
151
- : "Browse image file"
400
+ ? assetKind === "file"
401
+ ? "Browse files"
402
+ : "Browse image files"
403
+ : assetKind === "file"
404
+ ? "Browse file"
405
+ : "Browse image file"
152
406
  }
153
407
  className={cn(
154
- "group/file-upload relative flex min-h-16 w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-[color:color-mix(in_oklab,var(--border)_18%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] text-center shadow-none transition-[background-color,border-color,box-shadow] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] data-[drag-over=true]:border-[color:color-mix(in_oklab,var(--link)_28%,transparent)] data-[drag-over=true]:bg-[color:color-mix(in_oklab,var(--link)_13%,transparent)] data-[drag-over=true]:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--background)]",
155
- hasPreview ? "overflow-hidden p-2" : "px-3 py-3",
408
+ "group/file-upload relative flex min-h-16 w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-[color:color-mix(in_oklab,var(--border)_18%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] text-center shadow-none transition-[background-color,border-color,box-shadow] duration-150 ease-out data-[drag-over=true]:border-[color:color-mix(in_oklab,var(--link)_28%,transparent)] data-[drag-over=true]:bg-[color:color-mix(in_oklab,var(--link)_13%,transparent)] data-[drag-over=true]:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--background)]",
409
+ !shouldRenderFileList &&
410
+ "hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)]",
411
+ hasPreview
412
+ ? shouldRenderFileList
413
+ ? "overflow-hidden p-1.5"
414
+ : "overflow-hidden p-2"
415
+ : "px-3 py-3",
156
416
  )}
157
417
  data-drag-over={dragOver}
158
- onClick={openFileDialog}
418
+ onClick={handleDropTargetClick}
159
419
  onDragEnter={(event) => {
160
420
  event.preventDefault();
161
421
  setDragOver(true);
@@ -178,45 +438,72 @@ export function FileDropControl({
178
438
  role="button"
179
439
  tabIndex={0}
180
440
  >
181
- {shouldRenderPreviewGrid ? (
441
+ {shouldRenderFileList ? (
442
+ <div className="w-full" data-slot="file-upload-file-list">
443
+ <DndContext
444
+ collisionDetection={closestCenter}
445
+ onDragEnd={handlePreviewDragEnd}
446
+ sensors={sensors}
447
+ >
448
+ <SortableContext items={previewKeys} strategy={verticalListSortingStrategy}>
449
+ {previewEntries.map(({ item, key }, index) => (
450
+ <React.Fragment key={key}>
451
+ <SortableFileRow
452
+ index={index}
453
+ isSortable={Boolean(onPreviewReorder) && previewEntries.length > 1}
454
+ item={item}
455
+ itemKey={key}
456
+ onPreviewRemove={onPreviewRemove}
457
+ />
458
+ <div
459
+ aria-hidden="true"
460
+ className="mx-1 h-px bg-[color:color-mix(in_oklab,var(--border)_5%,transparent)]"
461
+ data-slot="file-upload-file-divider"
462
+ />
463
+ </React.Fragment>
464
+ ))}
465
+ </SortableContext>
466
+ </DndContext>
467
+ <button
468
+ aria-label="Add a new file"
469
+ className="mx-1 box-border flex h-8 w-[calc(100%-0.5rem)] min-w-0 shrink-0 items-center justify-center gap-1.5 px-1 text-xs text-[color:color-mix(in_oklab,var(--foreground)_65%,transparent)] transition-[background-color,color] duration-150 ease-out hover:bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] hover:text-[color:var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)]"
470
+ data-slot="file-upload-add-file"
471
+ onClick={(event) => {
472
+ event.stopPropagation();
473
+ openFileDialog();
474
+ }}
475
+ type="button"
476
+ >
477
+ <FileDropPlusGlyph className="size-3.5" />
478
+ <span className="font-medium">Add a new file</span>
479
+ </button>
480
+ </div>
481
+ ) : shouldRenderPreviewGrid ? (
182
482
  <div
183
483
  className="grid w-full grid-cols-4 gap-2"
184
484
  data-slot="file-upload-preview-grid"
185
485
  >
186
- {previewItems.map((item, index) => (
187
- <div
188
- className="relative aspect-square min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)-4px)] bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)]"
189
- data-slot="file-upload-preview-item"
190
- key={item.id ?? `${item.src}-${index}`}
191
- >
192
- <img
193
- alt={item.alt ?? ""}
194
- className="size-full object-cover"
195
- draggable={false}
196
- height={item.size?.height}
197
- src={item.src}
198
- width={item.size?.width}
199
- />
200
- {onPreviewRemove ? (
201
- <Button
202
- aria-label={`Remove ${item.alt ?? "image"}`}
203
- className="absolute top-1 right-1"
204
- onClick={(event) => {
205
- event.stopPropagation();
206
- onPreviewRemove(item, index);
207
- }}
208
- size="icon-sm"
209
- type="button"
210
- variant="ghost"
211
- >
212
- <XIcon className="drop-shadow-[0_2px_1px_color-mix(in_oklab,var(--background)_80%,transparent)]" />
213
- </Button>
214
- ) : null}
215
- </div>
216
- ))}
486
+ <DndContext
487
+ collisionDetection={closestCenter}
488
+ onDragEnd={handlePreviewDragEnd}
489
+ sensors={sensors}
490
+ >
491
+ <SortableContext items={previewKeys} strategy={rectSortingStrategy}>
492
+ {previewEntries.map(({ item, key }, index) => (
493
+ <SortablePreviewTile
494
+ index={index}
495
+ isSortable={Boolean(onPreviewReorder)}
496
+ item={item}
497
+ itemKey={key}
498
+ key={key}
499
+ onPreviewRemove={onPreviewRemove}
500
+ />
501
+ ))}
502
+ </SortableContext>
503
+ </DndContext>
217
504
  <button
218
505
  aria-label="Add image files"
219
- className="flex aspect-square min-w-0 items-center justify-center rounded-[calc(var(--radius-lg)-4px)] border border-[color:color-mix(in_oklab,var(--border)_5%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_4%,transparent)] text-[color:color-mix(in_oklab,var(--foreground)_65%,transparent)] transition-[background-color,border-color,color] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_7%,transparent)] hover:text-[color:var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)]"
506
+ className="flex aspect-square min-w-0 items-center justify-center rounded-[calc(var(--radius-lg)-4px)] border border-[color:color-mix(in_oklab,var(--border)_5%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_4%,transparent)] text-[color:color-mix(in_oklab,var(--foreground)_65%,transparent)] transition-[background-color,border-color,color] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_8%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_9%,transparent)] hover:text-[color:var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)]"
220
507
  data-slot="file-upload-add-preview"
221
508
  onClick={(event) => {
222
509
  event.stopPropagation();
@@ -224,7 +511,7 @@ export function FileDropControl({
224
511
  }}
225
512
  type="button"
226
513
  >
227
- <PlusIcon className="size-4" weight="regular" />
514
+ <FileDropPlusGlyph className="size-3.5" />
228
515
  </button>
229
516
  </div>
230
517
  ) : hasPreview ? (
@@ -263,8 +550,17 @@ export function FileDropControl({
263
550
  weight="light"
264
551
  />
265
552
  <p className="m-0 flex max-w-full flex-col text-xs leading-tight text-[color:color-mix(in_oklab,var(--foreground)_60%,transparent)] transition-colors duration-150 ease-out group-hover/file-upload:text-[color:color-mix(in_oklab,var(--foreground)_85%,transparent)] group-data-[drag-over=true]/file-upload:text-[color:var(--link)]">
266
- <span>Click to upload an image</span>
267
- <span>or drag it onto the canvas</span>
553
+ {assetKind === "file" ? (
554
+ <>
555
+ <span>Click to upload a file</span>
556
+ <span>or drag it onto the canvas</span>
557
+ </>
558
+ ) : (
559
+ <>
560
+ <span>Click to upload an image</span>
561
+ <span>or drag it onto the canvas</span>
562
+ </>
563
+ )}
268
564
  </p>
269
565
  </>
270
566
  )}
@@ -2,4 +2,4 @@
2
2
 
3
3
  export { FileDropControl } from "./file-drop-control";
4
4
  export type { FileDropControlProps } from "./file-drop-control";
5
- export type { FileDropPreview } from "./file-drop-control";
5
+ export type { FileDropAssetKind, FileDropPreview } from "./file-drop-control";
@@ -92,7 +92,6 @@ const defaultFontPickerFontSizePx = 16;
92
92
  const defaultFontPickerColor = "#FFFFFF";
93
93
  const defaultFontPickerOpacity = 100;
94
94
  const minFontPickerFontSizePx = 1;
95
- const maxFontPickerFontSizePx = 512;
96
95
 
97
96
  const menuItemInteractionClassName =
98
97
  "hover:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] hover:text-[color:var(--foreground)] focus:bg-[color:color-mix(in_oklab,var(--muted-foreground)_10%,transparent)] focus:text-[color:var(--foreground)]";
@@ -254,10 +253,7 @@ function normalizeFontPickerFontSize(value: unknown): number {
254
253
  return defaultFontPickerFontSizePx;
255
254
  }
256
255
 
257
- return Math.min(
258
- maxFontPickerFontSizePx,
259
- Math.max(minFontPickerFontSizePx, Math.round(value)),
260
- );
256
+ return Math.max(minFontPickerFontSizePx, Math.round(value));
261
257
  }
262
258
 
263
259
  function getFontFamilyStyle(font: FontPickerFontCatalogEntry | null): React.CSSProperties | undefined {
@@ -1131,7 +1127,6 @@ export function FontPickerControl({
1131
1127
  aria-label="Font size"
1132
1128
  className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
1133
1129
  disabled={disabled}
1134
- max={maxFontPickerFontSizePx}
1135
1130
  min={minFontPickerFontSizePx}
1136
1131
  onBlur={() => commitFontSizeDraft()}
1137
1132
  onChange={(event) => setFontSizeDraft(event.target.value)}
@@ -84,6 +84,14 @@ export type {
84
84
  ChannelMixerControlProps as ChannelMixerProps,
85
85
  ChannelMixerValues,
86
86
  } from "./channel-mixer";
87
+ export {
88
+ CollectionActionsControl,
89
+ CollectionActionsControl as CollectionActions,
90
+ } from "./collection-actions";
91
+ export type {
92
+ CollectionActionsControlProps,
93
+ CollectionActionsControlProps as CollectionActionsProps,
94
+ } from "./collection-actions";
87
95
  export { createControlHistoryGroupId } from "./control-types";
88
96
  export type {
89
97
  ControlChangeHistoryMode,
@@ -107,6 +115,7 @@ export type {
107
115
  } from "./curves";
108
116
  export { FileDropControl, FileDropControl as FileDrop } from "./file-drop";
109
117
  export type {
118
+ FileDropAssetKind,
110
119
  FileDropControlProps,
111
120
  FileDropControlProps as FileDropProps,
112
121
  FileDropPreview,
@@ -109,6 +109,10 @@ function PanelSectionCollapsibleBody({
109
109
  }): React.JSX.Element | null {
110
110
  const [shouldRender, setShouldRender] = React.useState(!collapsed);
111
111
  const [isVisuallyCollapsed, setIsVisuallyCollapsed] = React.useState(collapsed);
112
+ const suppressControlTransitions =
113
+ useInitialPanelSectionControlTransitionSuppression(
114
+ `${collapsed ? "collapsed" : "expanded"}:${shouldRender ? "mounted" : "unmounted"}`,
115
+ );
112
116
 
113
117
  React.useEffect(() => {
114
118
  if (collapsed) {
@@ -155,11 +159,59 @@ function PanelSectionCollapsibleBody({
155
159
  data-slot="panel-section-collapsible-body"
156
160
  onTransitionEnd={handleTransitionEnd}
157
161
  >
158
- <div className="min-h-0 overflow-hidden">{children}</div>
162
+ <div
163
+ className="min-h-0 overflow-hidden"
164
+ data-toolcraft-controls-mounting={
165
+ suppressControlTransitions ? "true" : undefined
166
+ }
167
+ >
168
+ {children}
169
+ </div>
159
170
  </div>
160
171
  );
161
172
  }
162
173
 
174
+ function useInitialPanelSectionControlTransitionSuppression(
175
+ dependency: unknown,
176
+ ): boolean {
177
+ const [suppressionState, setSuppressionState] = React.useState(() => ({
178
+ dependency,
179
+ isSuppressing: true,
180
+ }));
181
+
182
+ React.useEffect(() => {
183
+ setSuppressionState({ dependency, isSuppressing: true });
184
+
185
+ if (typeof window === "undefined") {
186
+ return undefined;
187
+ }
188
+
189
+ if (typeof window.requestAnimationFrame !== "function") {
190
+ const timeout = window.setTimeout(() => {
191
+ setSuppressionState({ dependency, isSuppressing: false });
192
+ }, 0);
193
+
194
+ return () => window.clearTimeout(timeout);
195
+ }
196
+
197
+ let secondFrame = 0;
198
+ const firstFrame = window.requestAnimationFrame(() => {
199
+ secondFrame = window.requestAnimationFrame(() => {
200
+ setSuppressionState({ dependency, isSuppressing: false });
201
+ });
202
+ });
203
+
204
+ return () => {
205
+ window.cancelAnimationFrame(firstFrame);
206
+ window.cancelAnimationFrame(secondFrame);
207
+ };
208
+ }, [dependency]);
209
+
210
+ return (
211
+ suppressionState.dependency !== dependency || suppressionState.isSuppressing
212
+ );
213
+ }
214
+
163
215
  function getPanelSectionChildKey(
164
216
  child: React.ReactNode,
165
217
  index: number,
@@ -12,6 +12,7 @@ export {
12
12
  ChannelMixer,
13
13
  Checkbox,
14
14
  CodeTextarea,
15
+ CollectionActions,
15
16
  Color,
16
17
  ColorOpacity,
17
18
  ColorValue,