@pixel-point/toolcraft 0.0.4 → 0.0.6

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 (39) 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 +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +59 -2
  6. package/templates/runtime/contracts/component-contracts.ts +23 -2
  7. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  8. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  9. package/templates/runtime/react/controls-panel.test.tsx +269 -0
  10. package/templates/runtime/react/controls-panel.tsx +133 -24
  11. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  12. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  13. package/templates/runtime/react/timeline-panel.tsx +98 -10
  14. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  15. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  16. package/templates/runtime/schema/define-toolcraft.test.ts +77 -1
  17. package/templates/runtime/schema/define-toolcraft.ts +138 -5
  18. package/templates/runtime/schema/runtime-targets.ts +1 -0
  19. package/templates/runtime/schema/types.ts +23 -1
  20. package/templates/runtime/state/canvas-zoom.ts +1 -1
  21. package/templates/runtime/state/create-template-state.test.ts +6 -6
  22. package/templates/runtime/state/reducer.test.ts +86 -8
  23. package/templates/runtime/state/reducer.ts +41 -22
  24. package/templates/runtime/state/types.ts +1 -0
  25. package/templates/starter/AGENTS.md +2 -2
  26. package/templates/starter/docs/toolcraft/README.md +1 -1
  27. package/templates/starter/docs/toolcraft/acceptance-testing.md +1 -1
  28. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  29. package/templates/starter/docs/toolcraft/component-rules.md +13 -1
  30. package/templates/starter/docs/toolcraft/performance.md +5 -0
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  32. package/templates/starter/docs/toolcraft/schema-reference.md +10 -4
  33. package/templates/starter/gitignore +36 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  35. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  36. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  37. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  38. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  39. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -146,7 +146,9 @@ Async product actions such as Export, Download, Copy, Generate, or Apply must re
146
146
 
147
147
  For complex apps, use schema `settingsTransfer: "auto"` or `true` for settings import/export. Recalculate settings-transfer eligibility after adding, removing, or reorganizing controls, sections, timeline, or layers. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. Do not put Import Settings or Export Settings in sticky footer `panelActions`; runtime inserts the technical `Setup` settings-transfer section first without a visible section heading.
148
148
 
149
- If the app also uses `editable-output` canvas sizing, that first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split the canvas size fields and settings-transfer actions into app-authored sections.
149
+ If the app also uses `editable-output` canvas sizing, that first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, `Canvas height`, and optional `Resolution scale` in that order. Do not split the canvas size fields and settings-transfer actions into app-authored sections.
150
+
151
+ For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` to the same first technical section. The slider changes backing resolution from `1x` to `2x` without changing visible canvas size; DOM/SVG/vector-native previews should not use it.
150
152
 
151
153
  If a controls panel shows only `Export Settings` and `Import Settings` in the first runtime section, check the canvas sizing decision. Product-output apps usually need `editable-output`; intrinsic media and explicitly fixed output are the cases where visible canvas size inputs are absent.
152
154
 
@@ -197,7 +199,7 @@ Do not rerun `pnpm install` after every edit. Run it after fresh export, depende
197
199
 
198
200
  Use `pnpm verify:ui` when a tier calls for the browser acceptance suite without the performance suite. Use a focused named Playwright test instead when only one entity changed and the relevant test is already known.
199
201
 
200
- Run a full performance checkpoint with `pnpm verify:perf` when the first working version of the app exists, when renderer/canvas/animation/export/timeline/layers change, after fixing a bug that previously broke functionality, after any performance optimization, or when the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
202
+ Run a full performance checkpoint with `pnpm verify:perf` when the first working version of the app exists, when renderer/canvas/animation/export/timeline/layers change, when `canvas.renderScale` or the `Resolution scale` retina slider is added/enabled, after fixing a bug that previously broke functionality, after any performance optimization, or when the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
201
203
 
202
204
  Fast feature loops may defer full performance only when none of the checkpoint triggers apply. Record the deferred check and reason in the worklog.
203
205
 
@@ -50,6 +50,10 @@ Visual discrete sliders must declare `step`; the runtime derives one marker per
50
50
 
51
51
  Schema sliders always render stacked at full width. Do not put `slider` or `rangeSlider` controls in two-column inline rows. The only built-in exception is `fontPicker`, whose letter-spacing and line-height footer sliders stay paired inside that component.
52
52
 
53
+ Use slider `unit` only for measurement or scale suffixes: `%`, `px`, `°`, `x`, `s`, `ms`, `fps`, `rows`, `cols`, or a similarly useful domain unit. Do not use `unit` to repeat the entity already named by the section or label. Avoid `Letters` + `letters`, `Shape Density / Count` + `shapes`, `Words` + `words`, `Symbols` + `symbols`, `Items` + `items`, `Particles` + `particles`, and `Layers` + `layers`. If the numeric value needs an entity noun to make sense, rename the label or section instead of appending the noun to the value. Compact units render tight (`70%`, `24px`, `1.2x`, `8s`); word or acronym units render with a space (`5 cols`, `17 fps`) only when they are truly needed.
54
+
55
+ Slider value labels are editable only when they contain a numeric value. Textual state labels such as `Normal` are display-only and must not expose hover or click editing affordances.
56
+
53
57
  Range sliders are always full-width two-thumb controls. Do not put a `rangeSlider` in an inline row. Its `defaultValue` must start with different lower and upper values, such as `[20, 80]`, so the control does not collapse into a single-value slider.
54
58
 
55
59
  Range slider value editing accepts common range separators such as `20/80`, `20-80`, `20 - 80`, `20 80`, and en-dash ranges. Use the built-in parser instead of adding custom label parsing.
@@ -134,6 +138,8 @@ Use `fileDrop` for source material uploads in the controls panel. Do not place u
134
138
 
135
139
  In single-layer apps, the runtime shows uploaded image preview and clear button in the file control. Clearing removes source material from the renderer and canvas.
136
140
 
141
+ Use `multiple: true` when the app needs several uploaded images as one source set. The runtime appends media, switches to a four-column thumbnail grid when more than one image is present, puts the add-more tile last, and keeps per-image removal inside the file control.
142
+
137
143
  In multi-layer apps, deletion and visibility belong to the Layers panel; `fileDrop` stays an upload target.
138
144
 
139
145
  ## Image Picker
@@ -211,6 +217,8 @@ Short labels must still be semantically sufficient with nearby context. `Animati
211
217
 
212
218
  Visible control labels can get a runtime-owned filled Phosphor question tooltip icon. Put a concise product-specific explanation in `description` only when it adds meaning beyond the label. Do not write recaps like `Adjusts Opacity`, and do not build custom help icons beside built-in labels.
213
219
 
220
+ Do not add `description` to obvious color clusters. If a section title already names the palette/color context, sequential labels such as `Color 1`, `Color 2`, or simple palette controls such as `Spread` do not need help icons. Keep the whole obvious group clean unless the tooltip explains a non-obvious product behavior.
221
+
214
222
  For compound controls such as `fontPicker`, `description` must not enumerate owned fields like font, weight, size, case, color, opacity, letter spacing, or line height. The component already labels those fields.
215
223
 
216
224
  If a source label is unavoidably long, keep the visible label concise and rely on native `title` for the full text.
@@ -267,10 +275,14 @@ Use schema `settingsTransfer` for settings import/export. Do not add Import Sett
267
275
 
268
276
  Recalculate settings-transfer eligibility after adding, removing, or reorganizing controls, sections, timeline, or layers. The runtime threshold is 12 product controls, 5 product sections, or weighted score 18. If the threshold is reached, use `settingsTransfer: "auto"` / `true` or document a product-specific opt-out through `runtime.settingsTransfer` acceptance evidence.
269
277
 
270
- When settings transfer and editable-output canvas sizing are both enabled, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate app-authored sections, rename the controls, or rebuild the block by hand.
278
+ When settings transfer and editable-output canvas sizing are both enabled, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, `Canvas height`, and optional `Resolution scale` in that order. Do not split these into separate app-authored sections, rename the controls, or rebuild the block by hand.
271
279
 
272
280
  If only `Export Settings` and `Import Settings` appear in that section, the schema is not using `editable-output` canvas sizing or already owns `canvas.size.width` / `canvas.size.height` controls. For product-output apps, prefer fixing the canvas sizing decision over adding hand-built size fields.
273
281
 
282
+ Manual `Canvas width` or `Canvas height` edits are exact output-size edits. They keep the other dimension unchanged, switch `Aspect ratio` to `Custom`, and update the custom ratio inputs to the reduced current ratio. Do not recreate the old behavior where typing one size field stays locked to the previous aspect preset.
283
+
284
+ Enable `canvas.renderScale: true` for non-vector raster previews such as Canvas 2D, WebGL, or WebGPU output. Runtime adds a `Resolution scale` slider after canvas sizing; it defaults to `2x` and lets users trade preview quality/performance without changing output size. Adding or enabling this slider requires a full `pnpm verify:perf` checkpoint. Performance fixes must preserve the selected scale and keep canvas preview responsive while dragging sliders or other high-frequency controls. Diagnose the actual bottleneck before lowering quality; do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp `canvas.renderScale` below the user's chosen value. Do not enable it for DOM/SVG/vector-native previews.
285
+
274
286
  Reset belongs to the controls panel header reset button. Do not add a footer action with `label`, `value`, or `command` containing reset; acceptance treats that as a duplicate Reset.
275
287
 
276
288
  Still-output product apps include one primary `Export PNG` action.
@@ -55,6 +55,8 @@ Ordinary controls still need lightweight responsiveness checks. They should not
55
55
  - panel scroll affecting canvas zoom;
56
56
  - timeline or layer interactions destabilizing the viewport.
57
57
 
58
+ When `canvas.renderScale` / `Resolution scale` is enabled, responsiveness coverage must include slider or other high-frequency control drags at the selected scale. If the canvas lags, diagnose the source before changing quality: renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, stale async renders, or animation scheduling.
59
+
58
60
  ## Renderer Performance
59
61
 
60
62
  Custom renderers should:
@@ -105,8 +107,11 @@ Run a full performance checkpoint with `pnpm verify:perf` when:
105
107
 
106
108
  - the first working version of the app exists;
107
109
  - renderer, canvas, animation, export, timeline, or layers change;
110
+ - `canvas.renderScale` or the `Resolution scale` retina slider is added/enabled;
108
111
  - a bug that previously broke functionality is fixed;
109
112
  - a performance optimization lands;
110
113
  - the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
111
114
 
115
+ Performance fixes must preserve selected output and preview quality. Do not pass budgets by lowering image quality, selected `canvas.renderScale`, export resolution, source media fidelity, or canvas backing pixels unless the user explicitly chooses that lower-quality value through a visible control. Prefer coalescing slider updates, caching expensive inputs, moving work off the React render path, reusing GPU resources, or changing renderer strategy over reducing visual fidelity.
116
+
112
117
  Do not use the full performance suite as the default loop for Tier 0-2 edits. Those edits still need the targeted checks named by the verification tier, but they should not pay for renderer and viewport stress tests unless a checkpoint trigger applies. If a fast feature loop defers full performance, record the deferred check and reason in the worklog.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Choose render technology per product layer. Do not choose a renderer because it is convenient; choose it from product output semantics, reference behavior, fidelity, and workload.
4
4
 
5
- The initial renderer choice is provisional. It becomes accepted only after the app passes performance checks with the largest useful product canvas and the heaviest useful values for its own controls. If those checks show frame gaps, long tasks, viewport shaking, slow export, or interaction jank, revise the renderer strategy from that evidence before delivery.
5
+ The initial renderer choice is provisional. It becomes accepted only after the app passes performance checks with the largest useful product canvas and the heaviest useful values for its own controls. If those checks show frame gaps, long tasks, viewport shaking, slow export, or interaction jank, revise the renderer strategy from that evidence before delivery. Do not make a renderer look fast by silently reducing the selected preview scale, backing pixels, source media quality, or export fidelity.
6
6
 
7
7
  ## Strategy Guide
8
8
 
@@ -9,7 +9,7 @@ Edit `src/app/app-schema.ts` as the public product surface.
9
9
  - Use `settingsTransfer: "auto"` for complex apps that should let users import/export control settings.
10
10
  - Bind every control to a schema `target`.
11
11
  - Use `defaultValue` for reset behavior.
12
- - Use `description` for product-specific help beside a visible label. Keep `label` short. Omit `description` instead of writing label recaps like `Adjusts Opacity`; compound controls such as `fontPicker` must not use `description` to list their own fields.
12
+ - Use `description` for product-specific help beside a visible label. Keep `label` short. Omit `description` instead of writing label recaps like `Adjusts Opacity`; also omit it for obvious color clusters such as `Color 1` / `Color 2` inside a color section. Compound controls such as `fontPicker` must not use `description` to list their own fields.
13
13
  - Use `disabled: true` only when the control is intentionally unavailable; the runtime renders the disabled visual and interaction state.
14
14
  - Use `visibleWhen` when a control or section exists only for a specific template, type, mode, variant, or count. Hidden values are preserved. A section with no visible controls is hidden automatically.
15
15
  - Use `disabledWhen` when a control belongs to the current entity but is temporarily unavailable in the selected state. The value is preserved while disabled.
@@ -134,10 +134,14 @@ Choose sizing from product context:
134
134
 
135
135
  For product output, export, copy, download, shader rendering, procedural rendering, or no single intrinsic source image, use `editable-output` unless the product explicitly needs `fixed-output`.
136
136
 
137
- A prompt-provided base/default size is only the initial `canvas.size`. It must not remove the runtime Aspect ratio, Canvas width, and Canvas height controls. Aspect presets use canonical output sizes (`16:9` is `1920x1080`; the other presets are derived around a 1080px short edge or matching portrait long edge). Use `fixed-output` only when the reference or product explicitly locks dimensions, and add runtime acceptance with `canvasSizingCoverage: "fixed-output-size"`.
137
+ A prompt-provided base/default size is only the initial `canvas.size`. It must not remove the runtime Aspect ratio, Canvas width, and Canvas height controls. Aspect presets use canonical output sizes (`16:9` is `1920x1080`; the other presets are derived around a 1080px short edge or matching portrait long edge). When no explicit product size is provided, runtime defaults to `16:9` / `1920x1080`; choose another preset only when the product meaning calls for it. Use `fixed-output` only when the reference or product explicitly locks dimensions, and add runtime acceptance with `canvasSizingCoverage: "fixed-output-size"`.
138
138
 
139
139
  Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are mandatory only for `editable-output` sizing. They do not depend on `settingsTransfer`: when settings transfer is off, the runtime prepends a technical `Setup` canvas size section without a visible heading; when settings transfer is on, the controls merge into the first technical `Setup` runtime settings section without a visible heading. Do not hand-build a duplicate size selector.
140
140
 
141
+ When the user manually edits `Canvas width` or `Canvas height`, the runtime treats that as an exact custom output size. It keeps the typed dimension, keeps the other dimension unchanged, switches `Aspect ratio` to `Custom`, and shows the reduced current ratio in the custom ratio inputs. Only selecting an aspect preset may resize both dimensions from a canonical preset.
142
+
143
+ For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` after canvas sizing in the first technical `Setup` section. The slider ranges from `1x` to `2x`, defaults to `2x`, and changes the renderer backing pixels without changing the visible CSS size or product output dimensions. Adding or enabling this slider requires a full `pnpm verify:perf` checkpoint. Performance fixes must preserve the selected scale and keep the canvas responsive while dragging sliders or other high-frequency controls at that scale. Diagnose whether lag comes from renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, stale async renders, or animation scheduling before reducing quality. Do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp `canvas.renderScale` below the user's chosen value. Do not enable it for DOM/SVG/vector-native previews; use native vector rendering for those.
144
+
141
145
  ## Panels
142
146
 
143
147
  - Use `panels: {}` for the neutral starter or for products that have no user-facing panels yet.
@@ -161,7 +165,7 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
161
165
  | `color` | Hex color picker | `defaultValue: { hex }`, `target`, `label` |
162
166
  | `colorOpacity` | Hex color picker plus opacity percent input | `defaultValue: { hex, opacity }`, `target`, `label` |
163
167
  | `curves` | RGB or single curve editor | `defaultValue`, `target`, `variant: "single"`, `interpolation: "smooth" \| "monotone"` |
164
- | `fileDrop` | Upload/drop input | `accept`, `target` |
168
+ | `fileDrop` | Upload/drop input | `accept`, `multiple`, `target` |
165
169
  | `fontPicker` | Font preview select with popup, category search, weight, size, text case, text color/opacity, letter spacing, and line height; product text must consume `fontId`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `textCase`, `color`, and `opacity` | `defaultValue: { fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, opacity }`, `target` |
166
170
  | `gradient` | Gradient editor | `defaultValue: { angle, gradientType, stops }`, `target` |
167
171
  | `imagePicker` | Image choice grid | `items`, `defaultValue`, `target` |
@@ -178,6 +182,8 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
178
182
 
179
183
  `text` defaults to `commitMode: "content"` and applies while typing for real content such as prompts, names, titles, tokens, and short text. Use `commitMode: "setting"` for text inputs that edit settings such as font size, numeric-like style values, dimensions, ids, or configuration fields; setting text commits on blur or Enter. Canvas width and Canvas height always commit on blur or Enter. `code` / `CodeTextarea` is a content editor, applies while typing, and is capped at 12 visible lines. Long content scrolls inside the textarea instead of making the controls panel taller.
180
184
 
185
+ For `slider` and `rangeSlider`, `unit` is a measurement or scale suffix, not the entity being counted. Use units such as `%`, `px`, `°`, `x`, `s`, `ms`, `fps`, `rows`, or `cols` only when they clarify the number. Do not add repeated nouns such as `letters`, `shapes`, `words`, `symbols`, `items`, `particles`, or `layers` when the label or section already names that entity. If the value needs a noun, rename the label or section. Word or acronym units, when truly needed, render with a space (`5 cols`, `17 fps`); compact symbol/CSS units stay tight (`70%`, `24px`).
186
+
181
187
  ## Control Selection Inventory
182
188
 
183
189
  Before writing schema controls, map product needs to built-ins by value model, not visual similarity.
@@ -296,7 +302,7 @@ When enabled, the runtime inserts a technical `Setup` settings-transfer section
296
302
 
297
303
  After adding, removing, or reorganizing controls, sections, timeline, or layers, recalculate settings-transfer eligibility. If the threshold is reached, use `"auto"` / `true` or add an explicit `runtime.settingsTransfer` opt-out acceptance row with product evidence.
298
304
 
299
- When settings transfer is enabled and the canvas uses `editable-output` sizing, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, and `Canvas height` in that order. Do not split these into separate sections or recreate them manually.
305
+ When settings transfer is enabled and the canvas uses `editable-output` sizing, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, `Canvas height`, and optional `Resolution scale` in that order. Do not split these into separate sections or recreate them manually.
300
306
 
301
307
  A settings-transfer section with only `Export Settings` and `Import Settings` means the canvas is not `editable-output` or the app already declares its own `canvas.size.width` / `canvas.size.height` controls. For product-output apps, treat that as a schema decision to review.
302
308
 
@@ -0,0 +1,36 @@
1
+ # Dependencies
2
+ node_modules
3
+
4
+ # Build output
5
+ dist
6
+ dist-ssr
7
+ .vite
8
+ .turbo
9
+ .output
10
+ .next
11
+ *.tsbuildinfo
12
+
13
+ # Test output
14
+ coverage
15
+ test-results
16
+ playwright-report
17
+
18
+ # Local environment
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+
23
+ # Logs
24
+ logs
25
+ *.log
26
+ npm-debug.log*
27
+ pnpm-debug.log*
28
+ yarn-debug.log*
29
+
30
+ # OS and editor files
31
+ .DS_Store
32
+ Thumbs.db
33
+ .idea
34
+ .vscode/*
35
+ !.vscode/extensions.json
36
+ !.vscode/settings.json
@@ -5997,6 +5997,61 @@ describe("Toolcraft template app acceptance coverage", () => {
5997
5997
  );
5998
5998
  });
5999
5999
 
6000
+ it("rejects redundant descriptions in obvious color sections", () => {
6001
+ const schemaWithObviousColorHelp = defineToolcraft({
6002
+ canvas: { enabled: true },
6003
+ panels: {
6004
+ controls: {
6005
+ sections: [
6006
+ {
6007
+ controls: {
6008
+ color1: {
6009
+ defaultValue: { hex: "#DFFF1A" },
6010
+ description: "Sets the first bead color.",
6011
+ label: "Color 1",
6012
+ target: "beads.color1",
6013
+ type: "color",
6014
+ },
6015
+ color2: {
6016
+ defaultValue: { hex: "#8CFF3A" },
6017
+ description: "Sets the second bead color.",
6018
+ label: "Color 2",
6019
+ target: "beads.color2",
6020
+ type: "color",
6021
+ },
6022
+ colorSpread: {
6023
+ defaultValue: 34,
6024
+ description:
6025
+ "Controls how often beads use colors 2-5 instead of Color 1.",
6026
+ label: "Spread",
6027
+ max: 100,
6028
+ min: 0,
6029
+ target: "beads.colorSpread",
6030
+ type: "slider",
6031
+ unit: "%",
6032
+ },
6033
+ },
6034
+ title: "Bead Colors",
6035
+ },
6036
+ ],
6037
+ title: "Controls",
6038
+ },
6039
+ },
6040
+ });
6041
+
6042
+ expect(validateToolcraftAcceptanceCoverage(schemaWithObviousColorHelp, [
6043
+ makeControlAcceptance("beads.color1", "color"),
6044
+ makeControlAcceptance("beads.color2", "color"),
6045
+ makeControlAcceptance("beads.colorSpread", "slider"),
6046
+ ])).toEqual(
6047
+ expect.arrayContaining([
6048
+ "Bead Colors / color1 description adds a help icon to an obvious color-section control. Omit control.description when the section title and visible label already explain the setting.",
6049
+ "Bead Colors / color2 description adds a help icon to an obvious color-section control. Omit control.description when the section title and visible label already explain the setting.",
6050
+ "Bead Colors / colorSpread description adds a help icon to an obvious color-section control. Omit control.description when the section title and visible label already explain the setting.",
6051
+ ]),
6052
+ );
6053
+ });
6054
+
6000
6055
  it("requires mode selectors to appear before dependent controls", () => {
6001
6056
  const schemaWithLateModeSelector = {
6002
6057
  ...starterSchema,
@@ -1561,14 +1561,33 @@ function getToolcraftControlDescriptionError({
1561
1561
  control,
1562
1562
  controlId,
1563
1563
  sectionLabel,
1564
+ sectionTitle,
1564
1565
  }: {
1565
1566
  control: ToolcraftControlSchema;
1566
1567
  controlId: string;
1567
1568
  sectionLabel: string;
1569
+ sectionTitle: string | undefined;
1568
1570
  }): string | undefined {
1569
1571
  const description = control.description?.trim();
1570
1572
 
1571
- if (!description || control.type !== "fontPicker") {
1573
+ if (!description) {
1574
+ return undefined;
1575
+ }
1576
+
1577
+ const label = getControlLabelText(control).trim();
1578
+
1579
+ if (
1580
+ isToolcraftObviousColorSectionControlDescription({
1581
+ control,
1582
+ description,
1583
+ label,
1584
+ sectionTitle,
1585
+ })
1586
+ ) {
1587
+ return `${sectionLabel} / ${controlId} description adds a help icon to an obvious color-section control. Omit control.description when the section title and visible label already explain the setting.`;
1588
+ }
1589
+
1590
+ if (control.type !== "fontPicker") {
1572
1591
  return undefined;
1573
1592
  }
1574
1593
 
@@ -1583,6 +1602,52 @@ function getToolcraftControlDescriptionError({
1583
1602
  return `${sectionLabel} / ${controlId} description repeats FontPicker-owned fields (${repeatedParts.join(", ")}). FontPicker help must explain only non-obvious product behavior; use section titles and visible field labels for font family, weight, size, case, color, opacity, letter spacing, and line height, or omit description.`;
1584
1603
  }
1585
1604
 
1605
+ function isToolcraftColorSectionTitle(sectionTitle: string | undefined): boolean {
1606
+ return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1607
+ }
1608
+
1609
+ function isToolcraftSequentialColorLabel(label: string): boolean {
1610
+ return /^colou?r\s+\d+$/i.test(label.trim());
1611
+ }
1612
+
1613
+ function isToolcraftSimplePaletteDistributionLabel(label: string): boolean {
1614
+ return /^(spread|mix|distribution)$/i.test(label.trim());
1615
+ }
1616
+
1617
+ function isToolcraftGenericControlHelpDescription(description: string): boolean {
1618
+ return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
1619
+ description.trim(),
1620
+ );
1621
+ }
1622
+
1623
+ function isToolcraftObviousColorSectionControlDescription({
1624
+ control,
1625
+ description,
1626
+ label,
1627
+ sectionTitle,
1628
+ }: {
1629
+ control: ToolcraftControlSchema;
1630
+ description: string;
1631
+ label: string;
1632
+ sectionTitle: string | undefined;
1633
+ }): boolean {
1634
+ if (!isToolcraftColorSectionTitle(sectionTitle)) {
1635
+ return false;
1636
+ }
1637
+
1638
+ if (
1639
+ (control.type === "color" || control.type === "colorOpacity") &&
1640
+ isToolcraftSequentialColorLabel(label)
1641
+ ) {
1642
+ return true;
1643
+ }
1644
+
1645
+ return (
1646
+ isToolcraftSimplePaletteDistributionLabel(label) &&
1647
+ isToolcraftGenericControlHelpDescription(description)
1648
+ );
1649
+ }
1650
+
1586
1651
  function getToolcraftControlSectionGroupingErrors(
1587
1652
  schema: ResolvedToolcraftAppSchema,
1588
1653
  ): string[] {
@@ -1691,6 +1756,7 @@ function getToolcraftControlSectionGroupingErrors(
1691
1756
  control,
1692
1757
  controlId,
1693
1758
  sectionLabel,
1759
+ sectionTitle,
1694
1760
  });
1695
1761
 
1696
1762
  if (descriptionError) {
@@ -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"
@@ -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