@agent-native/core 0.131.3 → 0.131.5

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 (60) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/docs-components.mdx +491 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/client/AgentPanel.tsx +58 -34
  6. package/corpus/core/src/localization/default-messages.ts +6 -0
  7. package/corpus/templates/content/.agents/skills/content-product-development/SKILL.md +47 -0
  8. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +3 -1
  9. package/corpus/templates/design/actions/get-design.ts +11 -3
  10. package/corpus/templates/design/actions/present-design-variants.ts +62 -5
  11. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +64 -1
  12. package/corpus/templates/design/app/components/design/EditPanel.tsx +199 -25
  13. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +160 -46
  14. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +7 -1
  15. package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +59 -12
  16. package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +1 -1
  17. package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +34 -29
  18. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +2 -2
  19. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +28 -10
  20. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +15 -28
  21. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +4 -3
  22. package/corpus/templates/design/app/components/design/multi-screen/chrome-transitions.ts +12 -8
  23. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +34 -9
  24. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +13 -27
  25. package/corpus/templates/design/app/components/design/multi-screen/types.ts +8 -1
  26. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +95 -0
  27. package/corpus/templates/design/app/i18n-data.ts +121 -10
  28. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +45 -0
  29. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +59 -0
  30. package/corpus/templates/design/app/pages/design-editor/text-edit-utils.ts +115 -40
  31. package/corpus/templates/design/changelog/2026-07-28-generated-design-variants-no-longer-overlap-each-other-on-th.md +6 -0
  32. package/corpus/templates/design/changelog/2026-07-29-auto-layout-direction-icons-show-an-arrow.md +6 -0
  33. package/corpus/templates/design/changelog/2026-07-29-breakpoints-no-longer-overlap-neighbouring-screens.md +6 -0
  34. package/corpus/templates/design/changelog/2026-07-29-canvas-is-more-responsive-when-selecting-and-adding-frames.md +6 -0
  35. package/corpus/templates/design/changelog/2026-07-29-canvas-zoom-is-smoother-and-labels-hold-their-size.md +6 -0
  36. package/corpus/templates/design/changelog/2026-07-29-double-click-a-frame-now-drills-into-its-layers.md +6 -0
  37. package/corpus/templates/design/changelog/2026-07-29-export-preview-shows-the-real-render.md +6 -0
  38. package/corpus/templates/design/changelog/2026-07-29-figma-parity-shortcuts.md +6 -0
  39. package/corpus/templates/design/changelog/2026-07-29-more-breakpoint-width-presets.md +6 -0
  40. package/corpus/templates/design/changelog/2026-07-29-resize-a-selected-frame-to-a-device-preset.md +6 -0
  41. package/corpus/templates/design/changelog/2026-07-29-text-tool-reaches-the-right-surface.md +6 -0
  42. package/corpus/templates/design/shared/canvas-frames.ts +23 -0
  43. package/corpus/templates/design/shared/code-layer.ts +54 -2
  44. package/corpus/templates/design/shared/responsive-frame-layout.ts +55 -0
  45. package/dist/client/AgentPanel.d.ts.map +1 -1
  46. package/dist/client/AgentPanel.js +16 -5
  47. package/dist/client/AgentPanel.js.map +1 -1
  48. package/dist/localization/default-messages.d.ts +6 -0
  49. package/dist/localization/default-messages.d.ts.map +1 -1
  50. package/dist/localization/default-messages.js +6 -0
  51. package/dist/localization/default-messages.js.map +1 -1
  52. package/dist/notifications/routes.d.ts +1 -1
  53. package/dist/observability/routes.d.ts +3 -3
  54. package/dist/progress/routes.d.ts +1 -1
  55. package/dist/resources/handlers.d.ts +1 -1
  56. package/dist/server/transcribe-voice.d.ts +1 -1
  57. package/docs/content/docs-components.mdx +491 -0
  58. package/package.json +1 -1
  59. package/src/client/AgentPanel.tsx +58 -34
  60. package/src/localization/default-messages.ts +6 -0
@@ -174,9 +174,11 @@ const EMPTY_SCREEN_IDS: readonly string[] = [];
174
174
  // redeclared locally and drifting from the shared constant.
175
175
  const MIN_ZOOM = DEFAULT_CANVAS_MIN_ZOOM;
176
176
  const MAX_ZOOM = DEFAULT_CANVAS_MAX_ZOOM;
177
- const ZOOM_SENSITIVITY = 0.01;
178
- const MAX_WHEEL_ZOOM_DELTA = 120;
179
177
  const MAX_WHEEL_PAN_DELTA = 140;
178
+ /** Live counter-scale for constant-size frame chrome, written on the world
179
+ * element every gesture frame by applyViewToDom. React supplies the same
180
+ * number as the var's fallback, so first paint and SSR are unaffected. */
181
+ const CHROME_SCALE_CSS_VAR = "--an-chrome-scale";
180
182
  const PIXEL_GRID_ZOOM = 800;
181
183
  import {
182
184
  BOARD_SURFACE_BACKGROUND,
@@ -301,8 +303,13 @@ import {
301
303
  previewDraftPrimitive,
302
304
  shapeClosingHandles,
303
305
  } from "./multi-screen/draft-primitives";
306
+ import {
307
+ drillInCandidateKey,
308
+ resolveDrillInTarget,
309
+ } from "./multi-screen/drill-in";
304
310
  import {
305
311
  angleBetween,
312
+ BREAKPOINT_FRAME_GAP,
306
313
  cloneFrameGeometryById,
307
314
  deviceViewportFloorForWidth,
308
315
  findTopFrameEntryAtPoint,
@@ -340,6 +347,11 @@ import {
340
347
  } from "./multi-screen/primitive-drop-target";
341
348
  import type { AlignmentGuide, CanvasFrameEntry } from "./multi-screen/types";
342
349
  import { vectorEditCanvasToLocalPoint } from "./multi-screen/vector-edit-geometry";
350
+ import {
351
+ isPinchZoomDelta,
352
+ resolveExternalZoomAnchor,
353
+ resolveZoomFactor,
354
+ } from "./multi-screen/zoom-gesture";
343
355
 
344
356
  /**
345
357
  * Imperatively writes a draft primitive's full visual state onto its cached
@@ -909,6 +921,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
909
921
  // externally-driven (toolbar/keyboard) zoom change without a TDZ error.
910
922
  const recomputePenPointerForViewChangeRef = useRef<() => void>(() => {});
911
923
  const suppressNextPick = useRef(false);
924
+ /** Where the last frame-body double-click landed, so consecutive
925
+ * double-clicks descend one level further instead of re-selecting the same
926
+ * layer. Reset whenever the pointer moves to a different screen. */
927
+ const drillInTargetRef = useRef<{ screenId: string; key: string } | null>(
928
+ null,
929
+ );
930
+ /** Generation counter for drill-in requests. Collecting candidates is an
931
+ * async iframe round-trip, so ANY newer selection — another double-click, a
932
+ * plain click, a marquee — must invalidate an in-flight one, or its late
933
+ * reply overwrites the selection the user actually made. */
934
+ const drillInRequestRef = useRef(0);
912
935
  const feedbackTimerRef = useRef<number | null>(null);
913
936
  const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
914
937
  const wheelGestureFrameRef = useRef<number | null>(null);
@@ -1319,7 +1342,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1319
1342
  : referenceId
1320
1343
  ? frameGeometryRef.current[referenceId]
1321
1344
  : undefined;
1322
- const cursor = activeGeometry
1345
+ // A screen's frame is routinely much taller than the viewport, so its centre
1346
+ // is often far off-screen; anchoring there holds a point the user cannot see
1347
+ // and pushes the visible content out of frame entirely.
1348
+ const frameCenter = activeGeometry
1323
1349
  ? canvasToScreenPoint(
1324
1350
  {
1325
1351
  x: activeGeometry.x + activeGeometry.width / 2,
@@ -1329,9 +1355,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1329
1355
  { x: 0, y: 0 },
1330
1356
  SURFACE_PADDING,
1331
1357
  )
1332
- : rect
1333
- ? { x: rect.width / 2, y: rect.height / 2 }
1334
- : { x: 0, y: 0 };
1358
+ : null;
1359
+ const cursor = resolveExternalZoomAnchor({
1360
+ frameCenter,
1361
+ surfaceSize: { width: rect?.width ?? 0, height: rect?.height ?? 0 },
1362
+ });
1335
1363
  const nextPan = getPanForZoomToCursor({
1336
1364
  pan: panRef.current,
1337
1365
  cursor,
@@ -3364,6 +3392,9 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3364
3392
  (e: React.MouseEvent) => {
3365
3393
  e.preventDefault();
3366
3394
  e.stopPropagation();
3395
+ // Supersede any in-flight drill-in: its reply must not overwrite this
3396
+ // gesture's selection (see drillInRequestRef).
3397
+ drillInRequestRef.current += 1;
3367
3398
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
3368
3399
  let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
3369
3400
  let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
@@ -6082,6 +6113,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6082
6113
  suppressNextPick.current = false;
6083
6114
  return;
6084
6115
  }
6116
+ // Supersede any in-flight drill-in: its reply must not overwrite this pick.
6117
+ drillInRequestRef.current += 1;
6085
6118
 
6086
6119
  if (e.shiftKey) {
6087
6120
  updateSelectedDraftIds(() => []);
@@ -6115,7 +6148,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6115
6148
  ],
6116
6149
  );
6117
6150
 
6118
- const handleFrameDoubleClick = useCallback(
6151
+ const handleFrameInteract = useCallback(
6119
6152
  (id: string, e: React.MouseEvent<HTMLElement>) => {
6120
6153
  e.preventDefault();
6121
6154
  e.stopPropagation();
@@ -6137,6 +6170,65 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6137
6170
  ],
6138
6171
  );
6139
6172
 
6173
+ /**
6174
+ * Figma parity: double-clicking a frame's body descends into its layers so
6175
+ * the inspector shows the thing under the pointer. It deliberately does NOT
6176
+ * enter Interact — that is the frame label's own button (onEdit). Locked
6177
+ * screens have no selectable children to descend into, so they keep the
6178
+ * Interact behavior.
6179
+ */
6180
+ const handleFrameEnter = useCallback(
6181
+ (id: string, e: React.MouseEvent<HTMLElement>) => {
6182
+ e.preventDefault();
6183
+ e.stopPropagation();
6184
+ if (lockedScreenIdSet.has(id)) {
6185
+ onEdit?.(id);
6186
+ return;
6187
+ }
6188
+ const point = getCanvasPoint(e.clientX, e.clientY);
6189
+ const previousKey =
6190
+ drillInTargetRef.current?.screenId === id
6191
+ ? drillInTargetRef.current.key
6192
+ : null;
6193
+ const requestId = drillInRequestRef.current + 1;
6194
+ drillInRequestRef.current = requestId;
6195
+ void collectLayerMarqueeCandidates(new Set([id])).then((candidates) => {
6196
+ if (drillInRequestRef.current !== requestId) return;
6197
+ const target = resolveDrillInTarget({
6198
+ candidates,
6199
+ screenId: id,
6200
+ point,
6201
+ previousKey,
6202
+ });
6203
+ if (!target) {
6204
+ // Nothing selectable under the pointer (e.g. an empty frame). Leave
6205
+ // the frame itself selected rather than falling back to Interact.
6206
+ drillInTargetRef.current = null;
6207
+ return;
6208
+ }
6209
+ drillInTargetRef.current = {
6210
+ screenId: id,
6211
+ key: drillInCandidateKey(target),
6212
+ };
6213
+ updateSelectedDraftIds(() => []);
6214
+ updateSelectedIds(() => []);
6215
+ onLayerMarqueeSelectionChange?.(
6216
+ [{ screenId: target.screenId, info: target.info }],
6217
+ { source: "pointer" },
6218
+ );
6219
+ });
6220
+ },
6221
+ [
6222
+ collectLayerMarqueeCandidates,
6223
+ getCanvasPoint,
6224
+ lockedScreenIdSet,
6225
+ onEdit,
6226
+ onLayerMarqueeSelectionChange,
6227
+ updateSelectedDraftIds,
6228
+ updateSelectedIds,
6229
+ ],
6230
+ );
6231
+
6140
6232
  const beginDuplicateGesture = useCallback(
6141
6233
  (screen: ScreenFile, display: string, e: React.MouseEvent<HTMLElement>) => {
6142
6234
  if (readOnly) return;
@@ -6558,6 +6650,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6558
6650
  // 2D translate (not translate3d) so the layer is not GPU-pinned to a
6559
6651
  // stale low-res raster — keeps zoomed-in content crisp.
6560
6652
  world.style.transform = `translate(${p.x}px, ${p.y}px) scale(${nextScale})`;
6653
+ // Counter-scale for constant-size chrome (frame labels, the Interact
6654
+ // button). This has to be written on the SAME imperative tick as the
6655
+ // world transform: the React `chromeScale` it falls back to is only
6656
+ // reconciled by the debounced commitView, so during a gesture the labels
6657
+ // rode the world scale and then eased back — the "text/labels jump in
6658
+ // sizing" the canvas zoom was reported for.
6659
+ world.style.setProperty(
6660
+ CHROME_SCALE_CSS_VAR,
6661
+ String(nextScale > 0 ? 1 / nextScale : 1),
6662
+ );
6561
6663
  }
6562
6664
  const grid = pixelGridRef.current;
6563
6665
  if (grid) {
@@ -6764,13 +6866,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6764
6866
 
6765
6867
  if (gesture.mode === "zoom") {
6766
6868
  const currentZoom = zoomRef.current;
6767
- const zoomDeltaY = clamp(
6768
- gesture.deltaY,
6769
- -MAX_WHEEL_ZOOM_DELTA,
6770
- MAX_WHEEL_ZOOM_DELTA,
6771
- );
6772
6869
  const nextZoom = clamp(
6773
- currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
6870
+ currentZoom * resolveZoomFactor(gesture.deltaY, gesture.pinch),
6774
6871
  MIN_ZOOM,
6775
6872
  MAX_ZOOM,
6776
6873
  );
@@ -6803,10 +6900,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6803
6900
  const enqueueWheelGesture = useCallback(
6804
6901
  (gesture: PendingWheelGesture) => {
6805
6902
  const pending = pendingWheelGestureRef.current;
6806
- if (pending?.mode === "zoom" && gesture.mode === "zoom") {
6903
+ // Only accumulate like with like: a pinch delta and a wheel notch map to
6904
+ // zoom through different curves, so summing them would apply one curve to
6905
+ // the other's units.
6906
+ if (
6907
+ pending?.mode === "zoom" &&
6908
+ gesture.mode === "zoom" &&
6909
+ pending.pinch === gesture.pinch
6910
+ ) {
6807
6911
  pendingWheelGestureRef.current = {
6808
6912
  mode: "zoom",
6809
6913
  deltaY: pending.deltaY + gesture.deltaY,
6914
+ pinch: gesture.pinch,
6810
6915
  cursor: gesture.cursor,
6811
6916
  clientX: gesture.clientX,
6812
6917
  clientY: gesture.clientY,
@@ -6852,14 +6957,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6852
6957
  // no forced style/layout read on the hot pan path.
6853
6958
  const rect = surfaceRef.current?.getBoundingClientRect();
6854
6959
  if (!rect) return;
6855
- const zoomDeltaY = clamp(
6856
- delta.y,
6857
- -MAX_WHEEL_ZOOM_DELTA,
6858
- MAX_WHEEL_ZOOM_DELTA,
6859
- );
6960
+ // Raw delta: the per-frame clamp lives on the zoom FACTOR now (see
6961
+ // resolveZoomFactor), so accumulation stays frame-rate independent.
6962
+ // Line/page delta modes are always discrete wheels, never a pinch.
6860
6963
  enqueueWheelGesture({
6861
6964
  mode: "zoom",
6862
- deltaY: zoomDeltaY,
6965
+ deltaY: delta.y,
6966
+ pinch: args.deltaMode === 0 && isPinchZoomDelta(args.deltaY),
6863
6967
  cursor: {
6864
6968
  x: args.clientX - rect.left,
6865
6969
  y: args.clientY - rect.top,
@@ -7935,7 +8039,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7935
8039
  chromeScale={chromeScale}
7936
8040
  chromeSettling={chromeSettling}
7937
8041
  onPick={handleFrameClick}
7938
- onEdit={handleFrameDoubleClick}
8042
+ onEdit={handleFrameInteract}
8043
+ onEnterFrame={handleFrameEnter}
7939
8044
  onStartFrameDrag={beginFrameDrag}
7940
8045
  onStartResize={beginResize}
7941
8046
  onStartRotate={beginRotate}
@@ -9248,7 +9353,12 @@ interface ScreenProps {
9248
9353
  * descriptor is retained for an evicted screen's revisit. */
9249
9354
  cullTier: ScreenCullTier;
9250
9355
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
9356
+ /** Explicit Interact entry — the frame label's own button and the frame-label
9357
+ * row. Deliberately NOT the frame body's double-click: see onEnterFrame. */
9251
9358
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
9359
+ /** Double-click on the frame body. Descends into the frame's layers instead
9360
+ * of entering Interact, matching Figma. */
9361
+ onEnterFrame: (id: string, e: React.MouseEvent<HTMLElement>) => void;
9252
9362
  onStartFrameDrag: (id: string, e: React.MouseEvent) => void;
9253
9363
  onStartResize: (
9254
9364
  id: string,
@@ -9262,12 +9372,13 @@ interface ScreenProps {
9262
9372
  e: React.MouseEvent<HTMLElement>,
9263
9373
  ) => void;
9264
9374
  // Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own
9265
- // onAddBreakpoint/onActiveBreakpointChange props (PF18): Screen binds
9266
- // screen.id itself when calling these, so the parent can pass the same
9267
- // stable function reference for every screen instead of allocating a new
9268
- // per-screen `(widthPx) => onAddBreakpoint(screen.id, widthPx)` closure on
9269
- // every render, which defeated memo(Screen) for every screen every time.
9270
- onAddBreakpoint?: (screenId: string, widthPx: number) => void;
9375
+ // onActiveBreakpointChange prop (PF18): Screen binds screen.id itself when
9376
+ // calling these, so the parent can pass the same stable function reference
9377
+ // for every screen instead of allocating a new per-screen closure on every
9378
+ // render, which defeated memo(Screen) for every screen every time.
9379
+ // onAddBreakpoint is design-scoped and takes no screen id at all — see its
9380
+ // doc on MultiScreenCanvasProps.
9381
+ onAddBreakpoint?: (widthPx: number) => void;
9271
9382
  onActiveBreakpointChange?: (
9272
9383
  screenId: string,
9273
9384
  widthPx: number | undefined,
@@ -9309,6 +9420,7 @@ const Screen = memo(function Screen({
9309
9420
  chromeSettling,
9310
9421
  onPick,
9311
9422
  onEdit,
9423
+ onEnterFrame,
9312
9424
  onStartFrameDrag,
9313
9425
  onStartResize,
9314
9426
  onStartRotate,
@@ -9483,9 +9595,9 @@ const Screen = memo(function Screen({
9483
9595
  style={{
9484
9596
  width: labelInfoMaxWidth,
9485
9597
  maxWidth: labelInfoMaxWidth,
9486
- transform: `translateY(-50%) scale(${chromeScale})`,
9598
+ transform: `translateY(-50%) scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
9487
9599
  transformOrigin: "left center",
9488
- transition: getChromeLabelTransition(chromeSettling),
9600
+ transition: getChromeLabelTransition(),
9489
9601
  }}
9490
9602
  >
9491
9603
  {/* B5-3: the leading dot/bullet before the screen label was pure
@@ -9555,9 +9667,9 @@ const Screen = memo(function Screen({
9555
9667
  )}
9556
9668
  style={{
9557
9669
  maxWidth: fullViewMaxWidth,
9558
- transform: `translateY(-50%) scale(${chromeScale})`,
9670
+ transform: `translateY(-50%) scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
9559
9671
  transformOrigin: "right center",
9560
- transition: getChromeLabelTransition(chromeSettling),
9672
+ transition: getChromeLabelTransition(),
9561
9673
  }}
9562
9674
  aria-label={frameActionLabel}
9563
9675
  title={frameActionLabel}
@@ -9599,7 +9711,7 @@ const Screen = memo(function Screen({
9599
9711
  }
9600
9712
  e.preventDefault();
9601
9713
  e.stopPropagation();
9602
- onEdit(screen.id, e);
9714
+ onEnterFrame(screen.id, e);
9603
9715
  }}
9604
9716
  onMouseDown={(e) => {
9605
9717
  if (isInteractiveScreenContentTarget(e.target)) {
@@ -9818,11 +9930,7 @@ const Screen = memo(function Screen({
9818
9930
  ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
9819
9931
  : undefined
9820
9932
  }
9821
- onAddBreakpoint={
9822
- onAddBreakpoint
9823
- ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
9824
- : undefined
9825
- }
9933
+ onAddBreakpoint={onAddBreakpoint}
9826
9934
  onRemoveBreakpoint={
9827
9935
  onRemoveBreakpoint
9828
9936
  ? (widthPx) => onRemoveBreakpoint(screen.id, widthPx)
@@ -9893,6 +10001,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
9893
10001
  prev.chromeSettling === next.chromeSettling &&
9894
10002
  prev.onPick === next.onPick &&
9895
10003
  prev.onEdit === next.onEdit &&
10004
+ prev.onEnterFrame === next.onEnterFrame &&
9896
10005
  prev.onStartFrameDrag === next.onStartFrameDrag &&
9897
10006
  prev.onStartResize === next.onStartResize &&
9898
10007
  prev.onStartRotate === next.onStartRotate &&
@@ -9911,8 +10020,6 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
9911
10020
 
9912
10021
  // ── Breakpoint preview row (§6.4) ────────────────────────────────────────────
9913
10022
 
9914
- /** Gap between adjacent breakpoint frames in canvas pixels. */
9915
- const BREAKPOINT_FRAME_GAP = 24;
9916
10023
  const BREAKPOINT_LABEL_WITH_WIDTH_MIN_FRAME_WIDTH = 128;
9917
10024
 
9918
10025
  /**
@@ -10156,9 +10263,9 @@ function BreakpointPreviewRow({
10156
10263
  data-frame-label
10157
10264
  className="absolute left-1 top-1/2 flex min-w-0 max-w-[calc(100%-28px)] items-center gap-1.5"
10158
10265
  style={{
10159
- transform: `translateY(-50%) scale(${chromeScale})`,
10266
+ transform: `translateY(-50%) scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
10160
10267
  transformOrigin: "left center",
10161
- transition: getChromeLabelTransition(chromeSettling),
10268
+ transition: getChromeLabelTransition(),
10162
10269
  }}
10163
10270
  >
10164
10271
  <span
@@ -10200,7 +10307,7 @@ function BreakpointPreviewRow({
10200
10307
  <div
10201
10308
  className="absolute right-1 top-1/2 z-30"
10202
10309
  style={{
10203
- transform: `translateY(-50%) scale(${chromeScale})`,
10310
+ transform: `translateY(-50%) scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
10204
10311
  transformOrigin: "right center",
10205
10312
  }}
10206
10313
  >
@@ -10302,7 +10409,7 @@ function BreakpointPreviewRow({
10302
10409
  }}
10303
10410
  onDoubleClick={(e) => {
10304
10411
  // Item 8b — full view for THIS breakpoint: same double-click
10305
- // gesture as a regular screen's card (handleFrameDoubleClick),
10412
+ // gesture as a regular screen's card (handleFrameInteract),
10306
10413
  // but targeting the breakpoint's own width so the editor
10307
10414
  // opens single-screen mode scoped to it instead of Base.
10308
10415
  e.preventDefault();
@@ -10336,7 +10443,7 @@ function BreakpointPreviewRow({
10336
10443
  data-frame-full-view
10337
10444
  className="absolute right-1 top-1 z-30 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border bg-background/95 text-muted-foreground opacity-0 shadow-sm transition-opacity hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/artboard:opacity-100"
10338
10445
  style={{
10339
- transform: `scale(${chromeScale})`,
10446
+ transform: `scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
10340
10447
  transformOrigin: "right center",
10341
10448
  }}
10342
10449
  aria-label={frameActionLabel}
@@ -10472,10 +10579,17 @@ function BreakpointPreviewRow({
10472
10579
  "hover:border-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
10473
10580
  )}
10474
10581
  style={{
10475
- transform: `scale(${chromeScale})`,
10582
+ transform: `scale(var(${CHROME_SCALE_CSS_VAR}, ${chromeScale}))`,
10476
10583
  transformOrigin: "center",
10477
10584
  }}
10478
- title={`Add ${breakpointLabel(nextWidth)} breakpoint (${nextWidth}px)`}
10585
+ // Says "to all screens" because that is what it does: a design has
10586
+ // one breakpoint set, so this is not scoped to the frame it renders
10587
+ // beside. The old per-frame wording made every frame suddenly
10588
+ // sprouting the new width look like a bug.
10589
+ title={t("multiScreenCanvas.addBreakpointToAllScreens", {
10590
+ label: breakpointLabel(nextWidth),
10591
+ width: nextWidth,
10592
+ })}
10479
10593
  onClick={(e) => {
10480
10594
  e.stopPropagation();
10481
10595
  onAddBreakpoint(nextWidth);
@@ -12177,7 +12177,11 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean;
12177
12177
  typeof e.data.correlationId === "string" ? e.data.correlationId : "";
12178
12178
  var textEditStatusNodeId: string =
12179
12179
  typeof e.data.nodeId === "string" ? e.data.nodeId : "";
12180
- var textEditStatus: "active" | "done" | false = false;
12180
+ // "missing" (this document has no such node) stays distinct from a bare
12181
+ // `false` (node is here, just not being edited). The host's retry ladder
12182
+ // needs the difference: the first is a still-propagating insert or the
12183
+ // wrong iframe, the second means the user has not typed yet.
12184
+ var textEditStatus: "active" | "done" | "missing" | false = "missing";
12181
12185
  if (textEditStatusNodeId) {
12182
12186
  var escapedTextEditStatusNodeId = textEditStatusNodeId
12183
12187
  .replace(/\\/g, "\\\\")
@@ -12201,6 +12205,8 @@ declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean;
12201
12205
  (textEditStatusNode.textContent ?? "").trim().length > 0
12202
12206
  ) {
12203
12207
  textEditStatus = "done";
12208
+ } else if (textEditStatusNode) {
12209
+ textEditStatus = false;
12204
12210
  }
12205
12211
  }
12206
12212
  (window.parent as Window).postMessage(
@@ -28,6 +28,11 @@ import {
28
28
  PopoverContent,
29
29
  PopoverTrigger,
30
30
  } from "@/components/ui/popover";
31
+ import {
32
+ Tooltip,
33
+ TooltipContent,
34
+ TooltipTrigger,
35
+ } from "@/components/ui/tooltip";
31
36
  import { cn } from "@/lib/utils";
32
37
 
33
38
  import { ScrubInput } from "../inspector";
@@ -37,6 +42,7 @@ import {
37
42
  type GlslShaderPanelContext,
38
43
  } from "../inspector/GlslShaderPanel";
39
44
  import type { ElementInfo } from "../types";
45
+ import { isTextElement } from "./element-classification";
40
46
  import { elementStableKey } from "./element-identity";
41
47
  import { FieldTrailer } from "./field-primitives";
42
48
  import { splitCssLayers } from "./fill-gradient-helpers";
@@ -155,6 +161,26 @@ export function serializeShadowLayers(layers: ShadowLayer[]) {
155
161
  .join(", ");
156
162
  }
157
163
 
164
+ /**
165
+ * `text-shadow` form of the same layers: offsets, blur, colour. CSS text-shadow
166
+ * has no spread and no inset, so emitting the box-shadow string would produce a
167
+ * declaration the browser drops entirely.
168
+ */
169
+ export function serializeTextShadowLayers(layers: ShadowLayer[]) {
170
+ const visible = layers.filter((layer) => !layer.inset);
171
+ if (!visible.length) return "none";
172
+ return visible
173
+ .map((layer) =>
174
+ [
175
+ `${roundToOneDecimal(layer.x)}px`,
176
+ `${roundToOneDecimal(layer.y)}px`,
177
+ `${Math.max(0, roundToOneDecimal(layer.blur))}px`,
178
+ layer.color,
179
+ ].join(" "),
180
+ )
181
+ .join(", ");
182
+ }
183
+
158
184
  export function readBlurFilter(value: string | undefined): number {
159
185
  const match = value?.match(/blur\((-?(?:\d+(?:\.\d+)?|\.\d+))px\)/);
160
186
  return match ? Math.max(0, Number(match[1])) : 0;
@@ -479,13 +505,27 @@ export function EffectsProperties({
479
505
  const effectStashKey = elementStableKey(element);
480
506
  const layerBlurStashKey = `${effectStashKey}:filter:blur`;
481
507
  const backdropBlurStashKey = `${effectStashKey}:backdrop-filter:blur`;
508
+ const shadowTargetsText = isTextElement(element);
509
+ // Read from the same property the writer targets, or the rows would show a
510
+ // box-shadow that is no longer what this element uses.
482
511
  const shadowLayers = effectsAreMixed
483
512
  ? []
484
- : parseShadowLayers(styles.boxShadow);
513
+ : parseShadowLayers(
514
+ shadowTargetsText ? styles.textShadow : styles.boxShadow,
515
+ );
485
516
  const setShadowLayers = (layers: ShadowLayer[], meta?: StyleChangeMeta) => {
486
517
  setHiddenEffectStash((stash) =>
487
518
  remapIndexedShadowStash(stash, effectStashKey, layers),
488
519
  );
520
+ // A shadow on text belongs on the glyphs. box-shadow paints the element's
521
+ // BOX, which on a text node reads as a rectangle floating behind the words
522
+ // instead of a shadow on the letters.
523
+ if (shadowTargetsText) {
524
+ const textShadow = serializeTextShadowLayers(layers);
525
+ if (onStylesChange) onStylesChange({ textShadow }, meta);
526
+ else onStyleChange("textShadow", textShadow, meta);
527
+ return;
528
+ }
489
529
  const boxShadow = serializeShadowLayers(layers);
490
530
  if (onStylesChange) onStylesChange({ boxShadow }, meta);
491
531
  else onStyleChange("boxShadow", boxShadow, meta);
@@ -551,17 +591,24 @@ export function EffectsProperties({
551
591
  title={t("editPanel.sections.effects")}
552
592
  actions={
553
593
  <DropdownMenu>
554
- <DropdownMenuTrigger asChild>
555
- <Button
556
- type="button"
557
- variant="ghost"
558
- size="icon"
559
- className="size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground"
560
- aria-label={t("editPanel.labels.addLayer")}
561
- >
562
- <IconPlus className="size-3.5" />
563
- </Button>
564
- </DropdownMenuTrigger>
594
+ {/* Tooltip as well as aria-label: this was the one section-header +
595
+ that explained itself to screen readers only. */}
596
+ <Tooltip>
597
+ <TooltipTrigger asChild>
598
+ <DropdownMenuTrigger asChild>
599
+ <Button
600
+ type="button"
601
+ variant="ghost"
602
+ size="icon"
603
+ className="size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground"
604
+ aria-label={t("editPanel.labels.addEffect")}
605
+ >
606
+ <IconPlus className="size-3.5" />
607
+ </Button>
608
+ </DropdownMenuTrigger>
609
+ </TooltipTrigger>
610
+ <TooltipContent>{t("editPanel.labels.addEffect")}</TooltipContent>
611
+ </Tooltip>
565
612
  <DropdownMenuContent align="end" className="min-w-44">
566
613
  <DropdownMenuItem
567
614
  className="gap-2 !text-[11px]"
@@ -279,7 +279,7 @@ export function FillProperties({
279
279
  <IconLayoutGrid className="size-3.5" />
280
280
  </SectionIconButton>
281
281
  <SectionIconButton
282
- label={t("editPanel.labels.addLayer")}
282
+ label={t("editPanel.labels.addFill")}
283
283
  onClick={() => {
284
284
  if (fillIsMixed) {
285
285
  commitStylePatch(
@@ -28,36 +28,41 @@ export function SectionIconButton({
28
28
 
29
29
  return (
30
30
  <Tooltip>
31
+ {/* The span carries the hover, not the Button: a disabled Button stops
32
+ pointer events, so a disabled control would silently lose the very
33
+ tooltip that explains why it is disabled. */}
31
34
  <TooltipTrigger asChild>
32
- <Button
33
- type="button"
34
- variant="ghost"
35
- size="icon"
36
- className={cn(
37
- "size-6 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed",
38
- className,
39
- )}
40
- disabled={disabled}
41
- onPointerDown={(event) => {
42
- if (!activateOnPointerDown || disabled || event.button !== 0) {
43
- return;
44
- }
45
- pointerActivatedRef.current = true;
46
- event.preventDefault();
47
- event.stopPropagation();
48
- onClick?.();
49
- }}
50
- onClick={() => {
51
- if (pointerActivatedRef.current) {
52
- pointerActivatedRef.current = false;
53
- return;
54
- }
55
- onClick?.();
56
- }}
57
- aria-label={label}
58
- >
59
- {children}
60
- </Button>
35
+ <span className="flex shrink-0">
36
+ <Button
37
+ type="button"
38
+ variant="ghost"
39
+ size="icon"
40
+ className={cn(
41
+ "size-6 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed",
42
+ className,
43
+ )}
44
+ disabled={disabled}
45
+ onPointerDown={(event) => {
46
+ if (!activateOnPointerDown || disabled || event.button !== 0) {
47
+ return;
48
+ }
49
+ pointerActivatedRef.current = true;
50
+ event.preventDefault();
51
+ event.stopPropagation();
52
+ onClick?.();
53
+ }}
54
+ onClick={() => {
55
+ if (pointerActivatedRef.current) {
56
+ pointerActivatedRef.current = false;
57
+ return;
58
+ }
59
+ onClick?.();
60
+ }}
61
+ aria-label={label}
62
+ >
63
+ {children}
64
+ </Button>
65
+ </span>
61
66
  </TooltipTrigger>
62
67
  <TooltipContent>{label}</TooltipContent>
63
68
  </Tooltip>
@@ -352,7 +352,7 @@ export function StrokeProperties({
352
352
  title={t("editPanel.sections.stroke")}
353
353
  actions={
354
354
  <SectionIconButton
355
- label={t("editPanel.labels.addLayer")}
355
+ label={t("editPanel.labels.addStroke")}
356
356
  onClick={() => {
357
357
  if (strokeIsMixed) {
358
358
  commitStylePatch(
@@ -537,7 +537,7 @@ function TextStrokeProperties({
537
537
  title={t("editPanel.sections.stroke")}
538
538
  actions={
539
539
  <SectionIconButton
540
- label={t("editPanel.labels.addLayer")}
540
+ label={t("editPanel.labels.addStroke")}
541
541
  onClick={() => {
542
542
  // Kebab-case keys required: camelCase webkit props get mangled by
543
543
  // normalizeStyleProperty (camel→kebab drops the leading dash) and