@1agh/maude 0.58.3 → 0.60.0

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 (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -26,10 +26,14 @@
26
26
  # [--changed-only] (default off — screenshot only canvases changed
27
27
  # since the last smoke run; escalates to the full
28
28
  # set when dev-server / canvas-lib / templates changed)
29
+ # [--perf] (also benchmark the heaviest canvas after the sweep
30
+ # and append the frame-time / render-count delta to
31
+ # the report — see perf.sh)
29
32
  #
30
33
  # Reads:
31
34
  # $DESIGN_ROOT/_server.json (must exist — caller runs server-up.sh first)
32
- # $DESIGN_ROOT/ui/*.tsx (canvases)
35
+ # $DESIGN_ROOT/ui/**/*.tsx (canvases, RECURSIVELY — `--pages` imports
36
+ # land in ui/<folder>/ and were being missed)
33
37
  # $DESIGN_ROOT/system/*/preview/*.tsx (specimens, when --include-system 1)
34
38
  #
35
39
  # Writes:
@@ -52,6 +56,7 @@ INCLUDE_SYSTEM=1
52
56
  TIMEOUT=8
53
57
  ENGINE="auto"
54
58
  CHANGED_ONLY=0
59
+ PERF=0
55
60
 
56
61
  while [ $# -gt 0 ]; do
57
62
  case "$1" in
@@ -61,6 +66,7 @@ while [ $# -gt 0 ]; do
61
66
  --timeout) TIMEOUT="$2"; shift 2 ;;
62
67
  --engine) ENGINE="$2"; shift 2 ;;
63
68
  --changed-only) CHANGED_ONLY=1; shift ;;
69
+ --perf) PERF=1; shift ;;
64
70
  --help|-h)
65
71
  sed -n '2,30p' "$0" | sed 's/^# \?//'
66
72
  exit 0
@@ -111,13 +117,24 @@ fi
111
117
  echo "→ smoke engine: $ENGINE | port: $PORT | out: $OUT_DIR" >&2
112
118
 
113
119
  # ---------- collect canvases ----------
120
+ #
121
+ # RECURSIVE, and that is a fix, not a preference. This was `-maxdepth 1` and the
122
+ # consequence was measured on the first live Figma migration: `--pages` always
123
+ # writes to `ui/<folder>/`, so EVERY canvas the primary import route produces was
124
+ # invisible here. The run reported "52/52 canvases rendered styled" having never
125
+ # looked at the 6 imported pages — green, true, and worthless. The import-graph
126
+ # lint below already walks `ui/` recursively, which is exactly why the mismatch
127
+ # went unnoticed: the lint covered the imports, the screenshots did not.
128
+ #
129
+ # A `_`-prefixed DIRECTORY is skipped as well as a `_`-prefixed file, so runtime
130
+ # state (`_history/`, `_draw/`, `_smoke/` output itself) never enters the sweep —
131
+ # without that, recursing would screenshot our own previous reports.
114
132
  CANVASES=""
115
133
  if [ -d "$DESIGN_ROOT/ui" ]; then
116
134
  while IFS= read -r f; do
117
- base=$(basename "$f")
118
- case "$base" in _*) continue ;; esac
135
+ case "$f" in *'/_'*) continue ;; esac
119
136
  CANVASES="$CANVASES$f"$'\n'
120
- done < <(find "$DESIGN_ROOT/ui" -maxdepth 1 -type f -name '*.tsx' 2>/dev/null | sort)
137
+ done < <(find "$DESIGN_ROOT/ui" -type f -name '*.tsx' 2>/dev/null | sort)
121
138
  fi
122
139
 
123
140
  if [ "$INCLUDE_SYSTEM" = "1" ] && [ -d "$DESIGN_ROOT/system" ]; then
@@ -130,7 +147,7 @@ fi
130
147
 
131
148
  CANVASES=$(printf '%s' "$CANVASES" | sed '/^$/d')
132
149
  COUNT=$(printf '%s' "$CANVASES" | grep -c .)
133
- [ "$COUNT" -gt 0 ] || { echo "smoke.sh: no canvases found (ui/*.tsx or system/*/preview/*.tsx)" >&2; exit 1; }
150
+ [ "$COUNT" -gt 0 ] || { echo "smoke.sh: no canvases found (ui/**/*.tsx or system/*/preview/*.tsx)" >&2; exit 1; }
134
151
  echo "→ found $COUNT canvases" >&2
135
152
 
136
153
  # ---------- --changed-only incremental filter (Phase C / DDR-061) ----------
@@ -525,6 +542,33 @@ TOTAL_FAIL=$((FAILED + LINT_FAILED))
525
542
  fi
526
543
  } >> "$MD"
527
544
 
545
+ # ---------- optional perf pass (--perf) ----------
546
+ # Smoke answers "does it still render"; this answers "is it still fast", against
547
+ # the same canvases, in the same run. The heaviest canvas (most artboards) is the
548
+ # one worth timing — a 1-artboard specimen has nothing to say about pan/zoom.
549
+ if [ "$PERF" = "1" ]; then
550
+ HEAVIEST=""
551
+ HEAVIEST_N=0
552
+ while IFS= read -r f; do
553
+ [ -n "$f" ] || continue
554
+ n=$(grep -c 'DCArtboard' "$f" 2>/dev/null || echo 0)
555
+ if [ "$n" -gt "$HEAVIEST_N" ]; then HEAVIEST_N="$n"; HEAVIEST="$f"; fi
556
+ done <<< "$CANVASES"
557
+ if [ -n "$HEAVIEST" ]; then
558
+ REL_PERF="${HEAVIEST#"$REPO"/}"
559
+ echo "→ perf: benchmarking heaviest canvas ($HEAVIEST_N artboards): $REL_PERF" >&2
560
+ {
561
+ echo ""
562
+ echo "## Perf"
563
+ echo ""
564
+ echo '```'
565
+ bash "$(dirname "${BASH_SOURCE[0]}")/perf.sh" --root "$REPO" --canvas "$REL_PERF" 2>&1 || \
566
+ echo "(perf run failed — see stderr; smoke result above is unaffected)"
567
+ echo '```'
568
+ } >> "$MD"
569
+ fi
570
+ fi
571
+
528
572
  echo "→ report: $MD" >&2
529
573
  echo "→ tsv: $TSV" >&2
530
574
 
@@ -233,6 +233,37 @@ const IS_WEBKIT =
233
233
  (/apple/i.test(navigator.vendor || '') ||
234
234
  (typeof window !== 'undefined' && 'GestureEvent' in window));
235
235
 
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+ // Render instrumentation (feature-canvas-render-performance).
238
+ //
239
+ // The whole point of this work is the invariant "a pan/zoom gesture triggers no
240
+ // React render", and an invariant nothing measures is just an intention. The
241
+ // counter is OFF unless a probe installs `window.__dcPerf` first, so the
242
+ // production cost is one property read per render — far below the reconciliation
243
+ // it exists to count. `maude design perf` installs the object, drives a scripted
244
+ // gesture, and reports the count; an uninstrumented build reports null rather
245
+ // than 0, because "not measured" must never read as "measured zero".
246
+ type DcPerfCounters = {
247
+ artboardRenders: number;
248
+ annotationRenders: number;
249
+ instrumented?: boolean;
250
+ };
251
+
252
+ export function countRender(key: keyof DcPerfCounters): void {
253
+ if (typeof window === 'undefined') return;
254
+ const perf = (window as unknown as { __dcPerf?: DcPerfCounters }).__dcPerf;
255
+ if (!perf) return;
256
+ // Never let instrumentation break rendering. The page owns this object, so a
257
+ // canvas could hand back a frozen one — assigning to it throws in strict-mode
258
+ // ESM, and that would take out every DCArtboard render with it.
259
+ try {
260
+ perf.instrumented = true;
261
+ perf[key] = ((perf[key] as number) || 0) + 1;
262
+ } catch {
263
+ /* frozen or exotic object — measuring is optional, rendering is not */
264
+ }
265
+ }
266
+
236
267
  // ─────────────────────────────────────────────────────────────────────────────
237
268
  // Engine CSS (Phase 4) — injected once per iframe inside DesignCanvas's mount.
238
269
  // The visual chrome of `.dc-artboard` (borders, label strip, SKU type) still
@@ -917,6 +948,84 @@ export function getLiveViewport(): ViewportState | null {
917
948
  return liveViewport ? { ...liveViewport } : null;
918
949
  }
919
950
 
951
+ /**
952
+ * Viewport for the few consumers that must track a gesture AS IT HAPPENS —
953
+ * the minimap indicator and the zoom % readout. Everything else reads the
954
+ * published (settle-cadence) viewport and stays still while the user moves.
955
+ *
956
+ * Polls the live mirror on rAF, but only while a gesture is in flight, and
957
+ * only re-renders when the value actually changed and at most every
958
+ * `minIntervalMs`. That keeps these two small components on the same ~20 Hz
959
+ * cadence they had when EVERY component was re-rendering at 20 Hz — the
960
+ * difference is that now it is two of them instead of all 128 artboards.
961
+ */
962
+ // ONE rAF ticker for every live-viewport subscriber. Each mounted consumer
963
+ // running its own loop would multiply with the UI: a board with N sections
964
+ // renders N label chips, and N independent rAF callbacks per frame is a cost
965
+ // that grows with content while measuring the same single value.
966
+ const liveVpSubs = new Set<(v: ViewportState) => void>();
967
+ let liveVpRaf = 0;
968
+ let liveVpLastAt = 0;
969
+ let liveVpLastKey = '';
970
+
971
+ function liveVpTick(t: number): void {
972
+ liveVpRaf = requestAnimationFrame(liveVpTick);
973
+ if (t - liveVpLastAt < PUBLISH_MS) return;
974
+ liveVpLastAt = t;
975
+ const cur = liveViewport;
976
+ if (!cur) return;
977
+ const key = `${cur.x}|${cur.y}|${cur.zoom}`;
978
+ if (key === liveVpLastKey) return;
979
+ liveVpLastKey = key;
980
+ for (const fn of liveVpSubs) fn({ ...cur });
981
+ }
982
+
983
+ function subscribeLiveViewport(fn: (v: ViewportState) => void): () => void {
984
+ liveVpSubs.add(fn);
985
+ if (liveVpRaf === 0 && typeof requestAnimationFrame === 'function') {
986
+ liveVpLastKey = '';
987
+ liveVpRaf = requestAnimationFrame(liveVpTick);
988
+ }
989
+ return () => {
990
+ liveVpSubs.delete(fn);
991
+ if (liveVpSubs.size === 0 && liveVpRaf !== 0) {
992
+ cancelAnimationFrame(liveVpRaf);
993
+ liveVpRaf = 0;
994
+ }
995
+ };
996
+ }
997
+
998
+ /**
999
+ * Viewport for the few consumers that must track a gesture AS IT HAPPENS —
1000
+ * the minimap indicator, the zoom % readout, and the counter-scaled annotation
1001
+ * chrome that has to hold a constant screen size while the world scales.
1002
+ * Everything else reads the published (settle-cadence) viewport and stays still
1003
+ * while the user moves.
1004
+ *
1005
+ * Subscribes to the shared ticker only while a gesture is in flight, and only
1006
+ * re-renders when the value actually changed — the same ~20 Hz cadence these
1007
+ * components had back when EVERY component re-rendered at 20 Hz. The difference
1008
+ * is that it is now a handful of them instead of all 128 artboards.
1009
+ */
1010
+ export function useLiveViewport(): ViewportState {
1011
+ const controller = useViewportControllerContext();
1012
+ const published = controller?.viewport;
1013
+ const interacting = !!controller?.isInteracting;
1014
+ const [live, setLive] = useState<ViewportState | null>(null);
1015
+
1016
+ useEffect(() => {
1017
+ if (!interacting) {
1018
+ // Settled: the published value is authoritative again. Dropping the local
1019
+ // copy avoids rendering a stale mid-gesture frame forever.
1020
+ setLive(null);
1021
+ return;
1022
+ }
1023
+ return subscribeLiveViewport(setLive);
1024
+ }, [interacting]);
1025
+
1026
+ return live ?? published ?? { x: 0, y: 0, zoom: 1 };
1027
+ }
1028
+
920
1029
  export function useViewportController(opts: ViewportControllerOptions): ViewportControllerHandle {
921
1030
  const {
922
1031
  hostRef,
@@ -999,6 +1108,14 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
999
1108
  el.style.transform = `translate(${v.x / z}px, ${v.y / z}px)`;
1000
1109
  el.style.zoom = String(z);
1001
1110
  }
1111
+ // Live zoom for counter-scaled chrome (section labels, connector dots,
1112
+ // selection bboxes — anything that must stay a constant number of SCREEN px
1113
+ // while the world scales). It has to be written per frame, right here: the
1114
+ // published viewport is settle-only now, so chrome sized from React state
1115
+ // would scale with the world through a pinch and snap back at the end —
1116
+ // reintroducing exactly the fidelity-pop that was removed once already
1117
+ // (RCA addendum: the LOD toggle was reverted for visible flicker).
1118
+ el.style.setProperty('--dc-zoom', String(z));
1002
1119
  el.style.visibility = 'visible';
1003
1120
  }, []);
1004
1121
 
@@ -1047,11 +1164,16 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1047
1164
  if (vpRef.current.zoom !== zoomAtInteractStartRef.current) {
1048
1165
  writeTransform(vpRef.current, { crisp: true });
1049
1166
  }
1167
+ // The gesture is over — NOW publish. Everything that reads the published
1168
+ // viewport (LOD banding, collab awareness, the active-artboard pick) is
1169
+ // settle-cadence by design; deferring the publish to here is what keeps
1170
+ // the whole React tree still while the user is actually moving.
1171
+ setViewportPublished({ ...vpRef.current });
1050
1172
  }, 220);
1051
1173
  }, [writeTransform]);
1052
1174
 
1053
1175
  const applyViewport = useCallback(
1054
- (next: ViewportState) => {
1176
+ (next: ViewportState, opts?: { defer?: boolean }) => {
1055
1177
  const clamped: ViewportState = {
1056
1178
  x: Number.isFinite(next.x) ? next.x : 0,
1057
1179
  y: Number.isFinite(next.y) ? next.y : 0,
@@ -1060,7 +1182,17 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1060
1182
  vpRef.current = clamped;
1061
1183
  liveViewport = clamped; // RC3 — survives the soft-reload remount
1062
1184
  writeTransform(clamped);
1063
- schedulePublish();
1185
+ // `defer` is what makes a gesture render-free. A wheel/pinch/drag burst
1186
+ // writes the transform imperatively every frame (above) and publishes to
1187
+ // React only once the motion settles; a programmatic move (fit, reset,
1188
+ // jumpTo, animateTo) publishes immediately, because nothing follows it to
1189
+ // trigger the settle and consumers would sit on a stale value.
1190
+ //
1191
+ // Measured reason: at 20 Hz publishing, one pan+zoom over a 128-artboard
1192
+ // canvas re-rendered DCArtboard 6272 times (128 boards x ~49 publishes),
1193
+ // and every published value was one the drag/hit-test paths already read
1194
+ // live from a ref. See `maude design perf` and the plan's findings table.
1195
+ if (!opts?.defer) schedulePublish();
1064
1196
  scheduleSettle();
1065
1197
  markInteracting();
1066
1198
  },
@@ -1074,7 +1206,7 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1074
1206
  const panBy = useCallback(
1075
1207
  (dx: number, dy: number) => {
1076
1208
  const v = vpRef.current;
1077
- applyViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom });
1209
+ applyViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom }, { defer: true });
1078
1210
  },
1079
1211
  [applyViewport]
1080
1212
  );
@@ -1091,7 +1223,7 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1091
1223
  y: cy - wy * newZoom,
1092
1224
  zoom: newZoom,
1093
1225
  };
1094
- applyViewport(next);
1226
+ applyViewport(next, { defer: true });
1095
1227
  },
1096
1228
  [applyViewport]
1097
1229
  );
@@ -2062,6 +2194,7 @@ export function DCArtboard({
2062
2194
  print?: ArtboardPrintProp;
2063
2195
  children: ReactNode;
2064
2196
  }) {
2197
+ countRender('artboardRenders');
2065
2198
  const ctx = useWorldContext();
2066
2199
  const _controller = useViewportControllerContext();
2067
2200
  const toolMode = useToolModeOptional();
@@ -2105,6 +2238,8 @@ export function DCArtboard({
2105
2238
  rectFor: (rid) => (ctx ? ctx.rectFor(rid) : null),
2106
2239
  allRects: ctx?.artboards ?? [],
2107
2240
  viewport: ctx?.viewport ?? null,
2241
+ // Event-time zoom (publishing is settle-only — see applyViewport's `defer`).
2242
+ liveZoom: () => getLiveViewport()?.zoom ?? ctx?.viewport?.zoom ?? 1,
2108
2243
  // Cloud Phase 25 C2 — a read-only canvas keeps the Move tool for
2109
2244
  // selection, but artboards don't drag (editing is absent, not hidden).
2110
2245
  enabled: !!ctx && (toolMode?.tool ?? 'move') === 'move' && !isReadOnlyCanvas(),
@@ -3308,6 +3443,11 @@ export function DCMiniMap() {
3308
3443
  active: false,
3309
3444
  pointerId: -1,
3310
3445
  });
3446
+ // Live, not published: the minimap indicator is one of the two things that
3447
+ // must follow the gesture frame by frame (the other is the zoom readout).
3448
+ // Everything else in the tree deliberately sits still until settle.
3449
+ // Called before the early returns below — hooks cannot sit behind a branch.
3450
+ const vp = useLiveViewport();
3311
3451
 
3312
3452
  if (!world || !controller) return null;
3313
3453
  // Menubar "View ▸ Minimap" toggle + Presentation Mode (which hides ALL
@@ -3316,7 +3456,6 @@ export function DCMiniMap() {
3316
3456
 
3317
3457
  const geometry = computeMiniMapGeometry(world.artboards, MAP_W, MAP_BODY_H);
3318
3458
  const host = world.hostRef.current;
3319
- const vp = controller.viewport;
3320
3459
 
3321
3460
  // Visible-area rect in world coords, then projected into map coords.
3322
3461
  let vpRect: { left: number; top: number; w: number; h: number } | null = null;
@@ -3434,10 +3573,13 @@ export function DCZoomToolbar() {
3434
3573
  ensureOverlayStyles();
3435
3574
  const controller = useViewportControllerContext();
3436
3575
  const chrome = useChromeVisibility();
3576
+ // Before the early returns — hooks cannot sit behind a branch. Live, because
3577
+ // a zoom readout that only updates on settle reads as a frozen UI.
3578
+ const liveVp = useLiveViewport();
3437
3579
  if (!controller) return null;
3438
3580
  // Menubar "View ▸ Zoom controls" toggle + Presentation Mode.
3439
3581
  if (chrome && (!chrome.zoom || chrome.present)) return null;
3440
- const pct = Math.round(controller.viewport.zoom * 100);
3582
+ const pct = Math.round(liveVp.zoom * 100);
3441
3583
  return (
3442
3584
  <div className="dc-zoom-tb" role="toolbar" aria-label="Zoom">
3443
3585
  <button type="button" onClick={controller.zoomOut} aria-label="Zoom out">