@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
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { CloudArrowUpIcon, XIcon } from "@phosphor-icons/react";
4
+ import { CloudArrowUpIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
5
5
 
6
6
  import { cn } from "../../../lib/utils";
7
7
  import { Button, Field } from "../../primitives";
8
8
 
9
9
  export type FileDropPreview = {
10
10
  alt?: string;
11
+ id?: string;
11
12
  size?: {
12
13
  height: number;
13
14
  width: number;
@@ -17,9 +18,13 @@ export type FileDropPreview = {
17
18
 
18
19
  export type FileDropControlProps = {
19
20
  accept: string;
21
+ multiple?: boolean;
20
22
  onClear?: () => void;
21
23
  onFileSelect?: (file: File) => void;
24
+ onFilesSelect?: (files: File[]) => void;
25
+ onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
22
26
  preview?: FileDropPreview;
27
+ previews?: readonly FileDropPreview[];
23
28
  };
24
29
 
25
30
  function isDragLeavingCurrentTarget(event: React.DragEvent<HTMLElement>): boolean {
@@ -73,20 +78,38 @@ function getPreviewImageStyle(size: FileDropPreview["size"]): React.CSSPropertie
73
78
 
74
79
  export function FileDropControl({
75
80
  accept,
81
+ multiple = false,
76
82
  onClear,
77
83
  onFileSelect,
84
+ onFilesSelect,
85
+ onPreviewRemove,
78
86
  preview,
87
+ previews,
79
88
  }: FileDropControlProps): React.JSX.Element {
80
89
  const inputRef = React.useRef<HTMLInputElement>(null);
81
90
  const [dragOver, setDragOver] = React.useState(false);
82
- const hasPreview = Boolean(preview?.src);
91
+ const previewItems = previews ?? (preview ? [preview] : []);
92
+ const hasPreview = previewItems.some((item) => Boolean(item.src));
93
+ const shouldRenderPreviewGrid = multiple && previewItems.length > 1;
83
94
 
84
- function handleFile(file: File | undefined): void {
85
- if (!file) {
95
+ function handleFiles(fileList: FileList | readonly File[] | undefined): void {
96
+ const files = Array.from(fileList ?? []);
97
+
98
+ if (files.length === 0) {
86
99
  return;
87
100
  }
88
101
 
89
- onFileSelect?.(file);
102
+ if (multiple) {
103
+ if (onFilesSelect) {
104
+ onFilesSelect(files);
105
+ return;
106
+ }
107
+
108
+ files.forEach((file) => onFileSelect?.(file));
109
+ return;
110
+ }
111
+
112
+ onFileSelect?.(files[0]);
90
113
  }
91
114
 
92
115
  function openFileDialog(): void {
@@ -109,15 +132,24 @@ export function FileDropControl({
109
132
  aria-hidden="true"
110
133
  className="hidden"
111
134
  onChange={(event) => {
112
- handleFile(event.currentTarget.files?.[0]);
135
+ handleFiles(event.currentTarget.files ?? undefined);
113
136
  event.currentTarget.value = "";
114
137
  }}
138
+ multiple={multiple}
115
139
  ref={inputRef}
116
140
  tabIndex={-1}
117
141
  type="file"
118
142
  />
119
143
  <div
120
- aria-label={hasPreview ? "Replace image file" : "Browse image file"}
144
+ aria-label={
145
+ hasPreview
146
+ ? multiple
147
+ ? "Drop image files"
148
+ : "Replace image file"
149
+ : multiple
150
+ ? "Browse image files"
151
+ : "Browse image file"
152
+ }
121
153
  className={cn(
122
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)]",
123
155
  hasPreview ? "overflow-hidden p-2" : "px-3 py-3",
@@ -140,23 +172,74 @@ export function FileDropControl({
140
172
  onDrop={(event) => {
141
173
  event.preventDefault();
142
174
  setDragOver(false);
143
- handleFile(event.dataTransfer?.files?.[0]);
175
+ handleFiles(event.dataTransfer?.files ?? undefined);
144
176
  }}
145
177
  onKeyDown={handleDropTargetKeyDown}
146
178
  role="button"
147
179
  tabIndex={0}
148
180
  >
149
- {hasPreview ? (
181
+ {shouldRenderPreviewGrid ? (
182
+ <div
183
+ className="grid w-full grid-cols-4 gap-2"
184
+ data-slot="file-upload-preview-grid"
185
+ >
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
+ ))}
217
+ <button
218
+ 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)]"
220
+ data-slot="file-upload-add-preview"
221
+ onClick={(event) => {
222
+ event.stopPropagation();
223
+ openFileDialog();
224
+ }}
225
+ type="button"
226
+ >
227
+ <PlusIcon className="size-4" weight="regular" />
228
+ </button>
229
+ </div>
230
+ ) : hasPreview ? (
150
231
  <>
151
- <img
152
- alt={preview?.alt ?? ""}
153
- className="block h-auto max-h-[196px] max-w-full rounded-[calc(var(--radius-lg)-4px)] object-contain"
154
- draggable={false}
155
- height={preview?.size?.height}
156
- src={preview?.src}
157
- style={getPreviewImageStyle(preview?.size)}
158
- width={preview?.size?.width}
159
- />
232
+ {previewItems[0] ? (
233
+ <img
234
+ alt={previewItems[0].alt ?? ""}
235
+ className="block h-auto max-h-[196px] max-w-full rounded-[calc(var(--radius-lg)-4px)] object-contain"
236
+ draggable={false}
237
+ height={previewItems[0].size?.height}
238
+ src={previewItems[0].src}
239
+ style={getPreviewImageStyle(previewItems[0].size)}
240
+ width={previewItems[0].size?.width}
241
+ />
242
+ ) : null}
160
243
  {onClear ? (
161
244
  <Button
162
245
  aria-label="Remove image"
@@ -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,
@@ -1,8 +1,11 @@
1
+ import { applySliderValueLabelUnit } from "../slider/slider-value";
2
+
1
3
  export function formatRangeSliderValue(
2
4
  value: readonly number[],
3
5
  unit?: string,
4
6
  ): string {
5
- const formatValue = (item: number): string => `${Math.round(item)}${unit ?? ""}`;
7
+ const formatValue = (item: number): string =>
8
+ applySliderValueLabelUnit(String(Math.round(item)), unit);
6
9
 
7
10
  if (value.length >= 2 && value[0] === value[1]) {
8
11
  return formatValue(value[0] ?? 0);
@@ -17,19 +17,62 @@ export function formatSliderValue(value: number, step: number): string {
17
17
  return String(rounded);
18
18
  }
19
19
 
20
+ const compactSliderValueUnits = new Set([
21
+ "%",
22
+ "°",
23
+ "px",
24
+ "em",
25
+ "rem",
26
+ "vw",
27
+ "vh",
28
+ "vmin",
29
+ "vmax",
30
+ "x",
31
+ "s",
32
+ "ms",
33
+ ]);
34
+
35
+ function escapeRegExp(value: string): string {
36
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ export function getSliderValueUnitSeparator(unit: string): "" | " " {
40
+ const normalizedUnit = unit.trim();
41
+
42
+ if (
43
+ normalizedUnit === "" ||
44
+ compactSliderValueUnits.has(normalizedUnit) ||
45
+ /^[^\p{Letter}\p{Number}]+$/u.test(normalizedUnit)
46
+ ) {
47
+ return "";
48
+ }
49
+
50
+ return " ";
51
+ }
52
+
20
53
  export function applySliderValueLabelUnit(
21
54
  valueLabel: string,
22
55
  unit?: string,
23
56
  ): string {
24
- if (!unit || typeof parseSliderValueLabel(valueLabel) !== "number") {
57
+ const normalizedUnit = unit?.trim();
58
+
59
+ if (
60
+ !normalizedUnit ||
61
+ typeof parseSliderValueLabel(valueLabel) !== "number"
62
+ ) {
25
63
  return valueLabel;
26
64
  }
27
65
 
28
- return valueLabel.replaceAll(/-?\d+(?:\.\d+)?/g, (match, offset) => {
29
- const textAfterMatch = valueLabel.slice(offset + match.length).trimStart();
66
+ const separator = getSliderValueUnitSeparator(normalizedUnit);
67
+ const unitPattern = new RegExp(
68
+ `(-?\\d+(?:\\.\\d+)?)(?:\\s*${escapeRegExp(normalizedUnit)})?`,
69
+ "g",
70
+ );
30
71
 
31
- return textAfterMatch.startsWith(unit) ? match : `${match}${unit}`;
32
- });
72
+ return valueLabel.replaceAll(
73
+ unitPattern,
74
+ (_match, value: string) => `${value}${separator}${normalizedUnit}`,
75
+ );
33
76
  }
34
77
 
35
78
  export function formatSliderValueWithUnit(
@@ -40,6 +40,7 @@ export function EditableSliderValueLabel({
40
40
  const [editing, setEditing] = useState(false);
41
41
  const editorRef = useRef<HTMLSpanElement>(null);
42
42
  const valueLabelRef = useRef(valueLabel);
43
+ const isEditableValueLabel = hasEditableNumericValueLabel(valueLabel);
43
44
  const valueTextClassName = getEditableValueTextClassName({ layout, textAlign });
44
45
  const widestValueLabel = getWidestValueLabel(valueLabel, maxValueLabel);
45
46
 
@@ -61,7 +62,7 @@ export function EditableSliderValueLabel({
61
62
  }
62
63
  }, [editing]);
63
64
 
64
- if (disabled || !onCommit) {
65
+ if (disabled || !onCommit || !isEditableValueLabel) {
65
66
  const valueTextToneClassName = disabled
66
67
  ? "text-[color:color-mix(in_oklab,var(--foreground)_60%,transparent)] opacity-60"
67
68
  : "text-[color:var(--muted-foreground)]";
@@ -323,6 +324,10 @@ function getWidestValueLabel(valueLabel: string, maxValueLabel?: string): string
323
324
  return maxValueLabel;
324
325
  }
325
326
 
327
+ function hasEditableNumericValueLabel(valueLabel: string): boolean {
328
+ return /-?\d+(?:\.\d+)?/.test(valueLabel);
329
+ }
330
+
326
331
  function selectEditableText(node: HTMLElement): void {
327
332
  const selection = window.getSelection();
328
333
 
@@ -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,