@agent-native/core 0.85.5 → 0.85.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 (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. package/package.json +1 -1
@@ -8,24 +8,35 @@
8
8
  * jump and the B6 ellipse border-radius jump.
9
9
  *
10
10
  * Design decisions:
11
- * - Fill: `hsl(var(--primary, 213 91% 67%) / 0.24)` — theme-adaptive when the
12
- * document defines `--primary`, but still visibly blue inside standalone
13
- * iframe documents such as the overview board.
14
- * - Stroke: `hsl(var(--primary, 213 91% 67%) / 0.95)` same CSS-var approach
15
- * with a fallback so committed shapes do not become invisible.
11
+ * - Fill (rect/ellipse): `rgb(218 218 218)` — a plain, theme-independent
12
+ * neutral gray, not a CSS custom property. This is intentional: a shape
13
+ * drawn with no explicit color should look the same (a soft Figma-like
14
+ * gray) regardless of which document theme it lands in, rather than
15
+ * silently tinting to whatever `--primary` resolves to there.
16
+ * - Stroke (rect/ellipse): `rgb(168 168 168)` — a slightly darker plain gray,
17
+ * same rationale as fill above.
18
+ * - Frame fill: the one default that *is* theme-adaptive —
19
+ * `hsl(var(--primary) / 0.05)`, a very faint tint of the editor's accent
20
+ * color so a layout frame's interior reads as "structural chrome" rather
21
+ * than a fixed gray. Its border stays a plain dashed gray, same as
22
+ * rect/ellipse's border.
16
23
  * - Stroke width: 1px for div-based shapes.
17
24
  * - Ellipse: borderRadius "50%" in both paths — no more "oval on commit" jump.
18
25
  * - Rect: borderRadius "2px" (small, matches the previous committed value; the
19
26
  * preview used Tailwind `rounded-sm` which resolves to 2px).
20
- * - Frame: dashed border, slightly lighter fill (matches preview semantics).
21
- * - Text: inherits current color. Selection/edit chrome owns outlines so text
22
- * does not carry a persistent border that double-stacks with focused states.
27
+ * - Text: inherits current color by default. Selection/edit chrome owns
28
+ * outlines so text does not carry a persistent border that double-stacks
29
+ * with focused states.
23
30
  *
24
- * CSS custom properties (`hsl(var(--primary) / …)`) work in the committed HTML
25
- * because the iframe inherits the design-editor stylesheet that defines
26
- * `--primary`. When a `fill` / `stroke` override is already present on the
27
- * primitive those are passed through unchanged so user-chosen colors are never
28
- * clobbered.
31
+ * Overrides: when a caller passes a `fill` override, it becomes the
32
+ * `background` for every kind EXCEPT text a text primitive's `fill` maps to
33
+ * `color` instead (text has no fillable background box; DesignEditor's
34
+ * `appendCanvasPrimitiveToHtml` does the same `primitive.fill ?? "currentColor"`
35
+ * mapping for the committed element). Getting this wrong here previously
36
+ * meant a text primitive's chosen color rendered in the preview but was
37
+ * silently dropped, then jumped to the correct color only once committed.
38
+ * `stroke` / `strokeWidth` overrides are passed through unchanged for every
39
+ * kind.
29
40
  */
30
41
 
31
42
  import type * as React from "react";
@@ -152,18 +163,23 @@ export function canvasPrimitiveStyleString(
152
163
  overrides?: { fill?: string; stroke?: string; strokeWidth?: number },
153
164
  ): string {
154
165
  const v = canvasPrimitiveVisual(kind);
166
+ const isText = kind === "text";
155
167
 
156
168
  // Apply caller overrides so user-chosen colours are preserved.
157
169
  let background = v.background;
158
170
  let border = v.border;
159
171
 
160
- if (overrides?.fill) {
172
+ // Text primitives render their fill as `color`, not `background` (see the
173
+ // matching note in canvasPrimitiveReactStyle) — otherwise a caller-chosen
174
+ // text color would paint a filled background box instead of tinting the
175
+ // glyphs, and would disagree with the committed HTML output.
176
+ if (overrides?.fill && !isText) {
161
177
  background = overrides.fill;
162
178
  }
163
179
  if (overrides?.stroke || overrides?.strokeWidth !== undefined) {
164
180
  const stroke = overrides.stroke ?? DEFAULT_STROKE;
165
181
  const width = overrides.strokeWidth ?? DEFAULT_STROKE_WIDTH_PX;
166
- const style = kind === "frame" || kind === "text" ? "dashed" : "solid";
182
+ const style = kind === "frame" || isText ? "dashed" : "solid";
167
183
  border = `${width}px ${style} ${stroke}`;
168
184
  }
169
185
 
@@ -173,8 +189,9 @@ export function canvasPrimitiveStyleString(
173
189
  `border-radius:${v.borderRadius}`,
174
190
  ];
175
191
 
176
- if (v.color) {
177
- parts.push(`color:${v.color}`);
192
+ const color = isText ? overrides?.fill || v.color || "currentColor" : v.color;
193
+ if (color) {
194
+ parts.push(`color:${color}`);
178
195
  }
179
196
 
180
197
  return parts.join(";");
@@ -198,26 +215,30 @@ export function canvasPrimitiveReactStyle(
198
215
  overrides?: { fill?: string; stroke?: string; strokeWidth?: number },
199
216
  ): React.CSSProperties {
200
217
  const v = canvasPrimitiveVisual(kind);
218
+ const isText = kind === "text";
201
219
 
202
220
  let background = v.background as string | undefined;
203
221
  let borderColor: string | undefined;
204
222
  let borderWidth: number | string | undefined = DEFAULT_STROKE_WIDTH_PX;
205
223
  let borderStyle: string | undefined = "solid";
206
224
 
207
- if (kind === "frame" || kind === "text") {
225
+ if (kind === "frame" || isText) {
208
226
  borderStyle = "dashed";
209
227
  }
210
228
 
211
- if (
212
- kind === "text" &&
213
- !overrides?.stroke &&
214
- overrides?.strokeWidth === undefined
215
- ) {
229
+ if (isText && !overrides?.stroke && overrides?.strokeWidth === undefined) {
216
230
  borderWidth = 0;
217
231
  borderStyle = "solid";
218
232
  }
219
233
 
220
- if (overrides?.fill) {
234
+ // Text primitives render their fill as `color`, not `background` — the
235
+ // element itself stays transparent so it doesn't paint a filled box behind
236
+ // the glyphs. Committed HTML output (DesignEditor's
237
+ // appendCanvasPrimitiveToHtml) already maps `primitive.fill` to
238
+ // `element.style.color`; this must match exactly or a text primitive with
239
+ // a custom fill shows the wrong color in the draft preview and visibly
240
+ // jumps to the correct color the moment it commits.
241
+ if (overrides?.fill && !isText) {
221
242
  background = overrides.fill;
222
243
  }
223
244
  if (overrides?.stroke) {
@@ -245,11 +266,14 @@ export function canvasPrimitiveReactStyle(
245
266
  style.color = v.color;
246
267
  }
247
268
 
248
- // For text kind, clear fill-as-background since text uses `color`
249
- if (kind === "text") {
269
+ if (isText) {
250
270
  style.background = "transparent";
251
271
  style.outline = "none";
252
272
  style.outlineOffset = 0;
273
+ // Matches DesignEditor's `primitive.fill ?? "currentColor"` convention
274
+ // for the committed element, so the preview and the commit never
275
+ // disagree on text color.
276
+ style.color = overrides?.fill || "currentColor";
253
277
  }
254
278
 
255
279
  return style;
@@ -344,7 +344,15 @@ export function AutoLayoutMatrix({
344
344
  <IconFlowGrid className="size-3.5" />
345
345
  </FlowButton>
346
346
  </div>
347
- {/* Reset / reverse-flow button */}
347
+ {/* Swap axis: flips between horizontal/vertical flex flow
348
+ without forcing a specific direction. Previously this
349
+ unconditionally called selectFlow("horizontal"), silently
350
+ destroying an authored vertical/grid/normal-flow layout
351
+ every time it was clicked regardless of the current state
352
+ (there's no stashed "authored" value to restore to, so a
353
+ true reset isn't possible) — swapping is the one action
354
+ here that's both non-destructive and matches what an
355
+ undo/reverse-style icon implies. */}
348
356
  <Tooltip>
349
357
  <TooltipTrigger asChild>
350
358
  <Button
@@ -353,16 +361,20 @@ export function AutoLayoutMatrix({
353
361
  size="icon"
354
362
  disabled={disabled}
355
363
  aria-label={
356
- "Reset auto layout flow" /* i18n-ignore inspector tooltip */
364
+ "Swap flow direction" /* i18n-ignore inspector tooltip */
365
+ }
366
+ onClick={() =>
367
+ selectFlow(
368
+ activeFlow === "vertical" ? "horizontal" : "vertical",
369
+ )
357
370
  }
358
- onClick={() => selectFlow("horizontal")}
359
371
  className="size-7 shrink-0 rounded-md text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground"
360
372
  >
361
373
  <IconArrowBackUp className="size-4" />
362
374
  </Button>
363
375
  </TooltipTrigger>
364
376
  <TooltipContent>
365
- {"Reset auto layout flow" /* i18n-ignore inspector tooltip */}
377
+ {"Swap flow direction" /* i18n-ignore inspector tooltip */}
366
378
  </TooltipContent>
367
379
  </Tooltip>
368
380
  </div>
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  alphaToOpacity,
3
3
  parseCssColor,
4
+ parseCssColorExtended,
4
5
  rgbaToCss,
5
6
  rgbaToHex,
6
7
  rgbaToHsl,
@@ -204,86 +205,10 @@ interface HsvaColor {
204
205
 
205
206
  const FALLBACK_COLOR: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
206
207
 
207
- // ─── Extended CSS color parser ──────────────────────────────────────────────────
208
- //
209
- // `parseCssColor` from color-utils handles hex, comma-separated rgb/rgba, and
210
- // hsl/hsla. Browsers increasingly emit modern CSS Level 4 formats from
211
- // getComputedStyle: space-separated `rgb(R G B)`, `rgb(R G B / A)`, and
212
- // opaque formats like `oklch(...)` or `color(display-p3 ...)`.
213
- //
214
- // This local wrapper extends the parser to cover those cases so that colors
215
- // arriving from the canvas's computed-style bridge are always usable.
216
-
217
- const MODERN_RGB_PATTERN =
218
- /^rgba?\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*([0-9.]+%?))?\s*\)$/i;
219
-
220
- /** Canvas element reused across calls for DOM-based color resolution. */
221
- let _resolverCanvas: HTMLCanvasElement | null = null;
222
- let _resolverCtx: CanvasRenderingContext2D | null = null;
223
-
224
- /**
225
- * Parses a CSS color string into RgbaColor, extending the base parser with:
226
- * - Modern space-separated `rgb(R G B)` / `rgb(R G B / A)` syntax
227
- * - Opaque formats (oklch, color, etc.) resolved via a hidden canvas
228
- *
229
- * Falls back to null if the value is unparseable and the DOM is unavailable.
230
- */
231
- function parseCssColorExtended(value: string): RgbaColor | null {
232
- // 1. Try the standard parser first (handles hex, comma rgb/rgba, hsl/hsla).
233
- const standard = parseCssColor(value);
234
- if (standard) return standard;
235
-
236
- const trimmed = value.trim();
237
- if (!trimmed || trimmed === "transparent" || trimmed === "none") return null;
238
-
239
- // 2. Modern space-separated rgb/rgba — CSS Level 4.
240
- const modernRgb = trimmed.match(MODERN_RGB_PATTERN);
241
- if (modernRgb) {
242
- const parseAlphaLocal = (v: string | undefined): number => {
243
- if (!v) return 1;
244
- if (v.endsWith("%"))
245
- return Math.max(0, Math.min(1, Number(v.slice(0, -1)) / 100));
246
- return Math.max(0, Math.min(1, Number(v)));
247
- };
248
- return {
249
- r: Math.round(Math.max(0, Math.min(255, Number(modernRgb[1])))),
250
- g: Math.round(Math.max(0, Math.min(255, Number(modernRgb[2])))),
251
- b: Math.round(Math.max(0, Math.min(255, Number(modernRgb[3])))),
252
- a: parseAlphaLocal(modernRgb[4]),
253
- };
254
- }
255
-
256
- // 3. DOM-based resolver for oklch, color(display-p3 ...), hsl (modern), etc.
257
- // Uses a hidden 1×1 canvas to resolve any valid CSS color to rgb().
258
- if (typeof document === "undefined") return null;
259
- try {
260
- if (!_resolverCanvas) {
261
- _resolverCanvas = document.createElement("canvas");
262
- _resolverCanvas.width = 1;
263
- _resolverCanvas.height = 1;
264
- }
265
- if (!_resolverCtx) {
266
- _resolverCtx = _resolverCanvas.getContext("2d", {
267
- willReadFrequently: true,
268
- });
269
- }
270
- const ctx = _resolverCtx;
271
- if (!ctx) return null;
272
- // Detect invalid color values: save fillStyle before and after assignment.
273
- // If the browser rejects the value, fillStyle won't change.
274
- const prev = ctx.fillStyle;
275
- ctx.fillStyle = trimmed;
276
- const next = ctx.fillStyle; // browser normalises to rgb/hex on accept
277
- // If the value was rejected, fillStyle stays at the previous value.
278
- if (next === prev) return null;
279
- ctx.clearRect(0, 0, 1, 1);
280
- ctx.fillRect(0, 0, 1, 1);
281
- const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
282
- return { r, g, b, a: a / 255 };
283
- } catch {
284
- return null;
285
- }
286
- }
208
+ // `parseCssColorExtended` (from color-utils) extends `parseCssColor` with
209
+ // modern CSS Level 4 syntax (space-separated rgb, `oklch(...)`,
210
+ // `color(display-p3 ...)`) so that colors arriving from the canvas's
211
+ // computed-style bridge are always usable.
287
212
 
288
213
  const DEFAULT_LABELS: DesignColorPickerLabels = {
289
214
  trigger: "Open color picker", // i18n-ignore fallback component label
@@ -865,13 +790,20 @@ export function DesignColorPicker({
865
790
  emitColor(hslToRgba({ ...nextHsl, a: opacityToAlpha(effectiveOpacity) }));
866
791
  };
867
792
 
793
+ // Shared by the invalid-commit path below and the Escape handler in the hex
794
+ // input: reverts the draft to the currently active color (the selected
795
+ // gradient stop while editing a gradient, otherwise the solid color).
796
+ const revertHexDraft = () => {
797
+ const reverted = toDisplayHex(activeGradient ? fieldColor : color);
798
+ hexDraftRef.current = reverted;
799
+ setHexDraft(reverted);
800
+ };
801
+
868
802
  const commitHex = () => {
869
- const currentDraft = hexDraftRef.current;
803
+ const currentDraft = expandHexShorthand(hexDraftRef.current);
870
804
  const parsed = parseCssColor(`#${currentDraft.replace(/^#/, "")}`);
871
805
  if (!parsed) {
872
- const reverted = toDisplayHex(activeGradient ? fieldColor : color);
873
- hexDraftRef.current = reverted;
874
- setHexDraft(reverted);
806
+ revertHexDraft();
875
807
  return;
876
808
  }
877
809
  if (activeGradient) {
@@ -913,9 +845,14 @@ export function DesignColorPicker({
913
845
  : color;
914
846
  const rawFieldHsv = rgbaToHsv(fieldColor);
915
847
  // Preserve the last non-zero hue so dragging through gray doesn't lose it.
916
- if (rawFieldHsv.s > 0 && rawFieldHsv.v > 0) {
917
- lastHueRef.current = rawFieldHsv.h;
918
- }
848
+ // Recorded as an effect (not mutated during render) so the ref update is a
849
+ // render side-effect, not a render-body mutation.
850
+ useEffect(() => {
851
+ if (rawFieldHsv.s > 0 && rawFieldHsv.v > 0) {
852
+ lastHueRef.current = rawFieldHsv.h;
853
+ }
854
+ // eslint-disable-next-line react-hooks/exhaustive-deps
855
+ }, [rawFieldHsv.h, rawFieldHsv.s, rawFieldHsv.v]);
919
856
  const fieldHsv: HsvaColor =
920
857
  rawFieldHsv.s === 0
921
858
  ? { ...rawFieldHsv, h: lastHueRef.current }
@@ -1099,9 +1036,7 @@ export function DesignColorPicker({
1099
1036
  e.currentTarget.blur();
1100
1037
  }
1101
1038
  if (e.key === "Escape") {
1102
- const reverted = toDisplayHex(color);
1103
- hexDraftRef.current = reverted;
1104
- setHexDraft(reverted);
1039
+ revertHexDraft();
1105
1040
  skipNextHexBlurCommitRef.current = true;
1106
1041
  e.currentTarget.blur();
1107
1042
  }
@@ -1235,12 +1170,12 @@ export function DesignColorPicker({
1235
1170
  // switch causes the canvas to re-project the element. Without this,
1236
1171
  // Radix treats the resulting focus shift as an "interact outside" event
1237
1172
  // and closes the popover before the type switch is visible.
1238
- onInteractOutside={(e) => {
1239
- // Allow closing only for genuine pointer clicks on the canvas area
1240
- // (the user clicked somewhere else). Programmatic focus changes from
1241
- // the canvas bridge (element re-projection) should not close the picker.
1242
- if (e.type === "focusoutside") e.preventDefault();
1243
- }}
1173
+ // Radix fires a dedicated `onFocusOutside` (not `onInteractOutside`
1174
+ // with e.type === "focusoutside" that never matches) whenever focus
1175
+ // moves outside the content; suppress it so canvas-driven focus
1176
+ // re-projection can't close the popover. Genuine pointer clicks
1177
+ // outside still close it via the default onInteractOutside behavior.
1178
+ onFocusOutside={(e) => e.preventDefault()}
1244
1179
  >
1245
1180
  <div className="rounded-md bg-popover text-popover-foreground">
1246
1181
  {view === "shader" ? (
@@ -1475,6 +1410,13 @@ export function DesignColorPicker({
1475
1410
  backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
1476
1411
  onChange={(next) => {
1477
1412
  const h = next === 360 ? 0 : next;
1413
+ // Record the dragged hue immediately so the slider
1414
+ // doesn't snap back to a stale hue on the next
1415
+ // render when the color is (or becomes) achromatic
1416
+ // — the round-tripped color's s/v may still be 0,
1417
+ // so the lastHueRef-sync effect's s>0&&v>0 guard
1418
+ // won't fire on its own.
1419
+ lastHueRef.current = h;
1478
1420
  if (activeGradient) {
1479
1421
  emitStopColor(
1480
1422
  hsvToRgba({
@@ -1627,7 +1569,7 @@ export function DesignColorPicker({
1627
1569
  : [rgbaToCss(color)]
1628
1570
  ).map((docColor) => {
1629
1571
  const currentHex = rgbaToHex(
1630
- parseCssColor(docColor) ?? color,
1572
+ parseCssColorExtended(docColor) ?? color,
1631
1573
  );
1632
1574
  const isActive =
1633
1575
  rgbaToHex(color) === currentHex && !activeGradient;
@@ -1647,7 +1589,8 @@ export function DesignColorPicker({
1647
1589
  )}
1648
1590
  style={swatchStyle(docColor)}
1649
1591
  onClick={() => {
1650
- const parsed = parseCssColor(docColor) ?? color;
1592
+ const parsed =
1593
+ parseCssColorExtended(docColor) ?? color;
1651
1594
  if (activeGradient) emitStopColor(parsed);
1652
1595
  else emitColor(parsed);
1653
1596
  }}
@@ -1999,8 +1942,8 @@ function ScrubbyNumberInput({
1999
1942
  }, [value]);
2000
1943
 
2001
1944
  const commit = () => {
2002
- const parsed = Number(draftRef.current);
2003
- if (!Number.isFinite(parsed)) {
1945
+ const parsed = parseNumericDraft(draftRef.current);
1946
+ if (parsed === null) {
2004
1947
  const reverted = String(value);
2005
1948
  draftRef.current = reverted;
2006
1949
  setDraft(reverted);
@@ -2095,7 +2038,7 @@ export function inferPaintType(
2095
2038
  return "linear";
2096
2039
  }
2097
2040
  if (lower.startsWith("url(")) return "image";
2098
- const parsed = parseCssColor(value);
2041
+ const parsed = parseCssColorExtended(value);
2099
2042
  if (opacity <= 0 || parsed?.a === 0 || value.trim() === "transparent") {
2100
2043
  return "none";
2101
2044
  }
@@ -2189,13 +2132,19 @@ function triggerSwatchStyle(
2189
2132
  return swatchStyle(rgbaToCss(color));
2190
2133
  }
2191
2134
 
2135
+ /** Values that render their own background without needing color parsing. */
2136
+ function looksLikeImageOrGradient(value: string): boolean {
2137
+ const lower = value.trim().toLowerCase();
2138
+ return lower.includes("gradient(") || lower.startsWith("url(");
2139
+ }
2140
+
2192
2141
  function swatchStyle(value: string): {
2193
2142
  backgroundColor?: string;
2194
2143
  backgroundImage?: string;
2195
2144
  backgroundSize?: string;
2196
2145
  backgroundPosition?: string;
2197
2146
  } {
2198
- const parsed = parseCssColor(value);
2147
+ const parsed = parseCssColorExtended(value);
2199
2148
  if (parsed && parsed.a < 1) {
2200
2149
  return {
2201
2150
  backgroundImage: `${CHECKERBOARD_IMAGE}, linear-gradient(${rgbaToCss(parsed)}, ${rgbaToCss(parsed)})`,
@@ -2204,7 +2153,16 @@ function swatchStyle(value: string): {
2204
2153
  };
2205
2154
  }
2206
2155
  if (parsed) return { backgroundColor: rgbaToCss(parsed) };
2207
- return { backgroundImage: value || "none" };
2156
+ if (value && looksLikeImageOrGradient(value)) {
2157
+ return { backgroundImage: value };
2158
+ }
2159
+ // Unparseable and not a gradient/image value (e.g. a stale/invalid document
2160
+ // color) — show a neutral checkerboard instead of an invalid `background-image`
2161
+ // that would otherwise render as a blank swatch.
2162
+ return {
2163
+ backgroundImage: CHECKERBOARD_IMAGE,
2164
+ backgroundSize: "8px 8px",
2165
+ };
2208
2166
  }
2209
2167
 
2210
2168
  function alphaTrackBackground(color: RgbaColor): string {
@@ -2275,3 +2233,32 @@ function clampFloat(value: number, min: number, max: number): number {
2275
2233
  function hasHexAlpha(value: string): boolean {
2276
2234
  return /^#?(?:[0-9a-f]{4}|[0-9a-f]{8})$/i.test(value.trim());
2277
2235
  }
2236
+
2237
+ /**
2238
+ * Parses a `ScrubbyNumberInput` draft string into a finite number, or `null`
2239
+ * when the draft should be treated as invalid (and thus reverted rather than
2240
+ * committed). `Number("")` is `0`, not `NaN`, so an emptied field must be
2241
+ * special-cased — otherwise clearing the input and blurring/pressing Enter
2242
+ * would silently commit `0` instead of reverting to the last real value.
2243
+ */
2244
+ export function parseNumericDraft(draft: string): number | null {
2245
+ const trimmed = draft.trim();
2246
+ if (trimmed === "") return null;
2247
+ const parsed = Number(trimmed);
2248
+ return Number.isFinite(parsed) ? parsed : null;
2249
+ }
2250
+
2251
+ /**
2252
+ * Expands a bare 1-digit hex fragment (e.g. "F") into the standard 3-digit
2253
+ * shorthand ("FFF", which `parseCssColor` then doubles up to "FFFFFF") so a
2254
+ * single typed hex digit still commits instead of being rejected as
2255
+ * unparseable. Standard 3/4/6/8-digit hex (already handled by
2256
+ * `parseCssColor`) passes through unchanged.
2257
+ */
2258
+ export function expandHexShorthand(value: string): string {
2259
+ const trimmed = value.trim().replace(/^#/, "");
2260
+ if (/^[0-9a-f]$/i.test(trimmed)) {
2261
+ return trimmed.repeat(3);
2262
+ }
2263
+ return trimmed;
2264
+ }
@@ -1,7 +1,9 @@
1
1
  import { parseCssColor, rgbaToCss } from "@shared/color-utils";
2
2
  import { IconTrash } from "@tabler/icons-react";
3
3
  import {
4
+ type KeyboardEvent as ReactKeyboardEvent,
4
5
  type PointerEvent as ReactPointerEvent,
6
+ useEffect,
5
7
  useRef,
6
8
  useState,
7
9
  } from "react";
@@ -83,6 +85,44 @@ function clamp(n: number, min: number, max: number): number {
83
85
  return Math.max(min, Math.min(max, n));
84
86
  }
85
87
 
88
+ /**
89
+ * Parses a stop-position draft string into a finite 0–100 number, or `null`
90
+ * when the draft is invalid/empty and the field should revert instead of
91
+ * committing (mirrors `parseNumericDraft` in DesignColorPicker.tsx).
92
+ */
93
+ export function parseStopPositionDraft(draft: string): number | null {
94
+ const trimmed = draft.trim();
95
+ if (trimmed === "") return null;
96
+ const parsed = Number(trimmed);
97
+ return Number.isFinite(parsed) ? clamp(parsed, 0, 100) : null;
98
+ }
99
+
100
+ /**
101
+ * Picks which remaining stop should become selected after deleting one.
102
+ * Returns the id of the stop whose position is closest to the removed
103
+ * stop's position (ties broken by whichever appears first in `stops`),
104
+ * rather than always jumping to the leftmost stop — deleting a stop near
105
+ * the right edge of the ramp should keep selection nearby, not teleport
106
+ * the user's focus across the gradient.
107
+ */
108
+ export function nearestStopId(
109
+ stops: GradientStopValue[],
110
+ removedPosition: number | undefined,
111
+ ): string | null {
112
+ if (stops.length === 0) return null;
113
+ if (removedPosition === undefined) return sortedStops(stops)[0]?.id ?? null;
114
+ let best: GradientStopValue | null = null;
115
+ let bestDistance = Infinity;
116
+ for (const stop of stops) {
117
+ const distance = Math.abs(stop.position - removedPosition);
118
+ if (distance < bestDistance) {
119
+ best = stop;
120
+ bestDistance = distance;
121
+ }
122
+ }
123
+ return best?.id ?? null;
124
+ }
125
+
86
126
  // ─── Default / parse helpers ───────────────────────────────────────────────────
87
127
 
88
128
  let stopCounter = 0;
@@ -356,9 +396,17 @@ export function GradientEditor({
356
396
  };
357
397
 
358
398
  // Bar background click → add a new stop (only if no significant drag movement).
399
+ // setPointerCapture pins all subsequent pointermove/pointerup events to the
400
+ // bar element regardless of where the cursor physically ends up — without
401
+ // it, a fast drag that leaves the bar's bounding box before the next
402
+ // pointermove fires stops delivering events to this handler (hit-testing
403
+ // sends them to whatever's now under the cursor instead), so the movement
404
+ // never gets flagged and the eventual pointerup elsewhere is misread as a
405
+ // stationary click that adds a spurious stop.
359
406
  const handleBarPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
360
407
  if (disabled) return;
361
408
  barClickRef.current = { moved: false, startX: event.clientX };
409
+ event.currentTarget.setPointerCapture(event.pointerId);
362
410
  };
363
411
 
364
412
  const handleBarPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -369,6 +417,9 @@ export function GradientEditor({
369
417
  };
370
418
 
371
419
  const handleBarClick = (event: ReactPointerEvent<HTMLDivElement>) => {
420
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
421
+ event.currentTarget.releasePointerCapture(event.pointerId);
422
+ }
372
423
  // Only add if the pointer didn't move significantly (not a drag).
373
424
  if (!barClickRef.current || barClickRef.current.moved) {
374
425
  barClickRef.current = null;
@@ -440,10 +491,11 @@ export function GradientEditor({
440
491
 
441
492
  const removeStop = (id: string) => {
442
493
  if (value.stops.length <= 2) return;
494
+ const removed = value.stops.find((stop) => stop.id === id);
443
495
  const nextStops = value.stops.filter((stop) => stop.id !== id);
444
496
  onChange({ ...value, stops: nextStops });
445
497
  if (selectedStopId === id) {
446
- onSelectStop(sortedStops(nextStops)[0]?.id ?? "");
498
+ onSelectStop(nearestStopId(nextStops, removed?.position) ?? "");
447
499
  }
448
500
  };
449
501
 
@@ -454,6 +506,40 @@ export function GradientEditor({
454
506
  const showAngle = value.kind === "linear" || value.kind === "angular";
455
507
  const selectedStop = value.stops.find((s) => s.id === selectedStopId);
456
508
 
509
+ // Stop-position draft: buffered so typing doesn't commit (and dirty the
510
+ // history) on every keystroke. Commits on blur/Enter; Escape reverts.
511
+ const [positionDraft, setPositionDraft] = useState<string>(() =>
512
+ String(Math.round(selectedStop?.position ?? 0)),
513
+ );
514
+ const positionDraftRef = useRef(positionDraft);
515
+ const skipPositionBlurRef = useRef(false);
516
+ const selectedStopPosition = selectedStop?.position;
517
+ useEffect(() => {
518
+ const next = String(Math.round(selectedStopPosition ?? 0));
519
+ positionDraftRef.current = next;
520
+ setPositionDraft(next);
521
+ // Resync whenever the selected stop changes or its position changes
522
+ // externally (drag on the bar, another control).
523
+ // eslint-disable-next-line react-hooks/exhaustive-deps
524
+ }, [selectedStopId, selectedStopPosition]);
525
+
526
+ const commitPositionDraft = () => {
527
+ const parsed = parseStopPositionDraft(positionDraftRef.current);
528
+ if (parsed === null) {
529
+ const reverted = String(Math.round(selectedStop?.position ?? 0));
530
+ positionDraftRef.current = reverted;
531
+ setPositionDraft(reverted);
532
+ return;
533
+ }
534
+ updateStopPosition(selectedStopId, parsed);
535
+ };
536
+
537
+ const revertPositionDraft = () => {
538
+ const reverted = String(Math.round(selectedStop?.position ?? 0));
539
+ positionDraftRef.current = reverted;
540
+ setPositionDraft(reverted);
541
+ };
542
+
457
543
  return (
458
544
  <div className={cn("px-3 pt-1.5 pb-1 select-none", className)}>
459
545
  {/* ── Gradient bar + stop handles ──────────────────────────────────────── */}
@@ -558,11 +644,31 @@ export function GradientEditor({
558
644
  max={100}
559
645
  aria-label={"Stop position" /* i18n-ignore */}
560
646
  disabled={disabled}
561
- value={Math.round(selectedStop?.position ?? 0)}
647
+ value={positionDraft}
562
648
  onChange={(event) => {
563
- const next = Number(event.target.value);
564
- if (Number.isFinite(next))
565
- updateStopPosition(selectedStopId, clamp(next, 0, 100));
649
+ positionDraftRef.current = event.target.value;
650
+ setPositionDraft(event.target.value);
651
+ }}
652
+ onFocus={(event) => event.target.select()}
653
+ onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
654
+ if (event.key === "Enter") {
655
+ event.preventDefault();
656
+ commitPositionDraft();
657
+ skipPositionBlurRef.current = true;
658
+ event.currentTarget.blur();
659
+ }
660
+ if (event.key === "Escape") {
661
+ revertPositionDraft();
662
+ skipPositionBlurRef.current = true;
663
+ event.currentTarget.blur();
664
+ }
665
+ }}
666
+ onBlur={() => {
667
+ if (skipPositionBlurRef.current) {
668
+ skipPositionBlurRef.current = false;
669
+ return;
670
+ }
671
+ commitPositionDraft();
566
672
  }}
567
673
  className="h-full min-w-0 flex-1 bg-transparent px-1.5 !text-[11px] tabular-nums focus-visible:outline-none"
568
674
  />