@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
@@ -11,6 +11,11 @@ import {
11
11
  SelectTrigger,
12
12
  SelectValue,
13
13
  } from "@/components/ui/select";
14
+ import {
15
+ Tooltip,
16
+ TooltipContent,
17
+ TooltipTrigger,
18
+ } from "@/components/ui/tooltip";
14
19
  import { cn } from "@/lib/utils";
15
20
 
16
21
  export type ExportFormat = "png" | "jpg" | "svg" | "pdf" | "webp";
@@ -399,16 +404,29 @@ function ExportRow({
399
404
  aria-label={labels.suffix}
400
405
  />
401
406
 
402
- {/* Remove row button matches the design editor's × on each export entry */}
403
- <button
404
- type="button"
405
- aria-label={labels.removeExport}
406
- disabled={isDisabled || !canRemove}
407
- className="flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
408
- onClick={() => onRemoveRow(row.id)}
409
- >
410
- <IconX className="size-3" />
411
- </button>
407
+ {/* Remove row — only rendered once there is more than one export to
408
+ remove. A permanently disabled × cannot explain itself: `disabled`
409
+ suppresses pointer events, so it carries no tooltip either, and the
410
+ single-row default left an unexplained dead icon in the row. The span
411
+ keeps hover alive while a run is in flight and the button is disabled. */}
412
+ {canRemove ? (
413
+ <Tooltip>
414
+ <TooltipTrigger asChild>
415
+ <span className="flex shrink-0">
416
+ <button
417
+ type="button"
418
+ aria-label={labels.removeExport}
419
+ disabled={isDisabled}
420
+ className="flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
421
+ onClick={() => onRemoveRow(row.id)}
422
+ >
423
+ <IconX className="size-3" />
424
+ </button>
425
+ </span>
426
+ </TooltipTrigger>
427
+ <TooltipContent side="left">{labels.removeExport}</TooltipContent>
428
+ </Tooltip>
429
+ ) : null}
412
430
  </div>
413
431
  );
414
432
  }
@@ -109,7 +109,8 @@ export function IconPaddingVertical({ className }: IconProps) {
109
109
  );
110
110
  }
111
111
 
112
- // Flow horizontal: two filled boxes side-by-side inside a wide frame
112
+ // Flow horizontal: item + arrow. Keep the arrow two symmetric boxes read as
113
+ // "this axis" but not "this direction", forcing a tooltip hover to disambiguate.
113
114
  export function IconFlowHorizontal({ className }: IconProps) {
114
115
  return (
115
116
  <svg
@@ -124,31 +125,24 @@ export function IconFlowHorizontal({ className }: IconProps) {
124
125
  >
125
126
  {/* Outer container — wide landscape frame */}
126
127
  <rect x="2" y="6" width="20" height="12" rx="2" strokeWidth={1.5} />
127
- {/* Left item */}
128
+ {/* First item */}
128
129
  <rect
129
- x="5"
130
- y="9"
131
- width="5"
132
- height="6"
133
- rx="1"
134
- fill="currentColor"
135
- stroke="none"
136
- />
137
- {/* Right item */}
138
- <rect
139
- x="14"
130
+ x="4.5"
140
131
  y="9"
141
- width="5"
132
+ width="4"
142
133
  height="6"
143
134
  rx="1"
144
135
  fill="currentColor"
145
136
  stroke="none"
146
137
  />
138
+ {/* Direction arrow — left to right */}
139
+ <path d="M11 12H18.5" />
140
+ <path d="M16.3 9.8 18.5 12 16.3 14.2" />
147
141
  </svg>
148
142
  );
149
143
  }
150
144
 
151
- // Flow vertical: two filled boxes stacked inside a tall frame
145
+ // Flow vertical: an item plus a downward arrow see IconFlowHorizontal.
152
146
  export function IconFlowVertical({ className }: IconProps) {
153
147
  return (
154
148
  <svg
@@ -163,26 +157,19 @@ export function IconFlowVertical({ className }: IconProps) {
163
157
  >
164
158
  {/* Outer container — tall portrait frame */}
165
159
  <rect x="6" y="2" width="12" height="20" rx="2" strokeWidth={1.5} />
166
- {/* Top item */}
160
+ {/* First item */}
167
161
  <rect
168
162
  x="9"
169
- y="5"
163
+ y="4.5"
170
164
  width="6"
171
- height="5"
172
- rx="1"
173
- fill="currentColor"
174
- stroke="none"
175
- />
176
- {/* Bottom item */}
177
- <rect
178
- x="9"
179
- y="14"
180
- width="6"
181
- height="5"
165
+ height="4"
182
166
  rx="1"
183
167
  fill="currentColor"
184
168
  stroke="none"
185
169
  />
170
+ {/* Direction arrow — top to bottom */}
171
+ <path d="M12 11V18.5" />
172
+ <path d="M9.8 16.3 12 18.5 14.2 16.3" />
186
173
  </svg>
187
174
  );
188
175
  }
@@ -78,7 +78,8 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
78
78
  shortcut({
79
79
  id: "frame-tool",
80
80
  category: "tools",
81
- bindings: ["f"],
81
+ // Figma binds both to the frame tool; A is the one long-time users reach for.
82
+ bindings: ["f", "a"],
82
83
  labelKey: "designEditor.keyboardShortcuts.commands.frameTool",
83
84
  handler: "onFrameTool",
84
85
  }),
@@ -171,7 +172,7 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
171
172
  shortcut({
172
173
  id: "zoom-reset",
173
174
  category: "zoom",
174
- bindings: ["$mod+0"],
175
+ bindings: ["shift+0", "$mod+0"],
175
176
  labelKey: "designEditor.keyboardShortcuts.commands.zoomReset",
176
177
  handler: "onZoomReset",
177
178
  }),
@@ -502,7 +503,7 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
502
503
  shortcut({
503
504
  id: "ungroup",
504
505
  category: "layout",
505
- bindings: ["$mod+backspace"],
506
+ bindings: ["$mod+shift+g", "$mod+backspace"],
506
507
  labelKey: "designEditor.keyboardShortcuts.commands.ungroup",
507
508
  handler: "onUngroup",
508
509
  }),
@@ -7,10 +7,6 @@ const CHROME_OPACITY_TRANSITION = "opacity 150ms ease-out";
7
7
  const CHROME_BORDER_SETTLE_TRANSITION = `inset ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
8
8
  const SELECTION_BOX_SETTLE_TRANSITION = `border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
9
9
  const CHROME_HANDLE_SETTLE_TRANSITION = `width ${CHROME_SETTLE_MS}ms ease-out, height ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, top ${CHROME_SETTLE_MS}ms ease-out, bottom ${CHROME_SETTLE_MS}ms ease-out, left ${CHROME_SETTLE_MS}ms ease-out, right ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
10
- // Frame header (name + "Interact" button) is counter-scaled via
11
- // transform to stay a fixed screen size; ease that scale on zoom-settle. opacity
12
- // is included so the button's hover-fade (transition-opacity) keeps working.
13
- const CHROME_LABEL_SETTLE_TRANSITION = `transform ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
14
10
 
15
11
  export function getChromeBorderTransition(chromeSettling: boolean) {
16
12
  return chromeSettling
@@ -28,8 +24,16 @@ export function getChromeHandleTransition(chromeSettling: boolean) {
28
24
  : CHROME_OPACITY_TRANSITION;
29
25
  }
30
26
 
31
- export function getChromeLabelTransition(chromeSettling: boolean) {
32
- return chromeSettling
33
- ? CHROME_LABEL_SETTLE_TRANSITION
34
- : CHROME_OPACITY_TRANSITION;
27
+ /**
28
+ * Frame header (name + "Interact" button). It is counter-scaled by transform to
29
+ * stay a fixed screen size, and that counter-scale now tracks the live zoom
30
+ * every gesture frame through the `--an-chrome-scale` custom property — so
31
+ * unlike the border/handle/selection chrome above there is no settle-time
32
+ * snap-back to ease, and no `chromeSettling` branch. Transitioning `transform`
33
+ * here would actively lag the label behind the canvas whenever a new zoom tick
34
+ * lands inside the settle window. Opacity is still eased so the button's
35
+ * hover-fade keeps working.
36
+ */
37
+ export function getChromeLabelTransition() {
38
+ return CHROME_OPACITY_TRANSITION;
35
39
  }
@@ -39,13 +39,30 @@ export const OVERVIEW_CULLING_ENABLED = true;
39
39
  * recomputes. */
40
40
  export const OVERVIEW_CULLING_OVERSCAN_FACTOR = 1.5;
41
41
 
42
- /** Maximum number of evictable overview browsing contexts kept mounted at
43
- * once. One screen costs its primary preview plus every breakpoint preview.
42
+ /** Maximum number of evictable overview SCREENS kept mounted at once.
43
+ *
44
+ * The pool is budgeted in screens rather than iframes because a screen is the
45
+ * unit the user perceives: a board where every screen carries breakpoint
46
+ * previews costs several browsing contexts per screen, and charging those
47
+ * against one flat iframe budget shrank the pool to a third of its intended
48
+ * size. Past that point every committed pan/zoom re-ranked the in-viewport set
49
+ * by distance and admitted a different subset, so screens were destroyed and
50
+ * re-created — a document reload, i.e. a visible flash — on ordinary camera
51
+ * movement. Generating a variant set installs a design-wide breakpoint set, so
52
+ * this regressed the moment variants existed.
53
+ *
44
54
  * Interaction-protected screens are the sole safety exception: if the user
45
55
  * explicitly selects more than this budget, preserving their live editor
46
56
  * state wins until that interaction ends, after which the pool contracts on
47
57
  * the next culling pass. */
48
- export const OVERVIEW_LIVE_IFRAME_BUDGET = 32;
58
+ export const OVERVIEW_LIVE_SCREEN_BUDGET = 32;
59
+
60
+ /** Hard ceiling on total mounted browsing contexts, independent of the screen
61
+ * budget above. This is the memory backstop the original budget existed for —
62
+ * a long tour of a 100+ screen board still cannot accumulate contexts without
63
+ * bound — set high enough that a normal breakpoint-bearing board is limited by
64
+ * OVERVIEW_LIVE_SCREEN_BUDGET instead of by this. */
65
+ export const OVERVIEW_LIVE_IFRAME_CEILING = 96;
49
66
 
50
67
  export type ScreenCullTier =
51
68
  /** Full content (iframe/DesignCanvas) is mounted and rendered normally. */
@@ -127,7 +144,8 @@ export function computeBoundedScreenCullState({
127
144
  everVisibleScreenIds,
128
145
  lastVisibleEpochByScreenId,
129
146
  accessEpoch,
130
- liveIframeBudget = OVERVIEW_LIVE_IFRAME_BUDGET,
147
+ liveScreenBudget = OVERVIEW_LIVE_SCREEN_BUDGET,
148
+ liveIframeBudget = OVERVIEW_LIVE_IFRAME_CEILING,
131
149
  }: {
132
150
  candidates: readonly ScreenCullCandidate[];
133
151
  viewport: OverscannedViewportBounds | null;
@@ -136,6 +154,7 @@ export function computeBoundedScreenCullState({
136
154
  everVisibleScreenIds: ReadonlySet<string>;
137
155
  lastVisibleEpochByScreenId: ReadonlyMap<string, number>;
138
156
  accessEpoch: number;
157
+ liveScreenBudget?: number;
139
158
  liveIframeBudget?: number;
140
159
  }): BoundedScreenCullState {
141
160
  if (!OVERVIEW_CULLING_ENABLED) {
@@ -187,19 +206,25 @@ export function computeBoundedScreenCullState({
187
206
  if (protectedScreenIds.has(candidate.id)) add(candidate);
188
207
  }
189
208
  // Protected screens can temporarily exceed the normal pool. In that case
190
- // no evictable iframe is admitted until the interaction set shrinks again.
191
- const effectiveBudget = Math.max(
209
+ // no evictable screen is admitted until the interaction set shrinks again.
210
+ const effectiveScreenBudget = Math.max(
211
+ Math.max(0, Math.floor(liveScreenBudget)),
212
+ nextLive.size,
213
+ );
214
+ const effectiveIframeBudget = Math.max(
192
215
  Math.max(0, Math.floor(liveIframeBudget)),
193
216
  mountedIframeCount,
194
217
  );
195
218
  const tryAddWithinBudget = (candidate: ScreenCullCandidate) => {
196
219
  if (nextLive.has(candidate.id)) return;
220
+ if (nextLive.size + 1 > effectiveScreenBudget) return;
197
221
  if (
198
- mountedIframeCount + normalizedIframeCount(candidate) <=
199
- effectiveBudget
222
+ mountedIframeCount + normalizedIframeCount(candidate) >
223
+ effectiveIframeBudget
200
224
  ) {
201
- add(candidate);
225
+ return;
202
226
  }
227
+ add(candidate);
203
228
  };
204
229
 
205
230
  const visibleCandidates = candidates
@@ -1,3 +1,9 @@
1
+ import {
2
+ BREAKPOINT_FRAME_GAP,
3
+ getResponsiveGroupWidth,
4
+ visibleBreakpointWidths,
5
+ } from "@shared/responsive-frame-layout";
6
+
1
7
  import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "../types";
2
8
  import { SURFACE_PADDING } from "./overview-layout";
3
9
  import type { FrameGeometry, FrameGeometryById, Point } from "./types";
@@ -5,7 +11,8 @@ import type { FrameGeometry, FrameGeometryById, Point } from "./types";
5
11
  const SCREEN_WIDTH = 320;
6
12
  const SCREEN_GAP = 56;
7
13
  const FRAME_LABEL_HEIGHT = 28;
8
- const BREAKPOINT_FRAME_GAP = 24;
14
+
15
+ export { BREAKPOINT_FRAME_GAP, visibleBreakpointWidths };
9
16
 
10
17
  export interface BoundsRect {
11
18
  left: number;
@@ -26,26 +33,6 @@ type ResponsiveLayoutScreen = {
26
33
  layoutGroupId?: string;
27
34
  };
28
35
 
29
- /** Drops any breakpoint whose width equals the primary frame's own width — a
30
- * redundant duplicate of the base — also cleaning up designs authored before
31
- * the default set excluded the primary width. */
32
- export function visibleBreakpointWidths(
33
- breakpointWidths: readonly number[] | undefined,
34
- primaryWidthPx: number | undefined,
35
- ): number[] {
36
- const deduped = Array.from(
37
- new Set(
38
- (breakpointWidths ?? []).filter(
39
- (width) => Number.isFinite(width) && width > 0,
40
- ),
41
- ),
42
- );
43
- if (primaryWidthPx === undefined || !Number.isFinite(primaryWidthPx)) {
44
- return deduped;
45
- }
46
- return deduped.filter((width) => Math.abs(width - primaryWidthPx) > 1);
47
- }
48
-
49
36
  /** Minimum height for a frame of the given width — one device viewport tall
50
37
  * before it grows to content. Keep in sync with deviceViewportHeight in
51
38
  * content-size-report.ts. */
@@ -85,12 +72,11 @@ export function getResponsiveScreenGroupSize(
85
72
  : (width * sourceHeight) / sourceWidth;
86
73
  };
87
74
  return {
88
- width:
89
- baseWidth +
90
- breakpoints.reduce(
91
- (total, width) => total + BREAKPOINT_FRAME_GAP + width * scale,
92
- 0,
93
- ),
75
+ width: getResponsiveGroupWidth({
76
+ primaryWidth: baseWidth,
77
+ scale,
78
+ visibleWidths: breakpoints,
79
+ }),
94
80
  height: Math.max(
95
81
  baseHeight,
96
82
  ...breakpoints.map((width) => breakpointNaturalHeight(width) * scale),
@@ -225,7 +225,11 @@ export interface MultiScreenCanvasProps {
225
225
  * Called when the user clicks the + affordance on a screen's breakpoint
226
226
  * row to add the next standard breakpoint width (390 / 768 / 1280).
227
227
  */
228
- onAddBreakpoint?: (screenId: string, widthPx: number) => void;
228
+ /** Adds a breakpoint width to the DESIGN. A design has one active breakpoint
229
+ * set in v1, so this deliberately takes no screen id: every screen renders
230
+ * the same widths, and a per-screen parameter here only ever promised
231
+ * scoping the action cannot deliver. */
232
+ onAddBreakpoint?: (widthPx: number) => void;
229
233
  /**
230
234
  * Called when the user clicks a breakpoint frame header to make it the
231
235
  * active edit scope.
@@ -838,6 +842,9 @@ export type PendingWheelGesture =
838
842
  | {
839
843
  mode: "zoom";
840
844
  deltaY: number;
845
+ /** Trackpad pinch rather than a discrete mouse notch — the two use
846
+ * different sensitivities, so accumulation must not mix them. */
847
+ pinch: boolean;
841
848
  cursor: Point;
842
849
  clientX: number;
843
850
  clientY: number;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Wheel/pinch → zoom-factor conversion.
3
+ *
4
+ * The two input devices are not comparable and must not share a sensitivity.
5
+ * A mouse wheel emits coarse, discrete notches (one detent ≈ 100px in Chrome's
6
+ * pixel delta mode); a trackpad pinch emits fine, often fractional deltas at a
7
+ * far higher rate. Feeding both through one `exp(-deltaY * 0.01)` meant a
8
+ * single mouse notch multiplied zoom by e¹ ≈ 2.72× — which is what made canvas
9
+ * zoom feel uncontrollable next to Figma's ~1.1× per notch.
10
+ *
11
+ * The clamp applies to the resulting FACTOR, not to the accumulated delta.
12
+ * Clamping the delta made the step frame-rate dependent: deltas accumulate
13
+ * between animation frames, so a dropped frame silently doubled the distance
14
+ * travelled. Clamping the factor means a dropped frame costs smoothness, never
15
+ * distance.
16
+ */
17
+
18
+ /** One mouse-wheel detent in `deltaMode === 0` pixels (Chrome/Safari). */
19
+ export const MOUSE_WHEEL_NOTCH_PX = 100;
20
+ /** Zoom multiplier for one full mouse-wheel notch, matching Figma's feel. */
21
+ export const ZOOM_STEP_PER_NOTCH = 1.1;
22
+ /** Multiplier exponent per pixel of trackpad pinch delta. */
23
+ export const PINCH_ZOOM_SENSITIVITY = 0.0075;
24
+ /** Ceiling on a single frame's zoom change, in either direction. */
25
+ export const MAX_ZOOM_FACTOR_PER_FRAME = 1.6;
26
+
27
+ /**
28
+ * Whether a ctrl/meta-modified wheel delta came from a trackpad pinch rather
29
+ * than a mouse wheel. Browsers report macOS pinch as a synthetic ctrl+wheel
30
+ * with small, frequently fractional pixel deltas, while a real wheel detent is
31
+ * a whole number near the notch size. Only meaningful for `deltaMode === 0`;
32
+ * line/page modes are always discrete wheels and callers must not ask.
33
+ */
34
+ export function isPinchZoomDelta(deltaY: number): boolean {
35
+ if (!Number.isFinite(deltaY)) return false;
36
+ return (
37
+ !Number.isInteger(deltaY) || Math.abs(deltaY) < MOUSE_WHEEL_NOTCH_PX / 2
38
+ );
39
+ }
40
+
41
+ /** Unclamped zoom multiplier for a wheel/pinch delta. Negative delta (scroll
42
+ * up / pinch open) zooms in, matching the browser's wheel convention. */
43
+ export function zoomFactorForWheelDelta(
44
+ deltaY: number,
45
+ pinch: boolean,
46
+ ): number {
47
+ if (!Number.isFinite(deltaY) || deltaY === 0) return 1;
48
+ return pinch
49
+ ? Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY)
50
+ : Math.pow(ZOOM_STEP_PER_NOTCH, -deltaY / MOUSE_WHEEL_NOTCH_PX);
51
+ }
52
+
53
+ /** Bounds one frame's zoom change symmetrically in multiplicative space, so
54
+ * zooming in and back out by the same clamped amount is a round trip. */
55
+ export function clampZoomFactor(factor: number): number {
56
+ if (!Number.isFinite(factor) || factor <= 0) return 1;
57
+ return Math.min(
58
+ MAX_ZOOM_FACTOR_PER_FRAME,
59
+ Math.max(1 / MAX_ZOOM_FACTOR_PER_FRAME, factor),
60
+ );
61
+ }
62
+
63
+ /** The zoom multiplier to apply for one flushed gesture frame. */
64
+ export function resolveZoomFactor(deltaY: number, pinch: boolean): number {
65
+ return clampZoomFactor(zoomFactorForWheelDelta(deltaY, pinch));
66
+ }
67
+
68
+ /** Anchor for an externally driven zoom change. Only hold the frame centre when
69
+ * it is on screen: a frame taller than the viewport has its centre off-screen,
70
+ * and holding an invisible point fixed pushes the visible part out of view. */
71
+ export function resolveExternalZoomAnchor(args: {
72
+ frameCenter: { x: number; y: number } | null;
73
+ surfaceSize: { width: number; height: number };
74
+ }): { x: number; y: number } {
75
+ const { frameCenter, surfaceSize } = args;
76
+ const viewportCenter = {
77
+ x: surfaceSize.width / 2,
78
+ y: surfaceSize.height / 2,
79
+ };
80
+ if (!frameCenter) return viewportCenter;
81
+ if (
82
+ !Number.isFinite(frameCenter.x) ||
83
+ !Number.isFinite(frameCenter.y) ||
84
+ surfaceSize.width <= 0 ||
85
+ surfaceSize.height <= 0
86
+ ) {
87
+ return viewportCenter;
88
+ }
89
+ const onScreen =
90
+ frameCenter.x >= 0 &&
91
+ frameCenter.x <= surfaceSize.width &&
92
+ frameCenter.y >= 0 &&
93
+ frameCenter.y <= surfaceSize.height;
94
+ return onScreen ? frameCenter : viewportCenter;
95
+ }