@agent-native/core 0.85.6 → 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 (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -264,11 +264,18 @@ export function ImageFillControls({
264
264
  const fileInputRef = useRef<HTMLInputElement>(null);
265
265
  const [urlDraft, setUrlDraft] = useState(value.url);
266
266
  const urlDraftRef = useRef(value.url);
267
+ // Guard re-syncing the draft from an external value change while the field
268
+ // is focused (mirrors ScrubInput's `focused` pattern): without this, an
269
+ // incoming prop update while the user is mid-typing a URL — e.g. a
270
+ // selection-driven re-render, or another control committing a sibling
271
+ // style in the same patch — clobbers their in-progress keystrokes.
272
+ const [focused, setFocused] = useState(false);
267
273
 
268
274
  useEffect(() => {
275
+ if (focused) return;
269
276
  urlDraftRef.current = value.url;
270
277
  setUrlDraft(value.url);
271
- }, [value.url]);
278
+ }, [focused, value.url]);
272
279
 
273
280
  const commitUrl = () => {
274
281
  onChange({ ...value, url: urlDraftRef.current.trim() });
@@ -352,7 +359,11 @@ export function ImageFillControls({
352
359
  urlDraftRef.current = event.target.value;
353
360
  setUrlDraft(event.target.value);
354
361
  }}
355
- onBlur={commitUrl}
362
+ onFocus={() => setFocused(true)}
363
+ onBlur={() => {
364
+ setFocused(false);
365
+ commitUrl();
366
+ }}
356
367
  onKeyDown={(event) => {
357
368
  if (event.key === "Enter") {
358
369
  event.preventDefault();
@@ -28,6 +28,10 @@ import {
28
28
 
29
29
  type ScrubInputIcon = (props: { className?: string }) => ReactNode;
30
30
 
31
+ /** Cumulative pointer movement (px) required before a label pointerdown→move
32
+ * is treated as a scrub drag rather than jitter during a plain click. */
33
+ const DRAG_THRESHOLD_PX = 3;
34
+
31
35
  export interface ScrubInputChangeMeta {
32
36
  source: "commit" | "keyboard" | "scrub";
33
37
  expression?: string;
@@ -127,7 +131,10 @@ export function ScrubInput({
127
131
 
128
132
  draftRef.current = parsed.normalized;
129
133
  setDraft(parsed.normalized);
130
- if (parsed.value !== value) {
134
+ // From a mixed selection every explicitly typed value must commit, even
135
+ // when it equals the placeholder `value` prop (e.g. typing "0"): the
136
+ // selected objects hold differing values, so "no change" is meaningless.
137
+ if (parsed.value !== value || mixed) {
131
138
  onChange(parsed.value, { source: "commit", expression: currentDraft });
132
139
  }
133
140
  };
@@ -135,12 +142,28 @@ export function ScrubInput({
135
142
  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
136
143
  if (event.key === "ArrowUp" || event.key === "ArrowDown") {
137
144
  event.preventDefault();
145
+ // Mixed selection: the `value` prop is only a placeholder (typically 0),
146
+ // so stepping from it would snap every selected object to a value the
147
+ // user never chose. Require an explicit typed value first — typing then
148
+ // committing applies to all, which is the design-editor convention.
149
+ if (mixed) return;
138
150
  const direction = event.key === "ArrowUp" ? 1 : -1;
139
151
  // getScrubStepFromEvent handles shiftKey (×10) and altKey (÷10).
140
152
  // Cmd (metaKey) mirrors Shift for ×10 — editor convention on macOS.
141
153
  const baseStep = getScrubStepFromEvent(event, step);
142
154
  const cmdMultiplier = event.metaKey && !event.shiftKey ? 10 : 1;
143
- setNextValue(value + direction * baseStep * cmdMultiplier, {
155
+ // Step from the currently typed draft, not the last-committed `value`
156
+ // prop — otherwise an in-progress, uncommitted edit (typed but not yet
157
+ // blurred/entered) is silently discarded the moment an arrow key is
158
+ // pressed. Parse the draft the same way commitDraft does, falling back
159
+ // to `value` only when the draft doesn't parse (e.g. empty/invalid).
160
+ const draftParsed = parseScrubExpression(
161
+ draftRef.current,
162
+ value,
163
+ options,
164
+ );
165
+ const base = draftParsed ? draftParsed.value : value;
166
+ setNextValue(base + direction * baseStep * cmdMultiplier, {
144
167
  source: "keyboard",
145
168
  });
146
169
  return;
@@ -179,8 +202,25 @@ export function ScrubInput({
179
202
 
180
203
  const handlePointerMove = (event: PointerEvent<HTMLLabelElement>) => {
181
204
  if (!dragging || dragRef.current.pointerId !== event.pointerId) return;
205
+ // Mixed selection: scrubbing has no meaningful base value (the `value`
206
+ // prop is a placeholder), so committing drag deltas would snap every
207
+ // selected object to a step-from-0 value. Keep the drag inert; releasing
208
+ // without a committed drag focuses the input so the user can type an
209
+ // explicit value that then applies to all.
210
+ if (mixed) return;
182
211
  const incr = event.clientX - dragRef.current.prevX;
183
212
  if (incr === 0) return;
213
+ // Require a small cumulative movement before treating this as a real
214
+ // drag. Without a threshold, ordinary mouse jitter between pointerdown
215
+ // and pointerup (a couple of px is common on real hardware even for a
216
+ // plain click) sets hasDragged=true on the very first move event, which
217
+ // makes endDrag skip its "focus the input for typing" path even though
218
+ // the user only meant to click.
219
+ const cumulativeDelta = Math.abs(event.clientX - dragRef.current.startX);
220
+ if (!dragRef.current.hasDragged && cumulativeDelta < DRAG_THRESHOLD_PX) {
221
+ dragRef.current.prevX = event.clientX;
222
+ return;
223
+ }
184
224
  dragRef.current.prevX = event.clientX;
185
225
  dragRef.current.hasDragged = true;
186
226
  // Use incremental deltas from the last move so that clamped/rounded values
@@ -17,6 +17,11 @@ type Token =
17
17
  | { type: "operator"; value: MathOperator };
18
18
 
19
19
  const NUMBER_CHAR_PATTERN = /[0-9.]/;
20
+ // Comma is only treated as a digit character while scanning a number token
21
+ // (see tokenizeExpression) so a locale-style decimal comma ("12,5") parses as
22
+ // 12.5, without swallowing the "," that a caller might use to separate
23
+ // distinct expressions elsewhere.
24
+ const NUMBER_OR_COMMA_CHAR_PATTERN = /[0-9.,]/;
20
25
 
21
26
  export function parseScrubExpression(
22
27
  input: string,
@@ -157,8 +162,12 @@ function tokenizeExpression(expression: string): Token[] {
157
162
  if (NUMBER_CHAR_PATTERN.test(char) || signedNumber) {
158
163
  const start = index;
159
164
  index += 1;
160
- while (NUMBER_CHAR_PATTERN.test(expression[index] ?? "")) index += 1;
161
- const value = Number(expression.slice(start, index));
165
+ // Allow a single comma decimal separator within this number token
166
+ // ("12,5" 12.5), matching common European locale input. A second
167
+ // comma/period is left for the caller's Number() parse to reject.
168
+ while (NUMBER_OR_COMMA_CHAR_PATTERN.test(expression[index] ?? ""))
169
+ index += 1;
170
+ const value = Number(expression.slice(start, index).replace(",", "."));
162
171
  if (!Number.isFinite(value)) return [];
163
172
  tokens.push({ type: "number", value });
164
173
  previousWasOperator = false;
@@ -18,6 +18,22 @@ export interface ElementInfo {
18
18
  selector?: string;
19
19
  classes: string[];
20
20
  computedStyles: Record<string, string>;
21
+ /**
22
+ * Raw authored `el.style` values (not computed) for a bounded set of
23
+ * layout-relevant properties: position, left, right, top, bottom, width,
24
+ * height, transform, whiteSpace. Populated on SELECTION payloads only
25
+ * (not hover). Optional because older/hover payloads omit it — callers
26
+ * must fall back to computedStyles-based inference when absent.
27
+ */
28
+ inlineStyles?: Record<string, string>;
29
+ /**
30
+ * Value of the element's `data-an-primitive` attribute (e.g. "text",
31
+ * "rectangle", "frame", "ellipse") when present. Canvas-drawn primitives —
32
+ * including T-tool text, which is a plain `div` — carry this marker so the
33
+ * inspector can identify them without relying on tagName alone. Optional
34
+ * because older payloads and non-primitive/source-backed elements omit it.
35
+ */
36
+ primitiveKind?: string;
21
37
  portableStyleSnapshot?: PortableStyleSnapshot;
22
38
  boundingRect: { x: number; y: number; width: number; height: number };
23
39
  textContent?: string;
@@ -340,16 +340,34 @@ export function CanvasCommentPins({
340
340
  const ro = new ResizeObserver(bump);
341
341
  ro.observe(canvas);
342
342
 
343
- // Scroll listeners on the canvas's scrollable ancestor(s) and window cover
344
- // both page-level scrolling and container-level panning.
345
- const scrollTarget =
346
- canvas.closest<HTMLElement>("[class*='overflow']") ?? window;
347
- scrollTarget.addEventListener("scroll", bump, { passive: true });
343
+ // Scroll listeners on every scrollable ancestor and the window cover both
344
+ // page-level scrolling and container-level panning. Walk the ancestor
345
+ // chain with getComputedStyle instead of a class-name heuristic so
346
+ // inline-styled, nested, and multiple scroll containers are all covered.
347
+ const isScrollable = (value: string) =>
348
+ value === "auto" || value === "scroll" || value === "overlay";
349
+ const scrollTargets: Array<HTMLElement | Window> = [];
350
+ for (let el: HTMLElement | null = canvas; el; el = el.parentElement) {
351
+ const style = getComputedStyle(el);
352
+ if (
353
+ isScrollable(style.overflow) ||
354
+ isScrollable(style.overflowX) ||
355
+ isScrollable(style.overflowY)
356
+ ) {
357
+ scrollTargets.push(el);
358
+ }
359
+ }
360
+ scrollTargets.push(window);
361
+ for (const target of scrollTargets) {
362
+ target.addEventListener("scroll", bump, { passive: true });
363
+ }
348
364
  window.addEventListener("resize", bump, { passive: true });
349
365
 
350
366
  return () => {
351
367
  ro.disconnect();
352
- scrollTarget.removeEventListener("scroll", bump);
368
+ for (const target of scrollTargets) {
369
+ target.removeEventListener("scroll", bump);
370
+ }
353
371
  window.removeEventListener("resize", bump);
354
372
  };
355
373
  }, [canvasEl]);
@@ -158,6 +158,10 @@ export function DrawOverlay({
158
158
  value: string;
159
159
  } | null>(null);
160
160
  const textInputRef = useRef<HTMLInputElement>(null);
161
+ // Escape cancels the pending text annotation, but unmounting the input also
162
+ // fires its blur handler, which would commit the very annotation the user
163
+ // just cancelled. This flag lets the blur handler skip that commit.
164
+ const cancelingTextRef = useRef(false);
161
165
  const [instruction, setInstruction] = useState("");
162
166
  const drawing = useRef(false);
163
167
  const canvasSizeRef = useRef({ w: 0, h: 0 });
@@ -252,6 +256,7 @@ export function DrawOverlay({
252
256
  const rect = canvasRef.current?.getBoundingClientRect();
253
257
  if (!rect) return;
254
258
  if (textMode) {
259
+ cancelingTextRef.current = false;
255
260
  setTextInput({
256
261
  xFrac: (e.clientX - rect.left) / rect.width,
257
262
  yFrac: (e.clientY - rect.top) / rect.height,
@@ -708,6 +713,12 @@ export function DrawOverlay({
708
713
  )
709
714
  }
710
715
  onBlur={() => {
716
+ // Escape unmounts the input, which fires this blur — skip
717
+ // the commit for a cancelled annotation.
718
+ if (cancelingTextRef.current) {
719
+ cancelingTextRef.current = false;
720
+ return;
721
+ }
711
722
  if (textInput.value.trim()) commitTextAnnotation();
712
723
  }}
713
724
  onKeyDown={(e) => {
@@ -715,7 +726,10 @@ export function DrawOverlay({
715
726
  e.preventDefault();
716
727
  commitTextAnnotation();
717
728
  }
718
- if (e.key === "Escape") setTextInput(null);
729
+ if (e.key === "Escape") {
730
+ cancelingTextRef.current = true;
731
+ setTextInput(null);
732
+ }
719
733
  }}
720
734
  className="h-7 w-48 border-primary bg-background text-sm"
721
735
  autoFocus
@@ -7,6 +7,9 @@ export type DesignHotkeyTool =
7
7
  | "move"
8
8
  | "frame"
9
9
  | "rectangle"
10
+ | "line"
11
+ | "arrow"
12
+ | "ellipse"
10
13
  | "text"
11
14
  | "pen"
12
15
  | "hand"
@@ -33,6 +36,11 @@ export interface DesignHotkeyTabDetails extends DesignHotkeyDetails {
33
36
  backwards: boolean;
34
37
  }
35
38
 
39
+ export interface DesignHotkeyOpacityDetails extends DesignHotkeyDetails {
40
+ /** 1-100. Digit "1".."9" (no modifier) map to 10-90; "0" maps to 100. */
41
+ opacity: number;
42
+ }
43
+
36
44
  export type DesignHotkeyTarget = Window | Document | HTMLElement;
37
45
  export type DesignHotkeyHandler = (details: DesignHotkeyDetails) => void;
38
46
  export type DesignHotkeyToolHandler = (
@@ -43,6 +51,9 @@ export type DesignHotkeyNudgeHandler = (
43
51
  details: DesignHotkeyNudgeDetails,
44
52
  ) => void;
45
53
  export type DesignHotkeyTabHandler = (details: DesignHotkeyTabDetails) => void;
54
+ export type DesignHotkeyOpacityHandler = (
55
+ details: DesignHotkeyOpacityDetails,
56
+ ) => void;
46
57
 
47
58
  export interface UseDesignHotkeysProps {
48
59
  enabled?: boolean;
@@ -55,6 +66,9 @@ export interface UseDesignHotkeysProps {
55
66
  onMoveTool?: DesignHotkeyHandler;
56
67
  onFrameTool?: DesignHotkeyHandler;
57
68
  onRectangleTool?: DesignHotkeyHandler;
69
+ onLineTool?: DesignHotkeyHandler;
70
+ onArrowTool?: DesignHotkeyHandler;
71
+ onEllipseTool?: DesignHotkeyHandler;
58
72
  onTextTool?: DesignHotkeyHandler;
59
73
  onPenTool?: DesignHotkeyHandler;
60
74
  onHandTool?: DesignHotkeyHandler;
@@ -88,6 +102,17 @@ export interface UseDesignHotkeysProps {
88
102
  onZoomReset?: DesignHotkeyHandler;
89
103
  onZoomToFit?: DesignHotkeyHandler;
90
104
  onZoomToSelection?: DesignHotkeyHandler;
105
+ /** Figma's Shift+0 — zoom to 100%. Distinct from onZoomReset (Cmd+0). */
106
+ onZoomTo100?: DesignHotkeyHandler;
107
+ /** Figma's Cmd+Alt+K — create component from the current selection. */
108
+ onCreateComponent?: DesignHotkeyHandler;
109
+ /**
110
+ * Figma's plain digit 1-9 / 0 — set selection opacity (10-90%, 0 = 100%).
111
+ * Only fires when a layer is selected (caller decides via presence of the
112
+ * handler / its own guard) and the event isn't a modifier combo or an
113
+ * editable-target keystroke (already filtered by ignoreEditableTargets).
114
+ */
115
+ onOpacityChange?: DesignHotkeyOpacityHandler;
91
116
  }
92
117
 
93
118
  const TOOL_SHORTCUTS: Record<
@@ -97,6 +122,8 @@ const TOOL_SHORTCUTS: Record<
97
122
  v: { tool: "move", handler: "onMoveTool" },
98
123
  f: { tool: "frame", handler: "onFrameTool" },
99
124
  r: { tool: "rectangle", handler: "onRectangleTool" },
125
+ o: { tool: "ellipse", handler: "onEllipseTool" },
126
+ l: { tool: "line", handler: "onLineTool" },
100
127
  t: { tool: "text", handler: "onTextTool" },
101
128
  p: { tool: "pen", handler: "onPenTool" },
102
129
  h: { tool: "hand", handler: "onHandTool" },
@@ -104,6 +131,17 @@ const TOOL_SHORTCUTS: Record<
104
131
  k: { tool: "scale", handler: "onScaleTool" },
105
132
  };
106
133
 
134
+ // H1: shift+key variants of a base tool shortcut (Figma muscle-memory), e.g.
135
+ // Shift+L selects the arrow tool while plain L selects the line tool. Keyed
136
+ // by the same lowercased key as TOOL_SHORTCUTS; only consulted when
137
+ // event.shiftKey is true so it never shadows the unshifted binding.
138
+ const SHIFT_TOOL_SHORTCUTS: Record<
139
+ string,
140
+ { tool: DesignHotkeyTool; handler: keyof UseDesignHotkeysProps }
141
+ > = {
142
+ l: { tool: "arrow", handler: "onArrowTool" },
143
+ };
144
+
107
145
  const ARROW_DIRECTIONS: Record<string, DesignHotkeyDirection> = {
108
146
  ArrowUp: "up",
109
147
  ArrowRight: "right",
@@ -255,6 +293,18 @@ function handleDesignHotkey(
255
293
  return true;
256
294
  };
257
295
 
296
+ if (!primary && !event.altKey && event.shiftKey) {
297
+ // H1: shift+key variant (e.g. Shift+L → arrow tool) takes priority over
298
+ // the base binding for the same key while shift is held.
299
+ const shiftToolShortcut = SHIFT_TOOL_SHORTCUTS[key];
300
+ if (shiftToolShortcut) {
301
+ return runTool(
302
+ shiftToolShortcut.tool,
303
+ props[shiftToolShortcut.handler] as DesignHotkeyHandler | undefined,
304
+ );
305
+ }
306
+ }
307
+
258
308
  if (!primary && !event.altKey && !event.shiftKey) {
259
309
  const toolShortcut = TOOL_SHORTCUTS[key];
260
310
  if (toolShortcut) {
@@ -319,6 +369,11 @@ function handleDesignHotkey(
319
369
  if (primary && key === "-") return run(props.onZoomOut);
320
370
  if (primary && key === "0") return run(props.onZoomReset);
321
371
 
372
+ // H2: Cmd+Alt+K — create component from the current selection.
373
+ if (primary && event.altKey && key === "k") {
374
+ return run(props.onCreateComponent);
375
+ }
376
+
322
377
  const digit = digitFromEvent(event);
323
378
  if (event.shiftKey && !primary && digit === "1") {
324
379
  return run(props.onZoomToFit);
@@ -326,6 +381,28 @@ function handleDesignHotkey(
326
381
  if (event.shiftKey && !primary && digit === "2") {
327
382
  return run(props.onZoomToSelection);
328
383
  }
384
+ // H2: Shift+0 — zoom to 100%. Distinct from Cmd+0 (onZoomReset above).
385
+ if (event.shiftKey && !primary && !event.altKey && digit === "0") {
386
+ return run(props.onZoomTo100);
387
+ }
388
+
389
+ // H2: plain digit 1-9/0 (no modifier) — set selection opacity. Figma maps
390
+ // 1-9 to 10%-90% and 0 to 100%. Only handled when nothing else claimed the
391
+ // digit (e.g. Shift+1/Shift+2 zoom above) and no modifier is held; the
392
+ // caller supplies onOpacityChange only when a layer is selected and canvas
393
+ // has focus, so an absent handler naturally no-ops here.
394
+ if (
395
+ !primary &&
396
+ !event.altKey &&
397
+ !event.shiftKey &&
398
+ digit &&
399
+ props.onOpacityChange
400
+ ) {
401
+ const opacity = digit === "0" ? 100 : Number(digit) * 10;
402
+ prevent();
403
+ props.onOpacityChange({ ...details, opacity });
404
+ return true;
405
+ }
329
406
 
330
407
  if (primary && key === "]") {
331
408
  return event.altKey ? run(props.onBringToFront) : run(props.onBringForward);
@@ -499,6 +499,7 @@ const messages = {
499
499
  propsPasted: "屬性已貼上",
500
500
  primitiveInsertFailed: "無法將該圖層新增到畫面",
501
501
  layerMoveFailed: "無法移動該圖層",
502
+ layerRenameSelectLayer: "在面板中連按兩下圖層即可重新命名",
502
503
  duplicateElementFailed: "無法複製該元素",
503
504
  saveCopyError: "無法儲存這個設計的副本",
504
505
  auditRunFailed: "無法執行設計稽核",
@@ -511,6 +511,8 @@ const enUS = {
511
511
  propsPasted: "Properties pasted",
512
512
  primitiveInsertFailed: "Could not add that layer to the screen",
513
513
  layerMoveFailed: "Could not move that layer",
514
+ layerRenameSelectLayer:
515
+ "Double-click the layer in the panel to rename it",
514
516
  duplicateElementFailed: "Could not duplicate that element",
515
517
  saveCopyError: "Could not save a copy of this design",
516
518
  auditRunFailed: "Unable to run design audit",
@@ -8228,6 +8230,7 @@ const designCanvasFeatureOverrides = {
8228
8230
  propsPasted: "属性已粘贴",
8229
8231
  primitiveInsertFailed: "无法将该图层添加到画面",
8230
8232
  layerMoveFailed: "无法移动该图层",
8233
+ layerRenameSelectLayer: "双击面板中的图层即可重命名",
8231
8234
  duplicateElementFailed: "无法复制该元素",
8232
8235
  },
8233
8236
  },
@@ -8288,6 +8291,8 @@ const designCanvasFeatureOverrides = {
8288
8291
  propsPasted: "Propiedades pegadas",
8289
8292
  primitiveInsertFailed: "No se pudo añadir esa capa a la pantalla",
8290
8293
  layerMoveFailed: "No se pudo mover esa capa",
8294
+ layerRenameSelectLayer:
8295
+ "Haz doble clic en la capa del panel para renombrarla",
8291
8296
  duplicateElementFailed: "No se pudo duplicar ese elemento",
8292
8297
  },
8293
8298
  },
@@ -8348,6 +8353,8 @@ const designCanvasFeatureOverrides = {
8348
8353
  propsPasted: "Propriétés collées",
8349
8354
  primitiveInsertFailed: "Impossible d’ajouter ce calque à l’écran",
8350
8355
  layerMoveFailed: "Impossible de déplacer ce calque",
8356
+ layerRenameSelectLayer:
8357
+ "Double-cliquez sur le calque dans le panneau pour le renommer",
8351
8358
  duplicateElementFailed: "Impossible de dupliquer cet élément",
8352
8359
  },
8353
8360
  },
@@ -8409,6 +8416,8 @@ const designCanvasFeatureOverrides = {
8409
8416
  primitiveInsertFailed:
8410
8417
  "Diese Ebene konnte nicht zur Ansicht hinzugefügt werden",
8411
8418
  layerMoveFailed: "Diese Ebene konnte nicht verschoben werden",
8419
+ layerRenameSelectLayer:
8420
+ "Doppelklicken Sie auf die Ebene im Panel, um sie umzubenennen",
8412
8421
  duplicateElementFailed: "Dieses Element konnte nicht dupliziert werden",
8413
8422
  },
8414
8423
  },
@@ -8469,6 +8478,8 @@ const designCanvasFeatureOverrides = {
8469
8478
  propsPasted: "プロパティを貼り付けました",
8470
8479
  primitiveInsertFailed: "そのレイヤーを画面に追加できませんでした",
8471
8480
  layerMoveFailed: "そのレイヤーを移動できませんでした",
8481
+ layerRenameSelectLayer:
8482
+ "パネル内のレイヤーをダブルクリックすると名前を変更できます",
8472
8483
  duplicateElementFailed: "その要素を複製できませんでした",
8473
8484
  },
8474
8485
  },
@@ -8529,6 +8540,8 @@ const designCanvasFeatureOverrides = {
8529
8540
  propsPasted: "속성이 붙여넣어짐",
8530
8541
  primitiveInsertFailed: "해당 레이어를 화면에 추가할 수 없습니다",
8531
8542
  layerMoveFailed: "해당 레이어를 이동할 수 없습니다",
8543
+ layerRenameSelectLayer:
8544
+ "패널에서 레이어를 더블클릭하면 이름을 바꿀 수 있습니다",
8532
8545
  duplicateElementFailed: "해당 요소를 복제할 수 없습니다",
8533
8546
  },
8534
8547
  },
@@ -8589,6 +8602,8 @@ const designCanvasFeatureOverrides = {
8589
8602
  propsPasted: "Propriedades coladas",
8590
8603
  primitiveInsertFailed: "Não foi possível adicionar essa camada à tela",
8591
8604
  layerMoveFailed: "Não foi possível mover essa camada",
8605
+ layerRenameSelectLayer:
8606
+ "Clique duas vezes na camada no painel para renomeá-la",
8592
8607
  duplicateElementFailed: "Não foi possível duplicar esse elemento",
8593
8608
  },
8594
8609
  },
@@ -8649,6 +8664,7 @@ const designCanvasFeatureOverrides = {
8649
8664
  propsPasted: "गुण चिपकाए गए",
8650
8665
  primitiveInsertFailed: "उस परत को स्क्रीन में नहीं जोड़ा जा सका",
8651
8666
  layerMoveFailed: "उस परत को स्थानांतरित नहीं किया जा सका",
8667
+ layerRenameSelectLayer: "नाम बदलने के लिए पैनल में परत पर डबल-क्लिक करें",
8652
8668
  duplicateElementFailed: "उस तत्व की प्रतिलिपि नहीं बनाई जा सकी",
8653
8669
  },
8654
8670
  },
@@ -8709,6 +8725,8 @@ const designCanvasFeatureOverrides = {
8709
8725
  propsPasted: "تم لصق الخصائص",
8710
8726
  primitiveInsertFailed: "تعذرت إضافة تلك الطبقة إلى الشاشة",
8711
8727
  layerMoveFailed: "تعذر نقل تلك الطبقة",
8728
+ layerRenameSelectLayer:
8729
+ "انقر نقرًا مزدوجًا فوق الطبقة في اللوحة لإعادة تسميتها",
8712
8730
  duplicateElementFailed: "تعذّر تكرار هذا العنصر",
8713
8731
  },
8714
8732
  },
@@ -1,3 +1,5 @@
1
+ import type { PortableStyleSnapshot } from "@/components/design/types";
2
+
1
3
  export interface ImportResult {
2
4
  designId?: string;
3
5
  files?: Array<{ id: string; filename: string }>;
@@ -43,3 +45,96 @@ export function importResultSummary(
43
45
  if (count === 1) return `Imported ${result!.files![0]!.filename}.`;
44
46
  return `Imported ${count} screens.`;
45
47
  }
48
+
49
+ // --- Cross-tab / system-clipboard round-trip (U4/U6) ---
50
+ //
51
+ // The in-memory clipboard refs (copiedLayerEntriesRef etc.) never survive a
52
+ // tab switch, a page reload, or a copy made in another window, and pasting
53
+ // from the OS clipboard after copying elsewhere silently reuses the stale
54
+ // in-memory entries instead. To fix that, every copy embeds a serialized
55
+ // marker comment in the text actually written to navigator.clipboard, and
56
+ // paste parses that marker back out of the live clipboard content so a
57
+ // cross-tab paste (or a same-tab paste after an external copy) round-trips
58
+ // the original entries instead of just raw concatenated HTML.
59
+
60
+ export interface DesignClipboardLayerEntry {
61
+ html: string;
62
+ rootNodeId?: string;
63
+ sourceFileId: string;
64
+ portableStyleSnapshot?: PortableStyleSnapshot;
65
+ }
66
+
67
+ export interface DesignClipboardScreenEntry {
68
+ filename: string;
69
+ fileType?: string;
70
+ content: string;
71
+ canvasFrame?: {
72
+ x?: number;
73
+ y?: number;
74
+ width?: number;
75
+ height?: number;
76
+ };
77
+ }
78
+
79
+ export interface DesignClipboardPayload {
80
+ version: 1;
81
+ entries: DesignClipboardLayerEntry[];
82
+ screens?: DesignClipboardScreenEntry[];
83
+ }
84
+
85
+ const CLIPBOARD_MARKER_PREFIX = "agent-native-clipboard-v1:";
86
+
87
+ function encodeClipboardMarkerData(payload: DesignClipboardPayload): string {
88
+ // btoa is UTF-16-unsafe for non-Latin1 text; encodeURIComponent first so
89
+ // arbitrary copied HTML (emoji, non-Latin scripts, etc.) round-trips.
90
+ return btoa(encodeURIComponent(JSON.stringify(payload)));
91
+ }
92
+
93
+ function decodeClipboardMarkerData(
94
+ data: string,
95
+ ): DesignClipboardPayload | null {
96
+ try {
97
+ const json = decodeURIComponent(atob(data));
98
+ const parsed = JSON.parse(json) as unknown;
99
+ if (
100
+ !parsed ||
101
+ typeof parsed !== "object" ||
102
+ (parsed as { version?: unknown }).version !== 1 ||
103
+ !Array.isArray((parsed as { entries?: unknown }).entries)
104
+ ) {
105
+ return null;
106
+ }
107
+ return parsed as DesignClipboardPayload;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Appends an invisible marker comment (safe inside HTML and plain text alike)
115
+ * encoding the full clipboard payload after the human-visible clipboard text.
116
+ */
117
+ export function serializeDesignClipboardPayload(
118
+ visibleText: string,
119
+ payload: DesignClipboardPayload,
120
+ ): string {
121
+ const marker = `<!--${CLIPBOARD_MARKER_PREFIX}${encodeClipboardMarkerData(payload)}-->`;
122
+ return `${visibleText}\n${marker}`;
123
+ }
124
+
125
+ /**
126
+ * Extracts a previously-serialized payload from clipboard text, if present.
127
+ * Returns null for clipboard content that was never written by this app (a
128
+ * plain copy from elsewhere, or another app's clipboard payload).
129
+ */
130
+ export function parseDesignClipboardMarker(
131
+ text: string | null | undefined,
132
+ ): DesignClipboardPayload | null {
133
+ if (!text) return null;
134
+ const markerIndex = text.lastIndexOf(`<!--${CLIPBOARD_MARKER_PREFIX}`);
135
+ if (markerIndex === -1) return null;
136
+ const start = markerIndex + 4 + CLIPBOARD_MARKER_PREFIX.length;
137
+ const end = text.indexOf("-->", start);
138
+ if (end === -1) return null;
139
+ return decodeClipboardMarkerData(text.slice(start, end));
140
+ }