prism branch T-1419/ios-zoom-controls commits 13 files 20 touched focus mermaid rework + fixes

Pre-push review: T-1419 iOS Zoom Controls (native mermaid zoom)

Second pre-push pass after the on-device pivot: the iOS mermaid viewer now uses the WKWebView's native scroll-view zoom. Covers the rework (commit aa21097) and this round's review fixes (726bef4).

At a glance

  • Pivot (decision 14): iOS mermaid uses the WKWebView's native UIScrollView zoom — smooth + crisp; the SwiftUI/JS hybrid was abandoned (it lagged on device).
  • Controls fix: zoom is tracked via the pinch gesture recogniser + optimistic per-command updates because UIScrollView.zoomScale isn't reliably KVO-observable.
  • This round's fixes: VoiceOver announcement reverted to the interpolated form; over-pinch bounce clamp; reuse Color.hexString + ZoomMath; dead-code + doc cleanup.
  • macOS untouched: DiagramView is now macOS-only; SVGWebView and the image path unchanged.
  • Accepted limitation: Reset's at-rest check keys off zoom only (not scroll offset) — observing offset needs the scroll-view delegate, which risks the confirmed-working native zoom.

Verdict

Ready to push

The mermaid zoom is device-verified working across two rounds: round 1 confirmed pinch/pan are smooth and crisp; round 2 caught that the toolbar controls were inert (zoomScale stuck at 100%) because UIScrollView.zoomScale isn't KVO-observable, now fixed. This review round found and fixed two more real bugs (a VoiceOver announcement that could speak a literal %@, and a stale overlay after an over-pinch bounce) plus reuse/cleanup. Builds pass on iOS and macOS with zero warnings, lint clean, unit tests pass. One residual is documented and accepted (Reset's at-rest check ignores scroll offset for tall diagrams).

Review findings

10 raised · 7 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

  • The full-screen image and diagram viewers on iPhone/iPad now have the same zoom controls the Mac already had: zoom in, zoom out, fit, and reset to 100%.
  • A small badge shows the current zoom (for example 150%), you can double-tap to jump between "fit" and "zoomed in", and VoiceOver announces the new zoom level.
  • Pinching and dragging a diagram is now smooth and stays sharp, instead of laggy and blurry as before.

Why It Matters

  • Before, the iPhone/iPad viewers only had pinch-to-zoom and one Reset button, and diagrams stuttered when pinched, making large diagrams hard to read.
  • Now you can zoom in exact steps, snap to a sensible whole-view, and the experience matches the Mac.

Key Concepts

  • Fit: the zoom where the whole thing is visible at once. For photos and short diagrams that is just 100%; for a very tall diagram it is less than 100% so the full height fits.
  • Reset: snap back to exactly 100% with no panning.
  • The image-vs-diagram trick: a photo is a native picture iOS can scale instantly. A diagram is a tiny web page inside a hidden browser; scaling that from the outside is slow and blurry, so we instead ask the browser to zoom itself (the way Safari zooms a page). That is what made diagram zoom smooth.

Architecture

  • One shared UI layer, prism/Views/ZoomControls.swift, holds ZoomConfig (per-viewer limits and step), the pure unit-tested ZoomMath, and the SwiftUI helpers zoomToolbarButtons, ZoomPercentageOverlay, applyZoom, and announceZoom. Both viewers render the same toolbar and overlay from here.
  • Image path: ZoomableImageView (iOS branch) + ImageDetailSheet drive a native SwiftUI Image with .scaleEffect/.offset from ZoomMath.
  • Mermaid path: a new iOS-only DiagramZoomWebView.swift with DiagramZoomWebView (a UIViewRepresentable) and an @Observable DiagramZoomController, hosted by MermaidDiagramSheet.
  • DiagramView is now macOS-only; SVGWebView is unchanged. No macOS behaviour changes.

Patterns

  • One UI, two zoom engines: the image uses pure-SwiftUI transforms (a native layer is cheap on the GPU); the diagram uses the WKWebView's built-in UIScrollView zoom (the engine Safari uses), so pinch/pan are smooth and the SVG vectors re-rasterise crisp.
  • The controller bridges the toolbar to the scroll view: buttons and double-tap call setZoomScale; the live zoom flows back into the observable zoomScale that feeds the shared overlay and disabled-at-limit states.
  • Discrete changes route through applyZoom (animated unless Reduce Motion, plus a VoiceOver announcement); continuous gestures apply with no added animation (Req 7.2).

Trade-offs

  • Native scroll-view zoom replaced the earlier SwiftUI/JS hybrid (decision 14): transforming a WKWebView from the host re-rasterises out-of-process content, which lagged and jumped on device. Native zoom is the platform's purpose-built mechanism and deleted a whole live-layer/commit/guard layer. Cost: the iOS SVGWebView struct and some ZoomMath helpers are now used only by macOS and tests (follow-up to trim).
  • Locale-aware percentage (decision 7) is an intentional divergence from the macOS overlay's raw interpolation, to meet localisation rules.
  • iOS clamps pan so content cannot be lost off-screen (decision 5); macOS leaves it unbounded. New iOS gesture code uses MagnifyGesture to avoid deprecation warnings (decision 12).

Deep dive

  • Image (iOS): displayedSize is the aspect-fit of image.size into a GeometryReader viewport, recomputed on rotation. MagnifyGesture applies a per-frame live-product clamp and re-clamps the offset against the new scale; double-tap (SpatialTapGesture(count: 2)) computes doubleTapTarget, then focalOffset to keep the tapped point fixed, then clampOffset, routed through applyZoom. Disabled state and interactiveDismissDisabled share isAtRestState using approxEqual (eps 1e-3) on the true scale/offset, never the rounded percent (Req 3.4).
  • Mermaid (iOS): setZoom is the single funnel: clamp, scrollView.setZoomScale(_, animated: !reduceMotion), write zoomScale optimistically, then announceZoom. Live tracking has no KVO: UIScrollView.zoomScale is not reliably KVO-observable, so a target on the scroll view's pinchGestureRecognizer mirrors the live scale (re-clamped to defeat over-pinch bounce-back) plus the optimistic per-command write. fitScale = clamp(min(1, bounds.height / contentHeight), minScale, 1), where contentHeight is the SVG painted height measured once via getBoundingClientRect on didFinish; computed on demand so it is correct after rotation. A UITapGestureRecognizer(2) drives double-tap; the nav delegate denies non-.other navigation.

Architecture impact

  • macOS Non-Goal preserved: DiagramView is now #if os(macOS) and keeps its CSS-transform body; SVGWebView is untouched; the iOS mermaid path is a brand-new file touching no macOS code.
  • Shared UI, split engines: the overlay and disabled rules are agnostic to the scale source (committed SwiftUI state vs controller.zoomScale).
  • Dead surface from the rework: the iOS SVGWebView struct, its baked-transform/onMeasure/guard machinery, and ZoomMath.intrinsicSVGSize / displayedSize(.fitWidth) are now exercised only by macOS and tests — flagged as a non-blocking follow-up to trim.

Edge cases

  • Diagram Reset keys off zoom only (documented, decision 14): the sheet passes offset: .zero and isAtRest checks only abs(zoomScale - 1) < 0.01, not contentOffset — becoming the scroll view's delegate to observe offset would risk the device-confirmed native zoom. So for a tall diagram panned at 100%, Reset stays disabled until the user zooms; reset() still re-homes the offset and Fit covers the tall case.
  • Fit never literally disabled on the diagram path (fitDisabled: false); a pre-measure Fit is a no-op to 1.0 — functionally meets Req 1.7, textually diverges.
  • Optimistic vs bounce: an animated setZoomScale may briefly mismatch the overlay; the pinch recogniser re-syncs the clamped value, so it converges (cosmetic only).
  • Inter-viewer double-tap: image is focal, diagram is centre-anchored; both honour "focal is double-tap only, never pinch" (decision 9). The hybrid-era deferred items (Req 7.2 animate, 4.2 overlay-during-pinch, 8.2 rotation re-clamp) are moot/satisfied under native zoom. Device-verified over two rounds (round 2 found and fixed the inert non-KVO controls).

Important changes — detailed

DiagramZoomWebView + DiagramZoomController — native scroll-view zoom

prism/Views/DiagramZoomWebView.swift

Why it matters. The core of the fix. Enables the WKWebView's built-in UIScrollView pinch-zoom/pan (smooth, crisp) and bridges the toolbar buttons/double-tap to setZoomScale, mirroring the live zoom back for the overlay and disabled states.

What to look at. prism/Views/DiagramZoomWebView.swift (new, ~245 lines)

Takeaway. To zoom web content on iOS, use the web view's own scroll-view zoom — never transform the WKWebView from the SwiftUI/JS host (its content is composited out-of-process).
Rationale. Decision 14 — confirmed on device that host-side transforms lag and jump.

Non-KVO zoom tracking (pinch recogniser + optimistic)

prism/Views/DiagramZoomWebView.swift

Why it matters. UIScrollView.zoomScale isn't reliably KVO-observable, which had left every control inert (overlay stuck at 100%, Reset hidden, zoom in/out moving once). Tracking via the pinch recogniser plus optimistic per-command writes fixes it; the synced value is clamped against over-pinch bounce-back.

What to look at. DiagramZoomController.syncFromScrollView / setZoom

Takeaway. Don't assume UIScrollView.zoomScale emits KVO — observe the pinch recogniser and update optimistically on programmatic zoom.
Rationale. Found on the 2nd device test; KVO never fired.

announceZoom reverted to interpolated localisation form

prism/Views/ZoomControls.swift

Why it matters. The explicit-key + defaultValue form could make VoiceOver speak a literal '%@'. The interpolated String(localized:) form builds the catalog key and supplies the runtime argument, so the percentage is spoken.

What to look at. ZoomControls.swift announceZoom

Takeaway. For an interpolated localized string with a runtime value, prefer String(localized: "key.\(value)") — it derives the %@ key and binds the argument in one step.
Rationale. Pre-push review finding (this round).

DiagramView made macOS-only; image path + SVGWebView untouched

prism/Views/DiagramView.swift

Why it matters. Removes the abandoned iOS hybrid and keeps the macOS window's CSS-transform path byte-stable, honouring the macOS Non-Goal.

What to look at. prism/Views/DiagramView.swift (#if os(macOS))

Takeaway. When two platforms diverge hard, split the view by platform rather than threading both behaviours through one struct.
Rationale. Decision 14.

Reuse: Color.hexString + ZoomMath in the controller

prism/Views/DiagramZoomWebView.swift

Why it matters. Drops a third copy of Color->hex and routes the controller's clamp/step/double-tap through ZoomMath, which also closes a latent double-tap edge (fit >= target).

What to look at. DiagramZoomWebView setZoom/zoomIn/zoomOut/toggleZoom; SVGCSS

Takeaway. Prefer the existing Color.hexString and ZoomMath helpers over inline re-implementations.
Rationale. Pre-push reuse finding (this round).

Key decisions

Native WKWebView scroll-view zoom supersedes the hybrid (Decision 14).

On-device testing showed transforming a WKWebView from the host lags and jumps. Native scroll-view zoom is smooth and crisp and removed the hybrid live-layer/commit/guard machinery for the diagram.

Track zoom without KVO.

UIScrollView.zoomScale is not reliably KVO-observable, so the live value comes from the pinch gesture recogniser plus an optimistic write on each command, clamped to defeat over-pinch bounce-back.

Reset is offset-blind for tall diagrams (accepted).

isAtRest checks zoom only; observing contentOffset would require becoming the scroll view's delegate, risking the confirmed native zoom. reset() re-homes the offset when invoked, and Fit covers the tall-diagram case.

Locale-aware percentage (Decision 7); MagnifyGesture (Decision 12).

The % overlay uses locale-aware formatting (image path); new iOS image gesture code uses MagnifyGesture to avoid deprecation warnings. macOS keeps its existing gesture.

Review findings

SeverityAreaFindingResolution
majorZoomControls.announceZoomThe explicit-key + defaultValue String(localized:) form could make VoiceOver announce a literal '%@' instead of the percentage.Reverted to String(localized: "zoom.announcement.\(percent)"), which derives the catalog key and binds the runtime argument.
majorDiagramZoomController.syncFromScrollViewWith bouncesZoom, an over-pinch past the limit leaves the overlay/limit states reading the overshot value after the spring-back (the pinch recogniser captures the pre-bounce value).Clamp the synced value to the configured range, so it matches where the scroll view settles.
majorDiagramZoomController.isAtRest (tall diagrams)isAtRest ignores contentOffset, so for a tall diagram panned at 100% Reset stays disabled until the user zooms.Accepted + documented (Decision 14): observing offset needs the scroll-view delegate, which risks the device-confirmed native zoom. reset() now re-homes the offset and Fit covers the tall case.
majorSVGCSS.hex vs Color.hexStringSVGCSS.hex was a third copy of the Color->CSS-hex conversion.Removed it; use the existing Color.hexString.
minorDiagramZoomController math vs ZoomMathclamp/step/double-tap math was re-implemented inline; the inline double-tap had a latent no-op when fit >= target.Routed through ZoomMath.clampScale/steppedScale/doubleTapTarget, closing the edge.
minorDiagramZoomController.canZoomIn/canZoomOutUnused (the toolbar computes its own disabled state).Removed.
minorSVGCSS.zoomableHTML viewport metaminimum/maximum-scale were hardcoded while the scroll-view bounds came from config — latent drift.Parameterised the meta from the config bounds.
minorDocs: 'via KVO' / UI-test header / CHANGELOGHeader/Decision-14 said 'via KVO' (code doesn't use KVO); UI-test header described the old single-Reset behaviour; CHANGELOG said 12 ADRs / 'fold' math.Corrected all three.
minorMermaidDiagramSheet fitDisabled (Req 1.7)fitDisabled is hardcoded false, so Fit is never literally disabled (a pre-measure Fit is a no-op to 1.0).Accepted: functionally meets Req 1.7 (Fit==Reset when unmeasured); documented in implementation.md.
minorZoomMathTests fold/intrinsicSVGSizeAfter the pivot these exercise ZoomMath helpers no longer used by the diagram production path.Left as-is (valid passing tests of still-present functions; image path + tests use them); dead surface documented for a follow-up trim.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8cc78aa..52ebd21 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Added +- `ios-zoom-controls` spec (T-1419) for bringing the iOS fullscreen image and mermaid viewers to zoom-control parity with macOS and fixing the laggy mermaid pinch/pan. Adds zoom in/out/fit/reset buttons, a locale-aware zoom-percentage overlay, double-tap-to-zoom, disabled-at-limit affordances, content-bounded pan clamping, rotation handling, and VoiceOver announcements to `ImageDetailSheet` and `MermaidDiagramSheet`. The mermaid responsiveness fix uses the `WKWebView`'s native `UIScrollView` zoom (the engine Safari uses) via a new iOS-only `DiagramZoomWebView` + `DiagramZoomController`, so pinch/pan are smooth and the vectors stay crisp at any zoom; the toolbar buttons and double-tap drive `setZoomScale` and the live zoom level is mirrored back (via the scroll view's pinch gesture recogniser plus an optimistic update on each command — `UIScrollView.zoomScale` is not reliably KVO-observable) for the overlay and disabled states. This replaced an earlier SwiftUI/JS hybrid transform that lagged on device because transforming a `WKWebView` from the host re-rasterises its out-of-process content. `DiagramView` is now macOS-only. The image viewer keeps a pure `ZoomMath`/`ZoomConfig` layer for its clamp/fit/step/double-tap math; `ZoomableImageView` gains an `#if os(iOS)` path plus default-param config so the macOS viewers stay unchanged. Documented divergences from macOS: locale-aware percentage formatting, content-bounded pan clamping (image), and a fit-to-view defined as "entire content visible" (which coincides with reset except for tall diagrams). Requirements (9 groups, 35 EARS criteria), design, decision log (14 ADRs), and 13 implementation tasks across 2 parallel streams live under `specs/ios-zoom-controls/`; `specs/OVERVIEW.md` updated. - HTML comments (`<!-- ... -->`) now have an opt-in Settings toggle (T-450) — "Show HTML comments" under a new "Markdown Rendering" section. Default is OFF: comment markers and content are hidden entirely (a behaviour change from today's raw-HTML fall-through, which leaked `<!--` markers into rendered body text). When ON, both block-level and inline HTML comments render as italic, dimmed annotations prefixed with the `info.circle` SF Symbol; markers are stripped, multi-line bodies preserve internal line breaks, and the indicator glyph scales with Dynamic Type. Toggling re-renders the open document without re-parsing the AST. Implementation: new `HTMLCommentParser.parseBlock` (`prism/Services/`) handles block-only HTML, rejecting conditional comments, CDATA, DOCTYPE, and the note-infrastructure `comment:HASH` tags; new `HTMLCommentStripping.strip` and `MarkdownBlockParser.stripCommentsInsideLinkLabels` strip comments from headings, image alt/title, link labels, and the raw-HTML fall-back path; new `MarkdownBlock.htmlComment(rawText:)` variant routes through a new `HTMLCommentBlockView`; ordered lists separated by a stand-alone comment block resume numbering via `MarkdownBlock.ordinalOffsetForList(at:in:)` (Decision 17). Inline rendering uses a new `SyntaxExtension.htmlComments(visible:appearance:)` shipped in the Textual fork (pin bumped to `5c9f477`); the search-highlight path in `HighlightedInlineText` now runs the comment transformation before `SearchHighlightApplicator`, and `TaskIdentifier` includes `showHTMLComments` so toggling refreshes the highlighted AttributedString and match count mid-search. Accessibility: per Decision 14, paragraphs with inline comments compose a single `Comment … End comment` boundary-bracketed label (`HTMLCommentAccessibility` + `HTMLCommentAccessibilityModifier`); block-level comments expose `Comment, <text>`; the indicator glyph is decorative. Search: new `SearchContext { showHTMLComments, footnoteData }` value drives `MarkdownBlock.searchableText(in:)`; legacy `searchableText` and `searchableText(with:)` become delegating shims; `SearchCoordinator.recomputeAfterVisibilityChange()` rebuilds totals against the new visibility and resets the cursor to the first match (or `0 of 0`) when the toggle changes mid-query. Export: `HTMLCommentExport.flatten` produces a localised `Comment: %@` prefix for plain-text and clipboard outputs when ON, strips comments when OFF; `NotesManager.exportWithInlineNotes`, `InlineNotesShareHelper.share`, and the macOS file export path thread the current setting through. New catalog entries: `Show HTML comments`, `Markdown Rendering`, `Comment`, `Comment, %@`, `End comment`, `Comment: %@`. Tests: new Swift Testing suites cover the parser (example + property-based), the stripping helpers, the block view, the inline rendering (toggle on/off + code-span skip + accessibility composer), search context, recompute-on-toggle, export, and AppSettings round-trip; new XCTest performance benchmark `HighlightedInlineTextRenderBench` asserts sub-quadratic scaling for inline-comment-heavy paragraphs; new 500 KB parsing benchmark variant pins the per-file regression budget; new UI test `HTMLCommentToggleUITests` exercises the live-toggle re-render path. Mixed-content HTML blocks now also pass through `HTMLCommentStripping` so the toggle-off promise holds (Decision 16). Note-infrastructure tags (`<!-- comment:HASH -->`) continue to be stripped before swift-markdown parses (Decision 20), with a defensive second-line-of-defence rejection inside `HTMLCommentParser`. Spec under `specs/render-html-comments/`. - VoiceOver and Voice Control can now reach the Mermaid card's "Show code" and "Copy code" overlay actions even while the overlay is hidden (T-1041, audit finding H1). `MermaidPreviewCard.renderedView(_:)` now attaches card-level `.accessibilityAction(named: "Show code") { openDiagram() }` and `.accessibilityAction(named: "Copy code") { copyToClipboard() }` on its `ZStack`, mirroring the established `ImageBlockView.swift:202-203` pattern. The visible "Show" and "Copy Code" overlay buttons also gain `.accessibilityHint(LocalizedStringKey(...))` modifiers so assistive tech narrates their behaviour when the overlay is on screen. Two new catalog entries — "Show code" and "Opens the rendered diagram" — are added to `prism/Localizable.xcstrings`; the "Copy code" action name and "Copies the diagram source code to clipboard" hint reuse existing keys (the latter shared with `MermaidErrorView.swift:101`). New `MermaidPreviewCardAccessibilityTests` Swift Testing suite covers card construction and `String(localized:)` resolution for the four catalog keys involved. Visual overlay behaviour (iOS tap-to-reveal with 3 s auto-dismiss, macOS hover-only) is unchanged. Spec lives under `specs/mermaid-card-a11y/`. - Render markdown tables that appear inside list items as tables instead of raw markdown text (T-1034). `MarkdownBlockParser.convertListItem` now emits a `.block(.table(...))` child for swift-markdown `Table` nodes nested under a list item, and `ListBlockView` renders them via a new private `NestedTableBlockView` wrapper that delegates to the existing `AccessibleTableView`. The wrapper hides the row-indicator column (per-row notes on nested tables deferred per ticket) and initialises its display mode via `TableDisplayMode.initialMode(headers:rows:)` so wider tables fall back to readable mode automatically. Test coverage extends `ListItemNestedBlocksTests` with four cases (parser emits the table child, item content no longer contains pipe text, alignment markers round-trip, list-block id structural fingerprint pinned against silent ID drift). Knowingly orphans any existing note attached to a list item that contains a table — the item's `contentForHashing` necessarily changes — accepted per `specs/tables-in-list-items/decision_log.md`.
CLAUDE.md Modified +2 / -0
diff --git a/CLAUDE.md b/CLAUDE.mdindex 4c75d8f..5c385cc 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -202,6 +202,8 @@ prism/ │   ├── ImageDetailSheet.swift         # iOS fullscreen image zoom/pan/share │   ├── ImageDetailWindow.swift        # macOS image detail window │   ├── ZoomableImageView.swift        # Pinch-zoom and pan gesture view+│   ├── ZoomControls.swift             # iOS shared zoom logic (ZoomMath/ZoomConfig), toolbar buttons, % overlay+│   ├── DiagramZoomWebView.swift       # iOS mermaid native WKWebView scroll-view zoom + DiagramZoomController │   ├── ImageErrorPlaceholder.swift    # Error placeholder for failed images │   ├── RawSourceView.swift           # Raw markdown toggle view │   ├── TableOfContentsSheet.swift
prism/Localizable.xcstrings Modified +69 / -0
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex b232450..f32c849 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -6625,6 +6625,29 @@         }       }     },+    "Fit to View": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Fit to View"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Fit to View"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Fit to View"+          }+        }+      }+    },     "Zoom In": {       "extractionState": "manual",       "localizations": {@@ -6648,6 +6671,29 @@         }       }     },+    "Zoom level": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoom level"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoom level"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoom level"+          }+        }+      }+    },     "Zoom Out": {       "extractionState": "manual",       "localizations": {@@ -7192,6 +7238,29 @@           }         }       }+    },+    "zoom.announcement.%@": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoomed to %@"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoomed to %@"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Zoomed to %@"+          }+        }+      }     }   },   "version": "1.0"
prism/Views/DiagramView.swift Modified +15 / -34
diff --git a/prism/Views/DiagramView.swift b/prism/Views/DiagramView.swiftindex ec4b3c4..57bcfc3 100644--- a/prism/Views/DiagramView.swift+++ b/prism/Views/DiagramView.swift@@ -5,12 +5,18 @@ //  Created by Arjen Schwarz on 7/1/2026. // +#if os(macOS) import SwiftUI -/// Displays a rendered SVG diagram with zoom and pan gesture support.+/// Displays a rendered SVG diagram with zoom and pan gesture support on macOS. ///-/// This view wraps SVGWebView and adds MagnificationGesture for pinch-to-zoom-/// and DragGesture for panning. Scale is bounded between 0.5 and 5.0.+/// Wraps `SVGWebView` and applies pinch/drag gestures directly to the committed+/// `scale`/`offset` bindings, which are pushed into the web view as CSS+/// transforms. Used by the macOS diagram window (`MermaidDiagramWindow`).+///+/// The iOS mermaid viewer uses `DiagramZoomWebView` (native scroll-view zoom)+/// instead — see decision 14; transforming a `WKWebView` from the SwiftUI host+/// cannot give smooth pinch-zoom, so iOS no longer shares this view. /// /// Requirements covered: /// - 4.5: Pinch-to-zoom gestures for diagram inspection (bounds 0.5-5.0)@@ -23,11 +29,14 @@ struct DiagramView: View {     @Binding var offset: CGSize     @Binding var lastScale: CGFloat +    /// Scale limits and step. Defaults to `.legacyDiagram` (0.5–5.0).+    var config: ZoomConfig = .legacyDiagram+     /// The minimum allowed scale factor.-    private let minScale: CGFloat = 0.5+    private var minScale: CGFloat { config.minScale }      /// The maximum allowed scale factor.-    private let maxScale: CGFloat = 5.0+    private var maxScale: CGFloat { config.maxScale }      /// Tracks the offset at the start of a drag gesture.     @State private var lastOffset: CGSize = .zero@@ -47,13 +56,10 @@ struct DiagramView: View {     private var magnificationGesture: some Gesture {         MagnificationGesture()             .onChanged { value in-                // Calculate new scale relative to last committed scale                 let newScale = lastScale * value-                // Clamp to bounds during gesture                 scale = min(max(newScale, minScale), maxScale)             }             .onEnded { _ in-                // Commit the current scale as the base for next gesture                 lastScale = scale             }     }@@ -62,14 +68,12 @@ struct DiagramView: View {     private var dragGesture: some Gesture {         DragGesture()             .onChanged { value in-                // Update offset based on drag translation                 offset = CGSize(                     width: lastOffset.width + value.translation.width,                     height: lastOffset.height + value.translation.height                 )             }             .onEnded { _ in-                // Commit the current offset as the base for next gesture                 lastOffset = offset             }     }@@ -78,7 +82,6 @@ struct DiagramView: View {      /// Generates an accessibility label based on SVG content hints.     private var accessibilityLabel: String {-        // Try to detect diagram type from SVG content         if svg.contains("flowchart") || svg.contains("graph") {             return "Rendered flowchart diagram"         } else if svg.contains("sequence") {@@ -111,10 +114,6 @@ struct DiagramView: View {             <svg viewBox="0 0 200 150" xmlns="http://www.w3.org/2000/svg">                 <rect x="50" y="10" width="100" height="40" rx="5" fill="#4CAF50" stroke="#2E7D32" stroke-width="2"/>                 <text x="100" y="35" text-anchor="middle" fill="white" font-family="sans-serif">Start</text>-                <line x1="100" y1="50" x2="100" y2="70" stroke="#333" stroke-width="2"/>-                <polygon points="100,80 90,70 110,70" fill="#333"/>-                <rect x="50" y="85" width="100" height="40" rx="5" fill="#2196F3" stroke="#1565C0" stroke-width="2"/>-                <text x="100" y="110" text-anchor="middle" fill="white" font-family="sans-serif">Process</text>             </svg>             """,         backgroundColor: Color(white: 0.95),@@ -124,22 +123,4 @@ struct DiagramView: View {     )     .frame(width: 300, height: 250) }--#Preview("Zoomed DiagramView") {-    @Previewable @State var scale: CGFloat = 2.0-    @Previewable @State var offset: CGSize = CGSize(width: 50, height: 50)-    @Previewable @State var lastScale: CGFloat = 2.0--    DiagramView(-        svg: """-            <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">-                <circle cx="50" cy="50" r="40" fill="#FF5722"/>-            </svg>-            """,-        backgroundColor: Color(white: 0.1),-        scale: $scale,-        offset: $offset,-        lastScale: $lastScale-    )-    .frame(width: 200, height: 200)-}+#endif
prism/Views/DiagramZoomWebView.swift Added +250 / -0
diff --git a/prism/Views/DiagramZoomWebView.swift b/prism/Views/DiagramZoomWebView.swiftnew file mode 100644index 0000000..71c8be3--- /dev/null+++ b/prism/Views/DiagramZoomWebView.swift@@ -0,0 +1,250 @@+//+//  DiagramZoomWebView.swift+//  prism+//++#if os(iOS)+import SwiftUI+import WebKit++/// Drives and observes a `WKWebView`'s native scroll-view zoom for the iOS+/// mermaid viewer.+///+/// Replaces the earlier SwiftUI/JS hybrid transform (decisions 8 & 11), which+/// could not give smooth, finger-tracking pinch-zoom because transforming a+/// `WKWebView` from the host re-rasterises its out-of-process content. The web+/// view zooms its own content natively (the engine Safari uses), so pinch/pan+/// are smooth and the vectors stay crisp at any zoom (decision 14).+///+/// The controller is the bridge: the toolbar buttons and double-tap call its+/// methods (which command `UIScrollView.setZoomScale`), and the live zoom level+/// is mirrored back from the scroll view via the pinch gesture recogniser (plus+/// an optimistic update on each command) so the `@Observable` drives the+/// percentage overlay and the disabled-at-limit button states.+@MainActor+@Observable+final class DiagramZoomController {+    /// Current zoom (1.0 == fit-to-width). Updated optimistically when a control+    /// commands a zoom and live from the pinch gesture while the user pinches.+    var zoomScale: CGFloat = 1.0++    let config: ZoomConfig++    /// Mirrors the Reduce Motion setting so button-driven zoom changes animate+    /// only when motion is allowed (Req 9.3).+    var reduceMotion: Bool = false++    /// The web view's scroll view, set when the representable is created.+    @ObservationIgnored weak var scrollView: UIScrollView?++    /// The diagram's unscaled painted height, measured on load; used to derive+    /// the fit scale against the current viewport height.+    @ObservationIgnored var contentHeight: CGFloat = 0++    init(config: ZoomConfig) {+        self.config = config+    }++    /// Reset returns to 100%; disabled once already there.+    var isAtRest: Bool { abs(zoomScale - 1.0) < 0.01 }++    /// Scale at which the whole diagram fits the viewport (1.0 unless it+    /// overflows vertically at fit-width).+    var fitScale: CGFloat {+        guard contentHeight > 0, let height = scrollView?.bounds.height, height > 0 else {+            return 1.0+        }+        return max(config.minScale, min(1.0, height / contentHeight))+    }++    func zoomIn() { setZoom(ZoomMath.steppedScale(zoomScale, by: 1, config)) }+    func zoomOut() { setZoom(ZoomMath.steppedScale(zoomScale, by: -1, config)) }+    func fit() { setZoom(fitScale) }++    /// Resets to 100% and scrolls back to the origin so a tall diagram that was+    /// panned at 100% also returns home.+    func reset() {+        setZoom(1.0)+        scrollView?.setContentOffset(.zero, animated: !reduceMotion)+    }++    /// Double-tap toggles between the fit scale and the double-tap target.+    func toggleZoom() {+        setZoom(ZoomMath.doubleTapTarget(current: zoomScale, fit: fitScale, config))+    }++    /// Mirrors the scroll view's live zoom level into `zoomScale` while the user+    /// pinches (driven by the pinch gesture recogniser — `UIScrollView.zoomScale`+    /// is not reliably KVO-observable). Clamped to the configured range so an+    /// over-pinch the scroll view bounces back from does not leave a stale+    /// out-of-range value in the overlay / limit states.+    func syncFromScrollView() {+        guard let scale = scrollView?.zoomScale else { return }+        zoomScale = ZoomMath.clampScale(scale, config)+    }++    private func setZoom(_ scale: CGFloat) {+        let clamped = ZoomMath.clampScale(scale, config)+        scrollView?.setZoomScale(clamped, animated: !reduceMotion)+        // Update optimistically so the overlay and disabled-at-limit states track+        // the command immediately (the animated set settles on this value).+        zoomScale = clamped+        // Announce the resulting zoom for VoiceOver (Req 9.2). Pinch is native and+        // continuous, so only these discrete command changes are announced.+        announceZoom(clamped)+    }+}++struct DiagramZoomWebView: UIViewRepresentable {+    let svg: String+    var backgroundColor: Color = .clear+    let controller: DiagramZoomController++    func makeUIView(context: Context) -> WKWebView {+        let config = WKWebViewConfiguration()+        config.websiteDataStore = .nonPersistent()++        let webView = WKWebView(frame: .zero, configuration: config)+        webView.isOpaque = false+        webView.backgroundColor = .clear+        webView.scrollView.backgroundColor = .clear+        // Native pinch-zoom of the web content (decision 14). The scroll view+        // bounds back the viewport-meta limits so programmatic setZoomScale works.+        webView.scrollView.minimumZoomScale = controller.config.minScale+        webView.scrollView.maximumZoomScale = controller.config.maxScale+        webView.scrollView.bouncesZoom = true+        webView.navigationDelegate = context.coordinator++        controller.scrollView = webView.scrollView+        // Track the live zoom from the pinch gesture; `UIScrollView.zoomScale`+        // is not reliably KVO-observable, so observing the recogniser is the+        // dependable signal.+        webView.scrollView.pinchGestureRecognizer?.addTarget(+            context.coordinator,+            action: #selector(Coordinator.handlePinch)+        )++        // Double-tap toggles fit <-> target (Req 5). The web view's own+        // double-tap-to-zoom does not act on a standalone SVG, so this drives it.+        let doubleTap = UITapGestureRecognizer(+            target: context.coordinator,+            action: #selector(Coordinator.handleDoubleTap)+        )+        doubleTap.numberOfTapsRequired = 2+        webView.scrollView.addGestureRecognizer(doubleTap)++        return webView+    }++    func updateUIView(_ webView: WKWebView, context: Context) {+        let coordinator = context.coordinator+        coordinator.controller = controller++        let bgHex = backgroundColor.hexString+        guard coordinator.lastSVG != svg || coordinator.lastBackgroundHex != bgHex else { return }+        coordinator.lastSVG = svg+        coordinator.lastBackgroundHex = bgHex+        webView.loadHTMLString(+            SVGCSS.zoomableHTML(+                svg: svg,+                backgroundHex: bgHex,+                minZoom: controller.config.minScale,+                maxZoom: controller.config.maxScale+            ),+            baseURL: nil+        )+    }++    func makeCoordinator() -> Coordinator {+        Coordinator(controller: controller)+    }++    @MainActor+    final class Coordinator: NSObject, WKNavigationDelegate {+        var lastSVG = ""+        var lastBackgroundHex = ""+        var controller: DiagramZoomController++        init(controller: DiagramZoomController) {+            self.controller = controller+        }++        @objc func handlePinch() {+            controller.syncFromScrollView()+        }++        @objc func handleDoubleTap() {+            controller.toggleZoom()+        }++        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {+            // Measure the painted SVG height to derive the fit scale: the zoom at+            // which the whole diagram fits the viewport (1.0 unless it overflows).+            let measureJS = """+            (function() {+                const svg = document.querySelector('#container svg');+                if (!svg) { return null; }+                const rect = svg.getBoundingClientRect();+                return { height: rect.height };+            })();+            """+            let controller = self.controller+            webView.evaluateJavaScript(measureJS) { result, _ in+                guard+                    let dict = result as? [String: Any],+                    let height = dict["height"] as? Double, height > 0+                else { return }+                Task { @MainActor in+                    controller.contentHeight = CGFloat(height)+                }+            }+        }++        /// Allow only the in-memory HTML load; deny link navigation inside the SVG.+        func webView(+            _ webView: WKWebView,+            decidePolicyFor navigationAction: WKNavigationAction,+            decisionHandler: @escaping (WKNavigationActionPolicy) -> Void+        ) {+            decisionHandler(navigationAction.navigationType == .other ? .allow : .cancel)+        }+    }+}++/// HTML/CSS helpers for the iOS native-zoom SVG web view.+enum SVGCSS {+    /// Wraps an SVG for native scroll-view zoom: the viewport allows user+    /// scaling between `minZoom` and `maxZoom` (kept in sync with the scroll+    /// view's bounds), the content fills the width at scale 1.0, and there is no+    /// host transform (the scroll view owns zoom/pan).+    static func zoomableHTML(svg: String, backgroundHex: String, minZoom: CGFloat, maxZoom: CGFloat) -> String {+        """+        <!DOCTYPE html>+        <html>+        <head>+            <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=\(minZoom), maximum-scale=\(maxZoom)">+            <style>+                * { margin: 0; padding: 0; box-sizing: border-box; }+                html, body {+                    width: 100%;+                    min-height: 100%;+                    background: \(backgroundHex);+                }+                .container {+                    width: 100%;+                    min-height: 100vh;+                    display: flex;+                    justify-content: center;+                    align-items: center;+                }+                svg { max-width: 100%; height: auto; }+            </style>+        </head>+        <body>+            <div class="container" id="container">\(svg)</div>+        </body>+        </html>+        """+    }+}+#endif
prism/Views/ImageDetailSheet.swift Modified +101 / -31
diff --git a/prism/Views/ImageDetailSheet.swift b/prism/Views/ImageDetailSheet.swiftindex fdf0343..c08b627 100644--- a/prism/Views/ImageDetailSheet.swift+++ b/prism/Views/ImageDetailSheet.swift@@ -8,17 +8,17 @@ import SwiftUI  /// Fullscreen image detail view with zoom, pan, and share actions. ///-/// Presented as a sheet on iOS. Displays a rasterized PlatformImage-/// with the same gesture infrastructure as DiagramView.+/// Presented as a sheet on iOS. Displays a rasterized PlatformImage with the+/// shared zoom controls (in/out/fit/reset buttons, a percentage overlay,+/// double-tap-to-zoom) wired through `ZoomControls.swift`. /// /// Requirements covered:-/// - 4.1: Tap image to open fullscreen detail view-/// - 4.2-4.3: Pinch-to-zoom and pan gestures-/// - 4.4: Reset zoom button-/// - 4.5: Accessibility label from alt text, title as caption-/// - 4.7: Share button via ShareLink-/// - 4.8: Reset zoom respects accessibilityReduceMotion-/// - 4.9: VoiceOver announces zoom reset and share actions+/// - 1.1, 1.8: zoom in/out/fit/reset buttons with the macOS SF Symbols+/// - 2.1: scale range 0.25-10.0+/// - 3: disabled-at-limit affordances (via zoomToolbarButtons / approxEqual)+/// - 4: zoom percentage overlay+/// - 6, 8: pan clamp + re-clamp on viewport change+/// - 9.2, 9.3: VoiceOver announcement + Reduce Motion (via applyZoom) struct ImageDetailSheet: View {     let image: PlatformImage     let alt: String@@ -27,11 +27,28 @@ struct ImageDetailSheet: View {     @State private var scale: CGFloat = 1.0     @State private var offset: CGSize = .zero     @State private var lastScale: CGFloat = 1.0+    @State private var viewSize: CGSize = .zero      @Environment(\.dismiss) private var dismiss     @Environment(\.accessibilityReduceMotion) private var reduceMotion     @Environment(\.themeColors) private var colors +    private let config = ZoomConfig.iosImage++    /// The image's intrinsic content size, used for fit and pan-clamp math.+    private var contentSize: CGSize { image.size }++    /// The image's laid-out size at scale 1.0, centred in the viewport.+    private var displayedSize: CGSize {+        ZoomMath.displayedSize(content: contentSize, viewport: viewSize, mode: .aspectFit)+    }++    /// Whether the viewport / content sizes needed to compute fit are unknown.+    private var fitDisabled: Bool {+        viewSize.width <= 0 || viewSize.height <= 0+            || contentSize.width <= 0 || contentSize.height <= 0+    }+     var body: some View {         NavigationStack {             ZStack {@@ -44,7 +61,8 @@ struct ImageDetailSheet: View {                         alt: alt,                         scale: $scale,                         offset: $offset,-                        lastScale: $lastScale+                        lastScale: $lastScale,+                        config: config                     )                     .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -58,9 +76,17 @@ struct ImageDetailSheet: View {                             .accessibilityLabel(LocalizedStringKey("Caption: \(title)"))                     }                 }++                ZoomPercentageOverlay(scale: scale)             }+            .background(GeometryReader { geometry in+                Color.clear+                    .onAppear { updateViewSize(geometry.size) }+                    .onChange(of: geometry.size) { _, newSize in updateViewSize(newSize) }+            })             .navigationTitle(alt.isEmpty ? "Image" : alt)             .navigationBarTitleDisplayMode(.inline)+            .interactiveDismissDisabled(!isAtRestState(scale: scale, offset: offset))             .toolbar {                 ToolbarItem(placement: .cancellationAction) {                     Button("Done") {@@ -68,7 +94,7 @@ struct ImageDetailSheet: View {                     }                 } -                ToolbarItemGroup(placement: .primaryAction) {+                ToolbarItem(placement: .primaryAction) {                     ShareLink(                         item: Image(platformImage: image),                         preview: SharePreview(@@ -77,35 +103,79 @@ struct ImageDetailSheet: View {                         )                     )                     .accessibilityLabel(LocalizedStringKey("Share image"))--                    Button {-                        resetZoom()-                    } label: {-                        Image(systemName: "1.magnifyingglass")-                    }-                    .accessibilityLabel(LocalizedStringKey("Reset Zoom"))-                    .disabled(scale == 1.0 && offset == .zero)                 }++                zoomToolbarButtons(+                    scale: scale,+                    offset: offset,+                    config: config,+                    fitDisabled: fitDisabled,+                    actions: ZoomToolbarActions(+                        onZoomOut: zoomOut,+                        onZoomIn: zoomIn,+                        onFit: fitToView,+                        onReset: resetZoom+                    )+                )             }         }     } +    // MARK: - Viewport++    /// Records the viewport size and, on change, recomputes the fit scale and+    /// re-clamps the pan offset to the new viewport without added animation+    /// (Req 8).+    private func updateViewSize(_ newSize: CGSize) {+        guard newSize != viewSize else { return }+        viewSize = newSize+        offset = ZoomMath.clampOffset(+            offset,+            scale: scale,+            displayed: displayedSize,+            viewport: newSize+        )+    }+     // MARK: - Actions -    /// Resets zoom to 1.0 and centers the image, respecting reduce motion preference.+    /// Zooms in by one step, clamped, panning re-clamped to the new scale.+    private func zoomIn() {+        applyCommitted(scale: ZoomMath.steppedScale(scale, by: 1, config))+    }++    /// Zooms out by one step, clamped, panning re-clamped to the new scale.+    private func zoomOut() {+        applyCommitted(scale: ZoomMath.steppedScale(scale, by: -1, config))+    }++    /// Fits the entire content within the viewport (Req 1.6).+    private func fitToView() {+        guard !fitDisabled else { return }+        let fit = ZoomMath.fitScale(displayed: displayedSize, viewport: viewSize, config)+        applyCommitted(scale: fit, offset: .zero)+    }++    /// Returns the viewer to the Reset state (scale 1.0, zero offset).     private func resetZoom() {-        if reduceMotion {-            scale = 1.0-            offset = .zero-            lastScale = 1.0-        } else {-            withAnimation(.easeInOut(duration: 0.3)) {-                scale = 1.0-                offset = .zero-                lastScale = 1.0-            }+        applyCommitted(scale: 1.0, offset: .zero)+    }++    /// Applies a discrete committed transform through the shared applier:+    /// animates (unless Reduce Motion), re-clamps the offset, syncs the+    /// gesture-tracking state, and announces the resulting percentage.+    private func applyCommitted(scale newScale: CGFloat, offset newOffset: CGSize? = nil) {+        let resolvedOffset = ZoomMath.clampOffset(+            newOffset ?? offset,+            scale: newScale,+            displayed: displayedSize,+            viewport: viewSize+        )+        applyZoom(to: newScale, reduceMotion: reduceMotion) {+            scale = newScale+            offset = resolvedOffset+            lastScale = newScale         }-        AccessibilityNotification.Announcement("Zoom reset to 100 percent").post()     } } 
prism/Views/MermaidPlaceholderCard.swift Modified +64 / -50
diff --git a/prism/Views/MermaidPlaceholderCard.swift b/prism/Views/MermaidPlaceholderCard.swiftindex f6e5c0e..2b6becc 100644--- a/prism/Views/MermaidPlaceholderCard.swift+++ b/prism/Views/MermaidPlaceholderCard.swift@@ -230,26 +230,42 @@ struct MermaidPlaceholderCard: View {  // MARK: - Diagram Sheet -/// Modal view for rendered mermaid diagram with zoom and pan support.+#if os(iOS)+/// Modal view for a rendered mermaid diagram with full zoom controls. ///-/// Displays a rendered mermaid diagram in a sheet with:+/// Presented as a sheet on iOS. Displays a rendered mermaid diagram with: /// - Loading state with progress indicator-/// - Success state with zoomable/pannable diagram+/// - Success state with `DiagramZoomWebView` (native `WKWebView` scroll-view+///   zoom — decision 14), the four zoom toolbar buttons, a live zoom-percentage+///   overlay, and double-tap-to-zoom /// - Error state with source code display ///+/// Pinch and pan are handled natively by the web view's scroll view (smooth and+/// crisp at any zoom). A `DiagramZoomController` bridges the toolbar buttons and+/// double-tap to `setZoomScale` and mirrors the live zoom level back for the+/// overlay and disabled-at-limit states. The toolbar buttons and overlay are the+/// shared `ZoomControls.swift` components.+/// /// Requirements covered:-/// - 4.4: Modal sheet with rendered diagram-/// - 4.5: Pinch-to-zoom gestures (bounds 0.5-5.0)-/// - 4.6: Pan gestures for navigation+/// - 1.2: zoom in/out/fit/reset buttons+/// - 2.2: scale range 0.25–5.0 (`.iosDiagram`)+/// - 3: disabled-at-limit affordances+/// - 4: zoom-percentage overlay /// - 4.11: Error display with source and warning banner+/// - 5: double-tap-to-zoom+/// - 6, 8: pan and zoom limits handled by the native scroll view+/// - 9: VoiceOver announcements and labelled controls struct MermaidDiagramSheet: View {     let source: String     let diagramType: String      @State private var renderState: RenderState = .loading-    @State private var scale: CGFloat = 1.0-    @State private var offset: CGSize = .zero-    @State private var lastScale: CGFloat = 1.0++    /// Bridges the toolbar buttons / double-tap to the web view's native+    /// `UIScrollView` zoom, and mirrors the live zoom level back for the overlay+    /// and disabled-at-limit button states (decision 14).+    @State private var zoomController = DiagramZoomController(config: .iosDiagram)+     @Environment(\.dismiss) private var dismiss     @Environment(\.accessibilityReduceMotion) private var reduceMotion     @Environment(\.colorScheme) private var colorScheme@@ -257,6 +273,9 @@ struct MermaidDiagramSheet: View {     @Environment(AppSettings.self) private var settings     @Environment(\.imageServices) private var imageServices +    /// Scale limits and step for the iOS mermaid viewer (0.25–5.0).+    private let config: ZoomConfig = .iosDiagram+     /// The active Prism theme based on current color scheme and user settings.     private var activeTheme: PrismTheme {         colorScheme == .dark ? settings.darkTheme : settings.lightTheme@@ -271,43 +290,53 @@ struct MermaidDiagramSheet: View {      var body: some View {         NavigationStack {-            Group {-                switch renderState {-                case .loading:-                    loadingView--                case .success(let svg):-                    DiagramView(-                        svg: svg,-                        backgroundColor: colors.background,-                        scale: $scale,-                        offset: $offset,-                        lastScale: $lastScale-                    )+            ZStack {+                Group {+                    switch renderState {+                    case .loading:+                        loadingView++                    case .success(let svg):+                        DiagramZoomWebView(+                            svg: svg,+                            backgroundColor: colors.background,+                            controller: zoomController+                        )++                    case .error(let message):+                        MermaidErrorView(source: source, error: message)+                    }+                } -                case .error(let message):-                    MermaidErrorView(source: source, error: message)+                if case .success = renderState {+                    ZoomPercentageOverlay(scale: zoomController.zoomScale)                 }             }             .navigationTitle(diagramType)-            #if os(iOS)             .navigationBarTitleDisplayMode(.inline)-            #elseif os(macOS)-            .frame(minWidth: 700, minHeight: 500)-            #endif             .toolbar {                 ToolbarItem(placement: .cancellationAction) {                     Button("Done") { dismiss() }                 }                 if case .success = renderState {-                    ToolbarItem(placement: .primaryAction) {-                        Button("Reset Zoom") {-                            resetZoom()-                        }-                    }+                    zoomToolbarButtons(+                        scale: zoomController.zoomScale,+                        offset: .zero,+                        config: config,+                        fitDisabled: false,+                        actions: ZoomToolbarActions(+                            onZoomOut: zoomController.zoomOut,+                            onZoomIn: zoomController.zoomIn,+                            onFit: zoomController.fit,+                            onReset: zoomController.reset+                        )+                    )                 }             }+            .interactiveDismissDisabled(!zoomController.isAtRest)         }+        .onAppear { zoomController.reduceMotion = reduceMotion }+        .onChange(of: reduceMotion) { _, newValue in zoomController.reduceMotion = newValue }         .task {             await renderDiagram()         }@@ -328,7 +357,7 @@ struct MermaidDiagramSheet: View {         .accessibilityLabel(LocalizedStringKey("Rendering diagram"))     } -    // MARK: - Actions+    // MARK: - Rendering      /// Renders the mermaid diagram using DiagramCache.     private func renderDiagram() async {@@ -343,23 +372,8 @@ struct MermaidDiagramSheet: View {             renderState = .error(error.localizedDescription)         }     }--    /// Resets zoom and pan to default values.-    /// Requirement 8.4: Respects Reduce Motion setting.-    private func resetZoom() {-        if reduceMotion {-            scale = 1.0-            offset = .zero-            lastScale = 1.0-        } else {-            withAnimation(.easeInOut(duration: 0.25)) {-                scale = 1.0-                offset = .zero-                lastScale = 1.0-            }-        }-    } }+#endif  // MARK: - Previews 
prism/Views/SVGWebView.swift Modified +167 / -10
diff --git a/prism/Views/SVGWebView.swift b/prism/Views/SVGWebView.swiftindex 9d81819..77be205 100644--- a/prism/Views/SVGWebView.swift+++ b/prism/Views/SVGWebView.swift@@ -20,6 +20,18 @@ import WebKit /// - 4.6: Pan gesture support via CSS transforms /// - 3.1: NSViewRepresentable on macOS /// - 3.2: UIViewRepresentable on iOS+///+/// Transform application (iOS Zoom Controls, decision 11):+/// - `makeHTML()` bakes the committed transform into the container's inline+///   style so the first paint is correct without any JavaScript.+/// - The coordinator is the `WKNavigationDelegate`; on `didFinish` it marks the+///   page loaded, applies the current transform once, and measures the painted+///   SVG box (`getBoundingClientRect`) reported up via `onMeasure`.+/// - While loaded, committed-transform changes go through a guarded+///   `evaluateJavaScript` that fires only when `(scale, offset)` actually+///   changed, so a held transform during a gesture produces no per-frame JS.+/// - The guard is reset on every `loadHTMLString` (svg or background-hex change)+///   so the transform re-applies to the fresh DOM.  #if os(iOS) struct SVGWebView: UIViewRepresentable {@@ -27,9 +39,14 @@ struct SVGWebView: UIViewRepresentable {     var scale: CGFloat = 1.0     var offset: CGSize = .zero     var backgroundColor: Color = .clear+    /// Reports the painted SVG box size measured on `didFinish`. Defaults to a+    /// no-op so existing callers (notably macOS) are unaffected.+    var onMeasure: (CGSize) -> Void = { _ in }      func makeUIView(context: Context) -> WKWebView {-        createWebView()+        let webView = createWebView()+        webView.navigationDelegate = context.coordinator+        return webView     }      func updateUIView(_ webView: WKWebView, context: Context) {@@ -46,9 +63,14 @@ struct SVGWebView: NSViewRepresentable {     var scale: CGFloat = 1.0     var offset: CGSize = .zero     var backgroundColor: Color = .clear+    /// Reports the painted SVG box size measured on `didFinish`. Defaults to a+    /// no-op so existing callers (notably macOS) are unaffected.+    var onMeasure: (CGSize) -> Void = { _ in }      func makeNSView(context: Context) -> WKWebView {-        createWebView()+        let webView = createWebView()+        webView.navigationDelegate = context.coordinator+        return webView     }      func updateNSView(_ webView: WKWebView, context: Context) {@@ -83,23 +105,155 @@ extension SVGWebView {      func updateWebView(_ webView: WKWebView, context: Context) {         let currentBgHex = backgroundHex+        let coordinator = context.coordinator -        // Reload HTML if content or background changed-        if context.coordinator.lastSVG != svg || context.coordinator.lastBackgroundHex != currentBgHex {-            context.coordinator.lastSVG = svg-            context.coordinator.lastBackgroundHex = currentBgHex+        // Keep the coordinator's view of the transform/callback current so that+        // a post-`didFinish` application uses the latest committed values.+        coordinator.scale = scale+        coordinator.offset = offset+        coordinator.onMeasure = onMeasure++        // Reload HTML if content or background changed. A reload throws away the+        // DOM (and the injected style / updateTransform function), so the+        // applied-transform guard is reset to force re-application to the fresh+        // page (decision 11 — protects the macOS theme-change-while-zoomed path).+        if coordinator.lastSVG != svg || coordinator.lastBackgroundHex != currentBgHex {+            coordinator.lastSVG = svg+            coordinator.lastBackgroundHex = currentBgHex+            coordinator.resetForReload()             let html = makeHTML()             webView.loadHTMLString(html, baseURL: nil)+            // The transform is baked into the HTML for a correct first paint and+            // re-applied via JS on `didFinish`; nothing to push yet.+            return+        }++        // Apply the committed transform only when the page is loaded and the+        // transform actually changed, so a held transform during a gesture+        // produces no per-frame JavaScript round-trip (Req 7.1).+        applyTransformIfNeeded(webView, coordinator: coordinator)+    }++    /// Pushes the committed transform to the web view when it differs from the+    /// last applied transform and the page is loaded, recording it on success.+    func applyTransformIfNeeded(_ webView: WKWebView, coordinator: Coordinator) {+        guard Self.shouldApplyTransform(+            isLoaded: coordinator.isLoaded,+            last: coordinator.lastAppliedTransform,+            scale: scale,+            offset: offset+        ) else {+            return         } -        // Update transform via JavaScript for smooth updates-        let js = "updateTransform(\(scale), \(offset.width), \(offset.height));"+        let applied = AppliedTransform(scale: scale, offset: offset)+        coordinator.lastAppliedTransform = applied+        let js = "updateTransform(\(applied.scale), \(applied.offset.width), \(applied.offset.height));"         webView.evaluateJavaScript(js, completionHandler: nil)     } -    class Coordinator {+    /// Pure decision for whether the committed transform should be pushed to the+    /// web view. Extracted so it can be unit-tested without a live web view.+    ///+    /// Returns `true` only when the page is loaded AND the requested+    /// `(scale, offset)` differs from the last applied transform (a `nil` last+    /// transform — the post-reload sentinel — always counts as different).+    static func shouldApplyTransform(+        isLoaded: Bool,+        last: AppliedTransform?,+        scale: CGFloat,+        offset: CGSize+    ) -> Bool {+        guard isLoaded else { return false }+        return last != AppliedTransform(scale: scale, offset: offset)+    }++    /// The committed transform recorded as applied to the current DOM.+    struct AppliedTransform: Equatable {+        var scale: CGFloat+        var offset: CGSize+    }++    class Coordinator: NSObject, WKNavigationDelegate {         var lastSVG: String = ""         var lastBackgroundHex: String = ""++        /// Whether the current DOM has finished loading. Reset on every reload.+        var isLoaded: Bool = false+        /// The transform last pushed to the current DOM, or `nil` after a reload+        /// (the sentinel that forces re-application to the fresh page).+        var lastAppliedTransform: AppliedTransform?++        /// Latest committed transform, mirrored from the view on each update so+        /// it can be applied once the page finishes loading.+        var scale: CGFloat = 1.0+        var offset: CGSize = .zero+        /// Latest painted-size callback, mirrored from the view on each update.+        var onMeasure: (CGSize) -> Void = { _ in }++        /// Resets the load/transform guard ahead of a `loadHTMLString` call so+        /// the committed transform re-applies to the fresh DOM.+        func resetForReload() {+            isLoaded = false+            lastAppliedTransform = nil+        }++        // MARK: WKNavigationDelegate++        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {+            isLoaded = true++            // Apply the committed transform once now that `updateTransform`+            // exists in the freshly loaded DOM.+            let applied = AppliedTransform(scale: scale, offset: offset)+            lastAppliedTransform = applied+            let js = "updateTransform(\(applied.scale), \(applied.offset.width), \(applied.offset.height));"+            webView.evaluateJavaScript(js, completionHandler: nil)++            // Measure the painted SVG box and report it up so fit-to-view and the+            // pan clamp can use the true content size (decision 11).+            let measureJS = """+            (function() {+                const svg = document.querySelector('#container svg');+                if (!svg) { return null; }+                const rect = svg.getBoundingClientRect();+                return { width: rect.width, height: rect.height };+            })();+            """+            let report = onMeasure+            // `getBoundingClientRect` returns the *transformed* box, so divide out+            // the committed scale to recover the content's unscaled layout size.+            // Initial load is scale 1.0 (no-op); this matters for the reload-while-+            // zoomed path (e.g. an iOS theme change at scale ≠ 1) so fit/clamp use+            // the true size, not a scale-inflated one (decision 11).+            let measuredScale = applied.scale > 0 ? applied.scale : 1+            webView.evaluateJavaScript(measureJS) { result, _ in+                // JS numbers bridge to NSNumber, so read as Double then convert.+                guard+                    let dict = result as? [String: Any],+                    let width = dict["width"] as? Double,+                    let height = dict["height"] as? Double,+                    width > 0, height > 0+                else {+                    return+                }+                report(CGSize(+                    width: CGFloat(width) / measuredScale,+                    height: CGFloat(height) / measuredScale+                ))+            }+        }++        /// Allow only the programmatic in-memory HTML loads (`loadHTMLString`,+        /// navigationType `.other`); deny any other navigation such as a link+        /// click inside the SVG, matching the project's WKWebView posture.+        func webView(+            _ webView: WKWebView,+            decidePolicyFor navigationAction: WKNavigationAction,+            decisionHandler: @escaping (WKNavigationActionPolicy) -> Void+        ) {+            decisionHandler(navigationAction.navigationType == .other ? .allow : .cancel)+        }     }      /// Converts SwiftUI Color to a hex string for CSS.@@ -140,6 +294,9 @@ extension SVGWebView {      private func makeHTML() -> String {         let bgColor = backgroundHex+        // Bake the committed transform into the container's inline style so the+        // first paint is correct without any JavaScript (decision 11).+        let initialTransform = "translate(\(offset.width)px, \(offset.height)px) scale(\(scale))"         return """         <!DOCTYPE html>         <html>@@ -168,7 +325,7 @@ extension SVGWebView {             </style>         </head>         <body>-            <div class="container" id="container">\(svg)</div>+            <div class="container" id="container" style="transform: \(initialTransform);">\(svg)</div>             <script>                 function updateTransform(scale, offsetX, offsetY) {                     const container = document.getElementById('container');
prism/Views/ZoomControls.swift Added +352 / -0
diff --git a/prism/Views/ZoomControls.swift b/prism/Views/ZoomControls.swiftnew file mode 100644index 0000000..72d71b5--- /dev/null+++ b/prism/Views/ZoomControls.swift@@ -0,0 +1,352 @@+//+//  ZoomControls.swift+//  prism+//+//  Shared zoom configuration, pure transform math, and SwiftUI helpers for the+//  two iOS fullscreen viewers (ImageDetailSheet, MermaidDiagramSheet).+//+//  The math layer (`ZoomConfig`, `ZoomMath`) is pure and dependency-free so it+//  can be unit-tested without a running view. The SwiftUI layer+//  (`ZoomPercentageOverlay`, `zoomToolbarButtons`, the shared applier) wires the+//  math into the viewers.+//+//  Requirements covered:+//  - 1: zoom in/out/fit/reset buttons (zoomToolbarButtons)+//  - 2: scale range and step (ZoomConfig, clampScale, steppedScale)+//  - 3: disabled-at-limit affordances (zoomToolbarButtons, approxEqual)+//  - 4: zoom percentage display (ZoomPercentageOverlay)+//  - 5: double-tap target (doubleTapTarget)+//  - 6: pan clamping (clampOffset, displayedSize)+//  - 7.2, 9.2, 9.3: animation / Reduce Motion / VoiceOver announcement (applyZoom)+//++import CoreGraphics+import Foundation++// MARK: - ZoomConfig++/// Scale limits and step for a zoom viewer.+///+/// The `ios*` presets adopt the macOS scale ranges (decision log 1); the+/// `legacy*` presets reproduce the shared views' pre-feature ranges so macOS+/// call sites are unaffected when the new `config:` parameter defaults to them.+struct ZoomConfig: Equatable {+    let minScale: CGFloat+    let maxScale: CGFloat+    let zoomStep: CGFloat+    let doubleTapTarget: CGFloat++    /// iOS image viewer: 0.25–10.0, matching `ImageDetailWindow`.+    static let iosImage = ZoomConfig(minScale: 0.25, maxScale: 10.0, zoomStep: 0.25, doubleTapTarget: 2.0)++    /// iOS mermaid viewer: 0.25–5.0, matching `MermaidDiagramWindow`.+    static let iosDiagram = ZoomConfig(minScale: 0.25, maxScale: 5.0, zoomStep: 0.25, doubleTapTarget: 2.0)++    /// Pre-feature shared image-view range (`ZoomableImageView` default).+    static let legacyImage = ZoomConfig(minScale: 0.5, maxScale: 10.0, zoomStep: 0.25, doubleTapTarget: 2.0)++    /// Pre-feature shared diagram-view range (`DiagramView` default).+    static let legacyDiagram = ZoomConfig(minScale: 0.5, maxScale: 5.0, zoomStep: 0.25, doubleTapTarget: 2.0)+}++// MARK: - FitMode++/// How content is laid out into the viewport at scale 1.0.+enum FitMode {+    /// Aspect-fit: the content fills one axis and is letterboxed on the other+    /// (raster images).+    case aspectFit+    /// Fit-width: the content fills the viewport width and overflows the height+    /// when it is taller than wide (mermaid diagrams).+    case fitWidth+}++// MARK: - ZoomMath++/// Pure, dependency-free transform math shared by the two iOS viewers.+///+/// `clampScale` and `clampOffset` are idempotent and always return values within+/// bounds. `fitScale` is clamped to `[minScale, 1.0]`. `doubleTapTarget` returns a+/// value strictly greater than `fit` when toggling up. `focalOffset` keeps the+/// focal point stationary *pre-clamp*; the caller clamps afterward.+enum ZoomMath {++    /// Clamps a scale to the config's `[minScale, maxScale]` range.+    static func clampScale(_ scale: CGFloat, _ config: ZoomConfig) -> CGFloat {+        min(max(scale, config.minScale), config.maxScale)+    }++    /// Steps a scale by ±`zoomStep` (per `direction`) and clamps to range.+    ///+    /// The step is added verbatim; it does not snap a non-grid value to a grid.+    static func steppedScale(_ scale: CGFloat, by direction: Int, _ config: ZoomConfig) -> CGFloat {+        clampScale(scale + CGFloat(direction) * config.zoomStep, config)+    }++    /// The content's laid-out size at scale 1.0, centred in the viewport.+    ///+    /// - `.aspectFit`: scales the content to fit inside the viewport preserving+    ///   aspect ratio (fills one axis, letterboxed on the other).+    /// - `.fitWidth`: fills the viewport width, scaling the height proportionally+    ///   (may overflow the viewport height for tall content).+    static func displayedSize(content: CGSize, viewport: CGSize, mode: FitMode) -> CGSize {+        guard content.width > 0, content.height > 0 else { return .zero }+        switch mode {+        case .aspectFit:+            let scale = min(viewport.width / content.width, viewport.height / content.height)+            return CGSize(width: content.width * scale, height: content.height * scale)+        case .fitWidth:+            let scale = viewport.width / content.width+            return CGSize(width: content.width * scale, height: content.height * scale)+        }+    }++    /// The scale at which the entire content fits the viewport, clamped to+    /// `[minScale, 1.0]`.+    ///+    /// Equals 1.0 when the content already fits vertically; below 1.0 only when+    /// the content overflows the viewport height at scale 1.0.+    static func fitScale(displayed: CGSize, viewport: CGSize, _ config: ZoomConfig) -> CGFloat {+        guard displayed.height > 0 else { return 1.0 }+        let raw = min(viewport.height / displayed.height, 1.0)+        return min(max(raw, config.minScale), 1.0)+    }++    /// Clamps a pan offset per axis so the content edges never move inside the+    /// viewport edges. On an axis where the content fits, the offset is pinned to+    /// zero (`allowed = 0`).+    static func clampOffset(_ offset: CGSize, scale: CGFloat, displayed: CGSize, viewport: CGSize) -> CGSize {+        let allowedX = max(0, (displayed.width * scale - viewport.width) / 2)+        let allowedY = max(0, (displayed.height * scale - viewport.height) / 2)+        return CGSize(+            width: min(max(offset.width, -allowedX), allowedX),+            height: min(max(offset.height, -allowedY), allowedY)+        )+    }++    /// The target scale for a double-tap toggle.+    ///+    /// When at the fit scale, zooms up to `doubleTapTarget` (or `maxScale` when+    /// the fit is already at or above the target, so the result stays strictly+    /// above fit). Otherwise returns the fit scale.+    static func doubleTapTarget(current: CGFloat, fit: CGFloat, _ config: ZoomConfig) -> CGFloat {+        if approxEqual(current, fit) {+            if fit >= config.doubleTapTarget {+                return config.maxScale+            }+            return min(config.doubleTapTarget, config.maxScale)+        }+        return fit+    }++    /// The pan offset that keeps the focal point stationary across a scale+    /// change, computed **pre-clamp** (the caller clamps afterward).+    ///+    /// Mirrors the macOS `zoomToScale` focal math: with the content centred in+    /// the viewport, the content point under `focal` maps to the same screen+    /// location before and after the scale change.+    static func focalOffset(+        focal: CGPoint,+        viewport: CGSize,+        current: CGFloat,+        offset: CGSize,+        new: CGFloat+    ) -> CGSize {+        let viewCenterX = viewport.width / 2+        let viewCenterY = viewport.height / 2+        let contentPointX = (focal.x - viewCenterX - offset.width) / current+        let contentPointY = (focal.y - viewCenterY - offset.height) / current+        return CGSize(+            width: focal.x - viewCenterX - contentPointX * new,+            height: focal.y - viewCenterY - contentPointY * new+        )+    }++    /// Extracts the intrinsic size from an SVG's `viewBox` (preferred) or+    /// `width`/`height` attributes, defaulting to 800×600.+    static func intrinsicSVGSize(from svg: String) -> CGSize {+        // Try viewBox first: viewBox="0 0 width height"+        let viewBoxPattern = #"viewBox\s*=\s*["']([^"']+)["']"#+        if let viewBoxMatch = svg.range(of: viewBoxPattern, options: .regularExpression) {+            let matchString = String(svg[viewBoxMatch])+            if let quoteStart = matchString.firstIndex(of: "\"") ?? matchString.firstIndex(of: "'"),+               let quoteEnd = matchString.lastIndex(of: "\"") ?? matchString.lastIndex(of: "'"),+               quoteStart < quoteEnd {+                let viewBoxValue = String(matchString[matchString.index(after: quoteStart)..<quoteEnd])+                let components = viewBoxValue.split(separator: " ").compactMap { Double($0) }+                if components.count >= 4, components[2] > 0, components[3] > 0 {+                    return CGSize(width: components[2], height: components[3])+                }+            }+        }++        // Fallback to width/height attributes.+        var width: CGFloat = 800+        var height: CGFloat = 600++        let widthPattern = #"<svg[^>]*\swidth\s*=\s*["']?(\d+(?:\.\d+)?)"#+        if let widthMatch = svg.range(of: widthPattern, options: .regularExpression) {+            let match = String(svg[widthMatch])+            let numberPattern = #"\d+(?:\.\d+)?$"#+            if let numMatch = match.range(of: numberPattern, options: .regularExpression) {+                width = CGFloat(Double(match[numMatch]) ?? 800)+            }+        }++        let heightPattern = #"<svg[^>]*\sheight\s*=\s*["']?(\d+(?:\.\d+)?)"#+        if let heightMatch = svg.range(of: heightPattern, options: .regularExpression) {+            let match = String(svg[heightMatch])+            let numberPattern = #"\d+(?:\.\d+)?$"#+            if let numMatch = match.range(of: numberPattern, options: .regularExpression) {+                height = CGFloat(Double(match[numMatch]) ?? 600)+            }+        }++        return CGSize(width: width, height: height)+    }++    /// Whether two scalars are equal within `eps`, used for disabled-state and+    /// zero-size guards so float drift after animation/focal math does not leave+    /// controls spuriously enabled.+    static func approxEqual(_ lhs: CGFloat, _ rhs: CGFloat, eps: CGFloat = 1e-3) -> Bool {+        abs(lhs - rhs) <= eps+    }+}++// MARK: - SwiftUI layer++#if canImport(SwiftUI)+import SwiftUI++// MARK: - ZoomPercentageOverlay++/// A bottom-right capsule showing the current zoom level as a locale-aware+/// whole-number percentage (Req 4; decision log 7 — intentional divergence from+/// the macOS overlay's raw interpolation).+struct ZoomPercentageOverlay: View {+    let scale: CGFloat++    var body: some View {+        VStack {+            Spacer()+            HStack {+                Spacer()+                Text(scale, format: .percent.precision(.fractionLength(0)))+                    .font(.caption)+                    .monospacedDigit()+                    .padding(.horizontal, 8)+                    .padding(.vertical, 4)+                    .adaptiveMaterialBackground()+                    .clipShape(Capsule())+                    .accessibilityLabel(LocalizedStringKey("Zoom level"))+                    .accessibilityValue(Text(scale, format: .percent.precision(.fractionLength(0))))+            }+            .padding()+        }+    }+}++// MARK: - ZoomToolbarActions++/// The four discrete zoom actions invoked by `zoomToolbarButtons`.+struct ZoomToolbarActions {+    let onZoomOut: () -> Void+    let onZoomIn: () -> Void+    let onFit: () -> Void+    let onReset: () -> Void+}++// MARK: - zoomToolbarButtons++/// Toolbar zoom controls (out / in / fit / reset) shared by both iOS viewers,+/// using the same SF Symbols as the macOS viewers (Req 1.8) with localised+/// accessibility labels (Req 9.1).+///+/// Disabled rules (Req 3): zoom-out at `scale <= minScale`; zoom-in at+/// `scale >= maxScale`; fit when `fitDisabled` (sizes unknown); reset when+/// already at the Reset state (via `approxEqual`).+@ToolbarContentBuilder+func zoomToolbarButtons(+    scale: CGFloat,+    offset: CGSize,+    config: ZoomConfig,+    fitDisabled: Bool,+    actions: ZoomToolbarActions+) -> some ToolbarContent {+    ToolbarItemGroup(placement: .primaryAction) {+        Button(action: actions.onZoomOut) {+            Image(systemName: "minus.magnifyingglass")+        }+        .accessibilityLabel(LocalizedStringKey("Zoom Out"))+        .accessibilityIdentifier("zoom.out")+        .disabled(scale <= config.minScale)++        Button(action: actions.onZoomIn) {+            Image(systemName: "plus.magnifyingglass")+        }+        .accessibilityLabel(LocalizedStringKey("Zoom In"))+        .accessibilityIdentifier("zoom.in")+        .disabled(scale >= config.maxScale)++        Button(action: actions.onFit) {+            Image(systemName: "arrow.up.left.and.arrow.down.right")+        }+        .accessibilityLabel(LocalizedStringKey("Fit to View"))+        .accessibilityIdentifier("zoom.fit")+        .disabled(fitDisabled)++        Button(action: actions.onReset) {+            Image(systemName: "1.magnifyingglass")+        }+        .accessibilityLabel(LocalizedStringKey("Reset Zoom"))+        .accessibilityIdentifier("zoom.reset")+        .disabled(isAtRestState(scale: scale, offset: offset))+    }+}++/// Whether the viewer is at the Reset state (scale 1.0, zero offset) within the+/// `approxEqual` tolerance — the same rule the Reset button and+/// `interactiveDismissDisabled` share so they cannot drift apart.+func isAtRestState(scale: CGFloat, offset: CGSize) -> Bool {+    ZoomMath.approxEqual(scale, 1.0)+        && ZoomMath.approxEqual(offset.width, 0)+        && ZoomMath.approxEqual(offset.height, 0)+}++// MARK: - Shared zoom-action applier++/// Applies a discrete zoom change (button / double-tap / reset), respecting+/// Reduce Motion (Req 9.3) and posting a VoiceOver announcement of the resulting+/// percentage (Req 9.2).+///+/// `reduceMotion` skips the animation; the mutation runs immediately. Continuous+/// pinch/drag does not use this helper — it writes the live layer directly and+/// commits on `.onEnded` without an added animation (Req 7.2).+@MainActor+func applyZoom(+    to newScale: CGFloat,+    reduceMotion: Bool,+    animationDuration: Double = 0.3,+    mutate: () -> Void+) {+    if reduceMotion {+        mutate()+    } else {+        withAnimation(.easeInOut(duration: animationDuration)) {+            mutate()+        }+    }+    announceZoom(newScale)+}++/// Posts a VoiceOver announcement of the resulting zoom percentage using+/// locale-aware formatting.+@MainActor+func announceZoom(_ scale: CGFloat) {+    let percent = Double(scale).formatted(.percent.precision(.fractionLength(0)))+    // Interpolating `percent` builds the catalog key `zoom.announcement.%@` and+    // supplies the runtime argument, so VoiceOver speaks the actual percentage.+    let message = String(localized: "zoom.announcement.\(percent)")+    AccessibilityNotification.Announcement(message).post()+}+#endif
prism/Views/ZoomableImageView.swift Modified +150 / -11
diff --git a/prism/Views/ZoomableImageView.swift b/prism/Views/ZoomableImageView.swiftindex ed227b0..4aa4cb1 100644--- a/prism/Views/ZoomableImageView.swift+++ b/prism/Views/ZoomableImageView.swift@@ -7,14 +7,24 @@ import SwiftUI  /// Displays a rasterized image with pinch-zoom and pan gestures. ///-/// Uses the same gesture pattern as DiagramView (MagnificationGesture +-/// DragGesture, scale bounds 0.5-5.0) but renders a PlatformImage-/// instead of an SVGWebView.+/// The macOS path keeps its original `MagnificationGesture` + `DragGesture`+/// behaviour (unbounded pan, centre-anchored pinch) with the default+/// `.legacyImage` config. The iOS path (under `#if os(iOS)`) adds:+/// - viewport-aware aspect-fit `displayedSize` from `image.size`,+/// - `MagnifyGesture` (not the deprecated `MagnificationGesture`, decision 12)+///   with a per-frame live-product clamp via `ZoomMath.clampScale`,+/// - drag clamped per-axis via `ZoomMath.clampOffset` so content edges stay put,+/// - a `SpatialTapGesture(count: 2)` double-tap that toggles between the fit+///   scale and the double-tap target with focal-point tracking.+///+/// The raster path is pure SwiftUI (no JS / hybrid layer); bitmap blur on zoom+/// is inherent to images and unchanged. /// /// Requirements covered:-/// - 4.2: Pinch-to-zoom gestures for image inspection (bounds 0.5-5.0)-/// - 4.3: Pan gestures to navigate zoomed images-/// - 4.5: Accessibility label from alt text+/// - 2.1, 2.5: iOS scale range 0.25-10.0; pinch clamped to the same limits+/// - 4.5 (alt): accessibility label from alt text+/// - 5: double-tap to zoom (iOS)+/// - 6: pan clamping (iOS) struct ZoomableImageView: View {     let image: PlatformImage     let alt: String@@ -22,19 +32,149 @@ struct ZoomableImageView: View {     @Binding var offset: CGSize     @Binding var lastScale: CGFloat -    /// Exposed scale range for testing and external reference.+    /// Scale limits and step for this viewer. Defaults to `.legacyImage`+    /// (0.5-10.0) so the macOS caller is unaffected; the iOS call site passes+    /// `.iosImage` (0.25-10.0).+    var config: ZoomConfig = .legacyImage++    /// Exposed scale range for testing and external reference. Retained as the+    /// test surface; the legacy default config reproduces these bounds.     static let scaleRange: ClosedRange<CGFloat> = 0.5...10.0      /// The minimum allowed scale factor.-    private let minScale: CGFloat = scaleRange.lowerBound+    private var minScale: CGFloat { config.minScale }      /// The maximum allowed scale factor.-    private let maxScale: CGFloat = scaleRange.upperBound+    private var maxScale: CGFloat { config.maxScale }      /// Tracks the offset at the start of a drag gesture.     @State private var lastOffset: CGSize = .zero      var body: some View {+        #if os(iOS)+        iOSBody+        #else+        legacyBody+        #endif+    }++    #if os(iOS)+    // MARK: - iOS Body++    /// Honours the Reduce Motion setting for the discrete double-tap zoom change+    /// (Req 9.3) and enables the VoiceOver announcement of the result (Req 9.2),+    /// routing through the same shared applier the diagram viewer uses.+    @Environment(\.accessibilityReduceMotion) private var reduceMotion++    /// Viewport-aware body with clamped pan and double-tap-to-zoom.+    private var iOSBody: some View {+        GeometryReader { geometry in+            let viewport = geometry.size+            let displayed = ZoomMath.displayedSize(+                content: image.size,+                viewport: viewport,+                mode: .aspectFit+            )++            Image(platformImage: image)+                .resizable()+                .aspectRatio(contentMode: .fit)+                .scaleEffect(scale)+                .offset(offset)+                .frame(width: viewport.width, height: viewport.height)+                .contentShape(Rectangle())+                .gesture(magnifyGesture(displayed: displayed, viewport: viewport))+                .simultaneousGesture(dragGesture(displayed: displayed, viewport: viewport))+                .simultaneousGesture(doubleTapGesture(displayed: displayed, viewport: viewport))+                .accessibilityLabel(alt)+                .accessibilityHint(LocalizedStringKey("Pinch to zoom, drag to pan"))+        }+    }++    /// Centre-anchored pinch (`MagnifyGesture`, decision 12) with a per-frame+    /// live-product clamp: `committedScale x magnification` is constrained to+    /// `[minScale, maxScale]` so the displayed scale never overshoots (Req 2.5).+    private func magnifyGesture(displayed: CGSize, viewport: CGSize) -> some Gesture {+        MagnifyGesture()+            .onChanged { value in+                let newScale = ZoomMath.clampScale(lastScale * value.magnification, config)+                scale = newScale+                offset = ZoomMath.clampOffset(+                    offset,+                    scale: newScale,+                    displayed: displayed,+                    viewport: viewport+                )+            }+            .onEnded { _ in+                lastScale = scale+                lastOffset = offset+            }+    }++    /// Drag gesture clamped per-axis via `clampOffset` so the content edges+    /// cannot move inside the viewport edges (Req 6.1).+    private func dragGesture(displayed: CGSize, viewport: CGSize) -> some Gesture {+        DragGesture()+            .onChanged { value in+                let proposed = CGSize(+                    width: lastOffset.width + value.translation.width,+                    height: lastOffset.height + value.translation.height+                )+                offset = ZoomMath.clampOffset(+                    proposed,+                    scale: scale,+                    displayed: displayed,+                    viewport: viewport+                )+            }+            .onEnded { _ in+                lastOffset = offset+            }+    }++    /// Double-tap toggle between the fit scale and the double-tap target with+    /// focal-point tracking (Req 5). The drag's default minimum distance keeps a+    /// stationary double-tap from being captured as a drag (Req 5.4).+    private func doubleTapGesture(displayed: CGSize, viewport: CGSize) -> some Gesture {+        SpatialTapGesture(count: 2)+            .onEnded { value in+                let fit = ZoomMath.fitScale(displayed: displayed, viewport: viewport, config)+                let target = ZoomMath.doubleTapTarget(current: scale, fit: fit, config)+                let newOffset: CGSize+                if ZoomMath.approxEqual(target, fit) {+                    // Returning to fit: centre the content.+                    newOffset = .zero+                } else {+                    let focal = ZoomMath.focalOffset(+                        focal: value.location,+                        viewport: viewport,+                        current: scale,+                        offset: offset,+                        new: target+                    )+                    newOffset = ZoomMath.clampOffset(+                        focal,+                        scale: target,+                        displayed: displayed,+                        viewport: viewport+                    )+                }+                // Route through the shared applier so the change is animated,+                // skipped under Reduce Motion (Req 9.3), and announced to+                // VoiceOver (Req 9.2) — matching the diagram viewer.+                applyZoom(to: target, reduceMotion: reduceMotion) {+                    scale = target+                    offset = newOffset+                    lastScale = target+                    lastOffset = newOffset+                }+            }+    }+    #else+    // MARK: - macOS Body (unchanged)++    private var legacyBody: some View {         Image(platformImage: image)             .resizable()             .aspectRatio(contentMode: .fit)@@ -47,8 +187,6 @@ struct ZoomableImageView: View {             .accessibilityHint(LocalizedStringKey("Pinch to zoom, drag to pan"))     } -    // MARK: - Gestures-     /// Magnification gesture for pinch-to-zoom with bounds enforcement.     private var magnificationGesture: some Gesture {         MagnificationGesture()@@ -74,6 +212,7 @@ struct ZoomableImageView: View {                 lastOffset = offset             }     }+    #endif      /// Resets zoom and pan to default values.     func resetTransform() {
prismTests/SVGWebViewTests.swift Modified +129 / -0
diff --git a/prismTests/SVGWebViewTests.swift b/prismTests/SVGWebViewTests.swiftindex 7c1d23b..7dd1a05 100644--- a/prismTests/SVGWebViewTests.swift+++ b/prismTests/SVGWebViewTests.swift@@ -132,4 +132,133 @@ struct SVGWebViewTests {         // We can verify the SVG is set in coordinator         #expect(coordinator.lastSVG.isEmpty, "Coordinator should start with empty SVG")     }++    // MARK: - Transform-Application Decision (decision 11, Req 7.1)++    @Test("transform is not applied before the page has loaded")+    func transformSkippedWhileNotLoaded() {+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: false,+            last: nil,+            scale: 2.0,+            offset: CGSize(width: 10, height: 20)+        )+        #expect(decision == false)+    }++    @Test("transform is applied when loaded and no transform has been applied yet")+    func transformAppliedWhenLoadedAndNeverApplied() {+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: true,+            last: nil,+            scale: 1.0,+            offset: .zero+        )+        #expect(decision == true)+    }++    @Test("transform is applied when loaded and the scale changed")+    func transformAppliedWhenScaleChanged() {+        let last = SVGWebView.AppliedTransform(scale: 1.0, offset: .zero)+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: true,+            last: last,+            scale: 2.0,+            offset: .zero+        )+        #expect(decision == true)+    }++    @Test("transform is applied when loaded and the offset changed")+    func transformAppliedWhenOffsetChanged() {+        let last = SVGWebView.AppliedTransform(scale: 1.0, offset: .zero)+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: true,+            last: last,+            scale: 1.0,+            offset: CGSize(width: 5, height: 0)+        )+        #expect(decision == true)+    }++    @Test("transform is skipped when loaded and the transform is unchanged")+    func transformSkippedWhenUnchanged() {+        let transform = SVGWebView.AppliedTransform(scale: 1.5, offset: CGSize(width: 10, height: 20))+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: true,+            last: transform,+            scale: 1.5,+            offset: CGSize(width: 10, height: 20)+        )+        #expect(decision == false)+    }++    @Test("a held identity transform during a gesture produces no per-frame apply")+    func unchangedIdentityProducesNoApply() {+        // Mirrors the hybrid path: while a gesture drives the live SwiftUI layer the+        // committed (scale, offset) stay constant, so no JS should fire each frame.+        let identity = SVGWebView.AppliedTransform(scale: 1.0, offset: .zero)+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: true,+            last: identity,+            scale: 1.0,+            offset: .zero+        )+        #expect(decision == false)+    }++    // MARK: - Reset-on-Reload++    @Test("reloading on an svg change resets the applied transform, forcing re-apply")+    func svgChangeResetsAppliedTransform() {+        let coordinator = SVGWebView.Coordinator()+        coordinator.isLoaded = true+        coordinator.lastAppliedTransform = SVGWebView.AppliedTransform(scale: 2.0, offset: .zero)++        // An svg change drives a loadHTMLString call, which resets the guard.+        coordinator.resetForReload()++        #expect(coordinator.isLoaded == false)+        #expect(coordinator.lastAppliedTransform == nil)++        // After the fresh DOM loads, the same committed transform must re-apply.+        coordinator.isLoaded = true+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: coordinator.isLoaded,+            last: coordinator.lastAppliedTransform,+            scale: 2.0,+            offset: .zero+        )+        #expect(decision == true)+    }++    @Test("reloading on a background-hex change resets the applied transform, forcing re-apply")+    func backgroundHexChangeResetsAppliedTransform() {+        // Protects the macOS theme-change-while-zoomed path: a background change reloads+        // the HTML, so the recorded transform must be reset and re-applied to the new DOM.+        let coordinator = SVGWebView.Coordinator()+        coordinator.isLoaded = true+        coordinator.lastAppliedTransform = SVGWebView.AppliedTransform(scale: 3.0, offset: CGSize(width: 8, height: -4))++        coordinator.resetForReload()++        #expect(coordinator.isLoaded == false)+        #expect(coordinator.lastAppliedTransform == nil)++        coordinator.isLoaded = true+        let decision = SVGWebView.shouldApplyTransform(+            isLoaded: coordinator.isLoaded,+            last: coordinator.lastAppliedTransform,+            scale: 3.0,+            offset: CGSize(width: 8, height: -4)+        )+        #expect(decision == true)+    }++    @Test("Coordinator starts unloaded with no applied transform")+    func coordinatorStartsUnloaded() {+        let coordinator = SVGWebView.Coordinator()+        #expect(coordinator.isLoaded == false)+        #expect(coordinator.lastAppliedTransform == nil)+    } }
prismTests/ZoomMathTests.swift Added +481 / -0
diff --git a/prismTests/ZoomMathTests.swift b/prismTests/ZoomMathTests.swiftnew file mode 100644index 0000000..6dda7af--- /dev/null+++ b/prismTests/ZoomMathTests.swift@@ -0,0 +1,481 @@+//+//  ZoomMathTests.swift+//  prismTests+//+//  Tests for the pure ZoomMath transform helpers shared by the two iOS+//  fullscreen viewers (ImageDetailSheet, MermaidDiagramSheet).+//+//  Requirements covered:+//  - 1.3, 1.4: steppedScale increases/decreases by one zoom step, clamped+//  - 1.6: fitScale / displayedSize+//  - 2.1-2.5: scale ranges and clamping+//  - 5.1: doubleTapTarget+//  - 6.1, 6.2: clampOffset (per-axis pan clamp)+//  - Fold formula + live-product clamp (decision log 8, 11)+//++import CoreGraphics+import Foundation+import Testing+@testable import prism++@Suite("ZoomMath")+struct ZoomMathTests {++    // MARK: - clampScale++    @Test("clampScale clamps below the minimum")+    func clampScaleBelowMin() {+        #expect(ZoomMath.clampScale(0.1, .iosImage) == 0.25)+        #expect(ZoomMath.clampScale(0.1, .iosDiagram) == 0.25)+    }++    @Test("clampScale clamps above the maximum")+    func clampScaleAboveMax() {+        #expect(ZoomMath.clampScale(50.0, .iosImage) == 10.0)+        #expect(ZoomMath.clampScale(50.0, .iosDiagram) == 5.0)+    }++    @Test("clampScale leaves in-range values untouched")+    func clampScaleInRange() {+        #expect(ZoomMath.clampScale(2.5, .iosImage) == 2.5)+        #expect(ZoomMath.clampScale(1.0, .iosDiagram) == 1.0)+    }++    // MARK: - steppedScale++    @Test("steppedScale increases by one zoom step")+    func steppedScaleUp() {+        #expect(ZoomMath.steppedScale(1.0, by: 1, .iosImage) == 1.25)+    }++    @Test("steppedScale decreases by one zoom step")+    func steppedScaleDown() {+        #expect(ZoomMath.steppedScale(1.0, by: -1, .iosImage) == 0.75)+    }++    @Test("steppedScale clamps at the maximum")+    func steppedScaleClampMax() {+        #expect(ZoomMath.steppedScale(10.0, by: 1, .iosImage) == 10.0)+        #expect(ZoomMath.steppedScale(5.0, by: 1, .iosDiagram) == 5.0)+    }++    @Test("steppedScale clamps at the minimum")+    func steppedScaleClampMin() {+        #expect(ZoomMath.steppedScale(0.25, by: -1, .iosImage) == 0.25)+        #expect(ZoomMath.steppedScale(0.25, by: -1, .iosDiagram) == 0.25)+    }++    @Test("steppedScale from a non-grid value adds the step verbatim")+    func steppedScaleNonGrid() {+        // Stepping from 0.37 simply adds/subtracts the step; it does not snap to a grid.+        #expect(abs(ZoomMath.steppedScale(0.37, by: 1, .iosImage) - 0.62) < 1e-9)+        #expect(abs(ZoomMath.steppedScale(0.37, by: -1, .iosImage) - 0.25) < 1e-9) // 0.12 clamped to min 0.25+    }++    // MARK: - displayedSize++    @Test("displayedSize aspect-fit letterboxes a portrait image in a landscape viewport")+    func displayedSizeAspectFitPortrait() {+        // 100x200 content into 400x400 viewport -> height-bound, width 200+        let size = ZoomMath.displayedSize(+            content: CGSize(width: 100, height: 200),+            viewport: CGSize(width: 400, height: 400),+            mode: .aspectFit+        )+        #expect(abs(size.height - 400) < 1e-6)+        #expect(abs(size.width - 200) < 1e-6)+    }++    @Test("displayedSize aspect-fit letterboxes a landscape image in a square viewport")+    func displayedSizeAspectFitLandscape() {+        // 400x100 content into 400x400 viewport -> width-bound, height 100+        let size = ZoomMath.displayedSize(+            content: CGSize(width: 400, height: 100),+            viewport: CGSize(width: 400, height: 400),+            mode: .aspectFit+        )+        #expect(abs(size.width - 400) < 1e-6)+        #expect(abs(size.height - 100) < 1e-6)+    }++    @Test("displayedSize fit-width fills width and scales height proportionally")+    func displayedSizeFitWidth() {+        // wide viewBox 200x100 into 400 wide viewport -> width 400, height 200+        let wide = ZoomMath.displayedSize(+            content: CGSize(width: 200, height: 100),+            viewport: CGSize(width: 400, height: 1000),+            mode: .fitWidth+        )+        #expect(abs(wide.width - 400) < 1e-6)+        #expect(abs(wide.height - 200) < 1e-6)+    }++    @Test("displayedSize fit-width on a tall viewBox overflows the viewport height")+    func displayedSizeFitWidthTall() {+        // tall viewBox 100x400 into 400 wide / 400 tall viewport -> width 400, height 1600+        let tall = ZoomMath.displayedSize(+            content: CGSize(width: 100, height: 400),+            viewport: CGSize(width: 400, height: 400),+            mode: .fitWidth+        )+        #expect(abs(tall.width - 400) < 1e-6)+        #expect(abs(tall.height - 1600) < 1e-6)+    }++    @Test("displayedSize fit-width on a square viewBox makes a square")+    func displayedSizeFitWidthSquare() {+        let square = ZoomMath.displayedSize(+            content: CGSize(width: 300, height: 300),+            viewport: CGSize(width: 400, height: 400),+            mode: .fitWidth+        )+        #expect(abs(square.width - 400) < 1e-6)+        #expect(abs(square.height - 400) < 1e-6)+    }++    // MARK: - fitScale++    @Test("fitScale for a short diagram is 1.0")+    func fitScaleShort() {+        // displayed height (200) <= viewport height (400) -> 1.0+        let fit = ZoomMath.fitScale(+            displayed: CGSize(width: 400, height: 200),+            viewport: CGSize(width: 400, height: 400),+            .iosDiagram+        )+        #expect(fit == 1.0)+    }++    @Test("fitScale for a tall diagram is below 1.0")+    func fitScaleTall() {+        // displayed height 800 in 400 viewport -> 0.5+        let fit = ZoomMath.fitScale(+            displayed: CGSize(width: 400, height: 800),+            viewport: CGSize(width: 400, height: 400),+            .iosDiagram+        )+        #expect(abs(fit - 0.5) < 1e-6)+    }++    @Test("fitScale for an extreme-tall diagram is clamped to minScale, never below")+    func fitScaleExtremeTall() {+        // displayed height 8000 in 400 viewport -> 0.05, clamped up to minScale 0.25+        let fit = ZoomMath.fitScale(+            displayed: CGSize(width: 400, height: 8000),+            viewport: CGSize(width: 400, height: 400),+            .iosDiagram+        )+        #expect(fit == 0.25)+    }++    // MARK: - clampOffset++    @Test("clampOffset pins to zero when scale <= fit (content fits both axes)")+    func clampOffsetPinsWhenFitting() {+        // displayed 200x200 at scale 1.0 fits inside 400x400 viewport on both axes+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 50, height: 80),+            scale: 1.0,+            displayed: CGSize(width: 200, height: 200),+            viewport: CGSize(width: 400, height: 400)+        )+        #expect(clamped == .zero)+    }++    @Test("clampOffset bounds the offset to allowed at scale > 1")+    func clampOffsetBoundedAtScale() {+        // displayed 400x400, scale 2.0 -> content 800x800, viewport 400 -> allowed 200 each axis+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 1000, height: -1000),+            scale: 2.0,+            displayed: CGSize(width: 400, height: 400),+            viewport: CGSize(width: 400, height: 400)+        )+        #expect(abs(clamped.width - 200) < 1e-6)+        #expect(abs(clamped.height - (-200)) < 1e-6)+    }++    @Test("clampOffset allows tall-axis pan at scale 1 while pinning the fitting axis")+    func clampOffsetTallAxisAtScaleOne() {+        // displayed 400x800 at scale 1.0 in 400x400 viewport:+        // width fits -> pinned; height overflows -> allowed (800-400)/2 = 200+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 999, height: 999),+            scale: 1.0,+            displayed: CGSize(width: 400, height: 800),+            viewport: CGSize(width: 400, height: 400)+        )+        #expect(clamped.width == 0)+        #expect(abs(clamped.height - 200) < 1e-6)+    }++    @Test("clampOffset result never exceeds the allowed bounds")+    func clampOffsetNeverExceeds() {+        let displayed = CGSize(width: 600, height: 300)+        let viewport = CGSize(width: 400, height: 400)+        let scale: CGFloat = 1.5+        let allowedX = max(0, (displayed.width * scale - viewport.width) / 2)+        let allowedY = max(0, (displayed.height * scale - viewport.height) / 2)+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 10_000, height: -10_000),+            scale: scale,+            displayed: displayed,+            viewport: viewport+        )+        #expect(abs(clamped.width) <= allowedX + 1e-6)+        #expect(abs(clamped.height) <= allowedY + 1e-6)+    }++    @Test("clampOffset does not over-pan a letterboxed image")+    func clampOffsetLetterboxNoOverPan() {+        // Landscape image displayed 400x100 (letterboxed top/bottom) in 400x400 viewport at scale 1.0.+        // Content fits both axes -> both pinned, no over-pan into the letterbox bars.+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 100, height: 100),+            scale: 1.0,+            displayed: CGSize(width: 400, height: 100),+            viewport: CGSize(width: 400, height: 400)+        )+        #expect(clamped == .zero)+    }++    // MARK: - doubleTapTarget++    @Test("doubleTapTarget at the fit scale returns the configured target")+    func doubleTapTargetAtFit() {+        let target = ZoomMath.doubleTapTarget(current: 1.0, fit: 1.0, .iosImage)+        #expect(target == 2.0)+    }++    @Test("doubleTapTarget off the fit scale returns the fit scale")+    func doubleTapTargetOffFit() {+        let target = ZoomMath.doubleTapTarget(current: 3.0, fit: 0.5, .iosDiagram)+        #expect(target == 0.5)+    }++    @Test("doubleTapTarget returns maxScale when fit >= configured target")+    func doubleTapTargetFitAboveTarget() {+        // For content whose fit is already >= 2.0, zooming up goes to maxScale so the+        // target stays strictly above fit (no oscillation).+        let target = ZoomMath.doubleTapTarget(current: 2.5, fit: 2.5, .iosDiagram)+        #expect(target == 5.0)+    }++    @Test("doubleTapTarget caps the target at maxScale")+    func doubleTapTargetCapsAtMax() {+        // .iosDiagram max is 5.0; a 2.0 target stays under it.+        let target = ZoomMath.doubleTapTarget(current: 1.0, fit: 1.0, .iosDiagram)+        #expect(target == 2.0)+    }++    // MARK: - focalOffset++    @Test("focalOffset keeps the focal point stationary across a scale change (pre-clamp)")+    func focalOffsetStationary() {+        let viewport = CGSize(width: 400, height: 400)+        let focal = CGPoint(x: 300, y: 250)+        let current: CGFloat = 1.0+        let offset = CGSize.zero+        let new: CGFloat = 2.0++        let result = ZoomMath.focalOffset(+            focal: focal,+            viewport: viewport,+            current: current,+            offset: offset,+            new: new+        )++        // The content point under the focal point before and after the scale change+        // must map to the same screen location. Recompute the focal screen position+        // from the resulting offset and assert it matches the original focal point.+        let viewCenter = CGPoint(x: viewport.width / 2, y: viewport.height / 2)+        let contentPointX = (focal.x - viewCenter.x - offset.width) / current+        let contentPointY = (focal.y - viewCenter.y - offset.height) / current+        let screenX = viewCenter.x + contentPointX * new + result.width+        let screenY = viewCenter.y + contentPointY * new + result.height+        #expect(abs(screenX - focal.x) < 1e-6)+        #expect(abs(screenY - focal.y) < 1e-6)+    }++    // MARK: - approxEqual++    @Test("approxEqual treats values within epsilon as equal")+    func approxEqualWithinEps() {+        #expect(ZoomMath.approxEqual(1.0, 1.0 + 5e-4))+        #expect(ZoomMath.approxEqual(0.0, -2e-4))+    }++    @Test("approxEqual treats values beyond epsilon as different")+    func approxEqualBeyondEps() {+        #expect(!ZoomMath.approxEqual(1.0, 1.01))+        #expect(!ZoomMath.approxEqual(0.0, 0.5))+    }++    // MARK: - intrinsicSVGSize++    @Test("intrinsicSVGSize reads the viewBox dimensions")+    func intrinsicSVGSizeViewBox() {+        let svg = "<svg viewBox=\"0 0 320 240\" xmlns=\"http://www.w3.org/2000/svg\"></svg>"+        let size = ZoomMath.intrinsicSVGSize(from: svg)+        #expect(size == CGSize(width: 320, height: 240))+    }++    @Test("intrinsicSVGSize falls back to width/height attributes")+    func intrinsicSVGSizeWidthHeight() {+        let svg = "<svg width=\"640\" height=\"480\" xmlns=\"http://www.w3.org/2000/svg\"></svg>"+        let size = ZoomMath.intrinsicSVGSize(from: svg)+        #expect(size == CGSize(width: 640, height: 480))+    }++    @Test("intrinsicSVGSize defaults to 800x600 when nothing parses")+    func intrinsicSVGSizeDefault() {+        let size = ZoomMath.intrinsicSVGSize(from: "<svg></svg>")+        #expect(size == CGSize(width: 800, height: 600))+    }++    // MARK: - Fold formula++    @Test("Fold for a pure drag (m = 1) is additive on the offset")+    func foldPureDrag() {+        let committedScale: CGFloat = 2.0+        let committedOffset = CGSize(width: 30, height: -40)+        let liveMagnification: CGFloat = 1.0+        let liveDrag = CGSize(width: 10, height: 5)+        let displayed = CGSize(width: 400, height: 400)+        let viewport = CGSize(width: 400, height: 400)++        let newScale = ZoomMath.clampScale(committedScale * liveMagnification, .iosDiagram)+        let newOffset = ZoomMath.clampOffset(+            CGSize(+                width: liveMagnification * committedOffset.width + liveDrag.width,+                height: liveMagnification * committedOffset.height + liveDrag.height+            ),+            scale: newScale,+            displayed: displayed,+            viewport: viewport+        )+        #expect(newScale == 2.0)+        // O + d = (40, -35), within allowed (400*2-400)/2 = 200+        #expect(abs(newOffset.width - 40) < 1e-6)+        #expect(abs(newOffset.height - (-35)) < 1e-6)+    }++    @Test("Fold for a pure pinch (d = 0) magnifies the committed offset")+    func foldPurePinch() {+        let committedScale: CGFloat = 1.0+        let committedOffset = CGSize(width: 50, height: 20)+        let liveMagnification: CGFloat = 2.0+        let liveDrag = CGSize.zero+        let displayed = CGSize(width: 400, height: 400)+        let viewport = CGSize(width: 400, height: 400)++        let newScale = ZoomMath.clampScale(committedScale * liveMagnification, .iosDiagram)+        let newOffset = ZoomMath.clampOffset(+            CGSize(+                width: liveMagnification * committedOffset.width + liveDrag.width,+                height: liveMagnification * committedOffset.height + liveDrag.height+            ),+            scale: newScale,+            displayed: displayed,+            viewport: viewport+        )+        #expect(newScale == 2.0)+        // m * O = (100, 40), within allowed 200+        #expect(abs(newOffset.width - 100) < 1e-6)+        #expect(abs(newOffset.height - 40) < 1e-6)+    }++    @Test("Fold for a simultaneous pinch + drag combines both terms with no jump")+    func foldSimultaneous() {+        let committedScale: CGFloat = 1.5+        let committedOffset = CGSize(width: 40, height: -20)+        let liveMagnification: CGFloat = 2.0+        let liveDrag = CGSize(width: 15, height: 25)+        let displayed = CGSize(width: 400, height: 400)+        let viewport = CGSize(width: 400, height: 400)++        let newScale = ZoomMath.clampScale(committedScale * liveMagnification, .iosDiagram)+        let newOffset = ZoomMath.clampOffset(+            CGSize(+                width: liveMagnification * committedOffset.width + liveDrag.width,+                height: liveMagnification * committedOffset.height + liveDrag.height+            ),+            scale: newScale,+            displayed: displayed,+            viewport: viewport+        )+        // scale 1.5 * 2.0 = 3.0+        #expect(newScale == 3.0)+        // m*O + d = (2*40+15, 2*-20+25) = (95, -15); allowed (400*3-400)/2 = 400+        #expect(abs(newOffset.width - 95) < 1e-6)+        #expect(abs(newOffset.height - (-15)) < 1e-6)+    }++    // MARK: - Live-product clamp++    @Test("Live product clamp keeps committed times live within bounds")+    func liveProductClamp() {+        // Over-driven pinch out: committed 4.0 * live 3.0 = 12.0 -> clamp to 5.0 (iosDiagram max)+        let clampedHigh = ZoomMath.clampScale(4.0 * 3.0, .iosDiagram)+        #expect(clampedHigh == 5.0)+        // Over-driven pinch in: committed 0.5 * live 0.1 = 0.05 -> clamp to 0.25 (min)+        let clampedLow = ZoomMath.clampScale(0.5 * 0.1, .iosDiagram)+        #expect(clampedLow == 0.25)+    }++    // MARK: - Property-style invariants (parameterised)++    /// Spread of scales covering below-min, in-range, and above-max for both configs.+    static let scaleSpread: [CGFloat] = [-1.0, 0.0, 0.1, 0.25, 0.37, 0.5, 1.0, 2.5, 5.0, 10.0, 50.0]++    @Test("clampScale is idempotent and within bounds", arguments: scaleSpread)+    func clampScaleIdempotentInBounds(scale: CGFloat) {+        for config in [ZoomConfig.iosImage, .iosDiagram, .legacyImage, .legacyDiagram] {+            let once = ZoomMath.clampScale(scale, config)+            let twice = ZoomMath.clampScale(once, config)+            #expect(once == twice, "clampScale should be idempotent")+            #expect(once >= config.minScale - 1e-9)+            #expect(once <= config.maxScale + 1e-9)+        }+    }++    static let offsetSpread: [CGSize] = [+        .zero,+        CGSize(width: 50, height: 50),+        CGSize(width: -300, height: 200),+        CGSize(width: 10_000, height: -10_000),+        CGSize(width: -5, height: 12)+    ]++    @Test("clampOffset is idempotent and within the allowed bounds", arguments: offsetSpread)+    func clampOffsetIdempotentInBounds(offset: CGSize) {+        let displayed = CGSize(width: 500, height: 350)+        let viewport = CGSize(width: 400, height: 400)+        for scale in ZoomMathTests.scaleSpread where scale > 0 {+            let allowedX = max(0, (displayed.width * scale - viewport.width) / 2)+            let allowedY = max(0, (displayed.height * scale - viewport.height) / 2)+            let once = ZoomMath.clampOffset(offset, scale: scale, displayed: displayed, viewport: viewport)+            let twice = ZoomMath.clampOffset(once, scale: scale, displayed: displayed, viewport: viewport)+            #expect(once == twice, "clampOffset should be idempotent")+            #expect(abs(once.width) <= allowedX + 1e-6)+            #expect(abs(once.height) <= allowedY + 1e-6)+        }+    }++    @Test("fitScale stays within minScale to 1.0", arguments: [+        CGSize(width: 400, height: 100),+        CGSize(width: 400, height: 400),+        CGSize(width: 400, height: 800),+        CGSize(width: 400, height: 8000)+    ])+    func fitScaleInRange(displayed: CGSize) {+        let viewport = CGSize(width: 400, height: 400)+        for config in [ZoomConfig.iosImage, .iosDiagram] {+            let fit = ZoomMath.fitScale(displayed: displayed, viewport: viewport, config)+            #expect(fit >= config.minScale - 1e-9)+            #expect(fit <= 1.0 + 1e-9)+        }+    }+}
prismTests/ZoomableImageViewTests.swift Modified +100 / -32
diff --git a/prismTests/ZoomableImageViewTests.swift b/prismTests/ZoomableImageViewTests.swiftindex df1e201..e6fa742 100644--- a/prismTests/ZoomableImageViewTests.swift+++ b/prismTests/ZoomableImageViewTests.swift@@ -21,27 +21,33 @@ struct ZoomableImageViewTests {         #endif     } -    // MARK: - Scale Bounds+    /// The image viewer's iOS config (0.25-10.0) - the values the iOS call site+    /// passes and against which clamp/double-tap behaviour is asserted.+    private let config = ZoomConfig.iosImage -    @Test("Min scale constant is 0.5")-    func minScaleIs05() {+    // MARK: - Scale Bounds (test surface kept on the view)++    @Test("static scaleRange remains the test surface (0.5-10.0)")+    func staticScaleRangeUnchanged() {+        // `scaleRange` is retained for external reference; the legacy default+        // config matches it so macOS callers are unaffected.         #expect(ZoomableImageView.scaleRange.lowerBound == 0.5)+        #expect(ZoomableImageView.scaleRange.upperBound == 10.0)     } -    @Test("Max scale constant is 5.0")-    func maxScaleIs50() {-        #expect(ZoomableImageView.scaleRange.upperBound == 5.0)+    @Test("iOS image config spans 0.25 to 10.0")+    func iosImageConfigBounds() {+        #expect(config.minScale == 0.25)+        #expect(config.maxScale == 10.0)     }      // MARK: - Default State -    @Test("Default scale is 1.0")+    @Test("Default scale is within the iOS config bounds")     func defaultScaleIsOne() {-        // The view's @Binding properties start at caller-supplied values.-        // Verify the documented default matches expectations.         let defaultScale: CGFloat = 1.0-        #expect(defaultScale >= ZoomableImageView.scaleRange.lowerBound)-        #expect(defaultScale <= ZoomableImageView.scaleRange.upperBound)+        #expect(defaultScale >= config.minScale)+        #expect(defaultScale <= config.maxScale)     }      @Test("Default offset is zero")@@ -51,44 +57,106 @@ struct ZoomableImageViewTests {         #expect(offset.height == 0)     } -    // MARK: - Scale Clamping Logic+    // MARK: - Scale Clamping (via ZoomMath, iOS image config) -    @Test("Clamping scale below minimum returns minimum")+    @Test("Clamping scale below minimum returns 0.25")     func clampBelowMinimum() {-        let clamped = min(max(0.1, ZoomableImageView.scaleRange.lowerBound),-                          ZoomableImageView.scaleRange.upperBound)-        #expect(clamped == 0.5)+        #expect(ZoomMath.clampScale(0.1, config) == 0.25)     } -    @Test("Clamping scale above maximum returns maximum")+    @Test("Clamping scale above maximum returns 10.0")     func clampAboveMaximum() {-        let clamped = min(max(10.0, ZoomableImageView.scaleRange.lowerBound),-                          ZoomableImageView.scaleRange.upperBound)-        #expect(clamped == 5.0)+        #expect(ZoomMath.clampScale(50.0, config) == 10.0)     }      @Test("Clamping scale within bounds returns same value")     func clampWithinBounds() {-        let value: CGFloat = 2.5-        let clamped = min(max(value, ZoomableImageView.scaleRange.lowerBound),-                          ZoomableImageView.scaleRange.upperBound)-        #expect(clamped == value)+        #expect(ZoomMath.clampScale(2.5, config) == 2.5)     }      @Test("Clamping at exact minimum returns minimum")     func clampAtExactMinimum() {-        let value: CGFloat = 0.5-        let clamped = min(max(value, ZoomableImageView.scaleRange.lowerBound),-                          ZoomableImageView.scaleRange.upperBound)-        #expect(clamped == 0.5)+        #expect(ZoomMath.clampScale(0.25, config) == 0.25)     }      @Test("Clamping at exact maximum returns maximum")     func clampAtExactMaximum() {-        let value: CGFloat = 5.0-        let clamped = min(max(value, ZoomableImageView.scaleRange.lowerBound),-                          ZoomableImageView.scaleRange.upperBound)-        #expect(clamped == 5.0)+        #expect(ZoomMath.clampScale(10.0, config) == 10.0)+    }++    @Test("Stepping up from 1.0 adds the 0.25 zoom step")+    func steppedScaleUp() {+        #expect(ZoomMath.steppedScale(1.0, by: 1, config) == 1.25)+    }++    @Test("Stepping down clamps at the 0.25 minimum")+    func steppedScaleDownClampsAtMinimum() {+        #expect(ZoomMath.steppedScale(0.25, by: -1, config) == 0.25)+    }++    @Test("Stepping up clamps at the 10.0 maximum")+    func steppedScaleUpClampsAtMaximum() {+        #expect(ZoomMath.steppedScale(10.0, by: 1, config) == 10.0)+    }++    // MARK: - Double-Tap (image case: fit == 1.0, target 200%)++    @Test("Double-tap at fit (1.0) zooms to the 200% target")+    func doubleTapFromFitZoomsToTarget() {+        // For a raster image, the fit scale is 1.0 (aspect-fit already fits).+        let target = ZoomMath.doubleTapTarget(current: 1.0, fit: 1.0, config)+        #expect(target == 2.0)+    }++    @Test("Double-tap when zoomed in returns to the fit scale")+    func doubleTapWhenZoomedReturnsToFit() {+        let target = ZoomMath.doubleTapTarget(current: 4.0, fit: 1.0, config)+        #expect(target == 1.0)+    }++    // MARK: - Disabled Predicates Use approxEqual (Req 3.4)++    @Test("Reset stays disabled at a drifted scale of ~1.0")+    func resetDisabledAtDriftedScale() {+        // Float drift after animation/focal math must not leave Reset enabled.+        #expect(isAtRestState(scale: 1.0 + 5e-4, offset: .zero))+        #expect(isAtRestState(scale: 1.0 - 5e-4, offset: CGSize(width: 4e-4, height: -4e-4)))+    }++    @Test("Reset is enabled once scale or offset moves beyond tolerance")+    func resetEnabledWhenAwayFromRest() {+        #expect(!isAtRestState(scale: 1.5, offset: .zero))+        #expect(!isAtRestState(scale: 1.0, offset: CGSize(width: 20, height: 0)))+    }++    // MARK: - Pan Clamp (letterboxed image does not over-pan)++    @Test("Pan clamp pins offset to zero when content fits at scale 1.0")+    func clampOffsetPinsWhenFitting() {+        let displayed = CGSize(width: 300, height: 200)+        let viewport = CGSize(width: 400, height: 300)+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 100, height: 100),+            scale: 1.0,+            displayed: displayed,+            viewport: viewport+        )+        #expect(clamped == .zero)+    }++    @Test("Pan clamp bounds offset to (displayed*scale - viewport)/2 when zoomed")+    func clampOffsetBoundsWhenZoomed() {+        let displayed = CGSize(width: 400, height: 300)+        let viewport = CGSize(width: 400, height: 300)+        // At scale 2.0 the content is 800x600; allowed pan is (800-400)/2 = 200.+        let clamped = ZoomMath.clampOffset(+            CGSize(width: 1000, height: -1000),+            scale: 2.0,+            displayed: displayed,+            viewport: viewport+        )+        #expect(clamped.width == 200)+        #expect(clamped.height == -150)     }      // MARK: - Reset Transform
prismUITests/ImageDetailSheetZoomUITests.swift Added +197 / -0
diff --git a/prismUITests/ImageDetailSheetZoomUITests.swift b/prismUITests/ImageDetailSheetZoomUITests.swiftnew file mode 100644index 0000000..e580a9b--- /dev/null+++ b/prismUITests/ImageDetailSheetZoomUITests.swift@@ -0,0 +1,197 @@+//+//  ImageDetailSheetZoomUITests.swift+//  prismUITests+//+//  UI tests for the iOS ImageDetailSheet zoom controls.+//+//  Requirements covered:+//  - 1.1: zoom in/out/fit/reset buttons present+//  - 3.1: zoom-in disabled at maximum scale+//  - 3.2: zoom-out disabled at minimum scale+//  - 3.3: reset disabled at the Reset state (100%)+//  - 5.4: double-tap changes the scale (not swallowed by drag/pinch)++#if os(iOS)+import XCTest++/// UI tests for the iOS image detail sheet zoom controls.+///+/// Controls are matched by their accessibility identifiers (`zoom.out/in/fit/+/// reset`), not labels or screen positions, so the assertions stay stable across+/// locales — matching `MermaidDiagramSheetUITests`. Tests skip gracefully when no+/// tappable image is present in the test document.+final class ImageDetailSheetZoomUITests: XCTestCase {++    var app: XCUIApplication!++    override func setUpWithError() throws {+        continueAfterFailure = false+        app = XCUIApplication()+        app.launchArguments = ["--uitesting"]+        app.launch()+    }++    override func tearDownWithError() throws {+        app = nil+    }++    // MARK: - Helpers++    /// Opens the image detail sheet by tapping the first tappable image.+    /// - Returns: Whether the sheet was successfully opened.+    @MainActor+    private func openImageDetailSheet() -> Bool {+        let predicate = NSPredicate(+            format: "label CONTAINS[c] 'image' OR label CONTAINS[c] 'photo'"+        )+        let imageButton = app.buttons.matching(predicate).firstMatch++        if imageButton.waitForExistence(timeout: 3) {+            imageButton.tap()+            return app.buttons["Done"].waitForExistence(timeout: 3)+        }++        let images = app.images.firstMatch+        guard images.waitForExistence(timeout: 3) else {+            return false+        }+        images.tap()+        return app.buttons["Done"].waitForExistence(timeout: 3)+    }++    /// Reads the current zoom percentage from the overlay / accessibility value.+    /// - Returns: The current zoom percentage, or nil if not found.+    @MainActor+    private func currentZoomPercentage() -> Int? {+        for text in app.staticTexts.allElementsBoundByIndex {+            let label = text.label+            if let range = label.range(of: #"(\d+)%"#, options: .regularExpression) {+                let number = String(label[range]).replacingOccurrences(of: "%", with: "")+                if let value = Int(number) { return value }+            }+        }+        // Fall back to the overlay's accessibility value.+        let overlay = app.otherElements["Zoom level"].firstMatch+        if overlay.exists {+            let value = overlay.value as? String ?? ""+            if let range = value.range(of: #"(\d+)%"#, options: .regularExpression) {+                let number = String(value[range]).replacingOccurrences(of: "%", with: "")+                return Int(number)+            }+        }+        return nil+    }++    // MARK: - Button Presence (Req 1.1)++    /// All four zoom controls are present in the sheet toolbar.+    @MainActor+    func testFourZoomButtonsExist() throws {+        guard openImageDetailSheet() else {+            throw XCTSkip("No tappable image found - requires test document with image content")+        }++        XCTAssertTrue(app.buttons["zoom.out"].waitForExistence(timeout: 3),+                      "Zoom Out button should exist")+        XCTAssertTrue(app.buttons["zoom.in"].exists, "Zoom In button should exist")+        XCTAssertTrue(app.buttons["zoom.fit"].exists, "Fit to View button should exist")+        XCTAssertTrue(app.buttons["zoom.reset"].exists, "Reset Zoom button should exist")+    }++    // MARK: - Disabled at Reset (Req 3.3)++    /// Reset Zoom is disabled when the sheet first opens at 100%.+    @MainActor+    func testResetDisabledAt100Percent() throws {+        guard openImageDetailSheet() else {+            throw XCTSkip("No tappable image found - requires test document with image content")+        }++        let resetButton = app.buttons["zoom.reset"]+        XCTAssertTrue(resetButton.waitForExistence(timeout: 3), "Reset Zoom button should exist")+        XCTAssertFalse(resetButton.isEnabled,+                       "Reset Zoom should be disabled in the Reset state (100%, zero offset)")+    }++    // MARK: - Disabled at Limits (Req 3.1, 3.2)++    /// Zoom In becomes disabled at the maximum scale; Reset becomes enabled.+    @MainActor+    func testZoomInDisabledAtMaximum() throws {+        guard openImageDetailSheet() else {+            throw XCTSkip("No tappable image found - requires test document with image content")+        }++        let zoomInButton = app.buttons["zoom.in"]+        guard zoomInButton.waitForExistence(timeout: 3) else {+            throw XCTSkip("Zoom In button not found")+        }++        // Zoom in repeatedly until disabled (image max is 1000%).+        for _ in 0..<60 where zoomInButton.isEnabled {+            zoomInButton.tap()+            Thread.sleep(forTimeInterval: 0.1)+        }++        XCTAssertFalse(zoomInButton.isEnabled, "Zoom In should be disabled at maximum scale")+        XCTAssertTrue(app.buttons["zoom.reset"].isEnabled,+                      "Reset Zoom should be enabled once zoomed away from 100%")+    }++    /// Zoom Out becomes disabled at the minimum scale.+    @MainActor+    func testZoomOutDisabledAtMinimum() throws {+        guard openImageDetailSheet() else {+            throw XCTSkip("No tappable image found - requires test document with image content")+        }++        let zoomOutButton = app.buttons["zoom.out"]+        guard zoomOutButton.waitForExistence(timeout: 3) else {+            throw XCTSkip("Zoom Out button not found")+        }++        // Zoom out repeatedly until disabled (image min is 25%).+        for _ in 0..<20 where zoomOutButton.isEnabled {+            zoomOutButton.tap()+            Thread.sleep(forTimeInterval: 0.1)+        }++        XCTAssertFalse(zoomOutButton.isEnabled, "Zoom Out should be disabled at minimum scale")+    }++    // MARK: - Double-Tap (Req 5.4)++    /// A double-tap changes the scale, proving it is not swallowed by the+    /// pinch / drag gestures (Req 5.4).+    @MainActor+    func testDoubleTapChangesScale() throws {+        guard openImageDetailSheet() else {+            throw XCTSkip("No tappable image found - requires test document with image content")+        }++        // Ensure we begin at the Reset state.+        let resetButton = app.buttons["zoom.reset"]+        guard resetButton.waitForExistence(timeout: 3) else {+            throw XCTSkip("Reset Zoom button not found")+        }+        if resetButton.isEnabled {+            resetButton.tap()+            Thread.sleep(forTimeInterval: 0.4)+        }++        XCTAssertFalse(resetButton.isEnabled,+                       "Should start in the Reset state (Reset disabled) before double-tapping")++        // Double-tap the image content area.+        let imageArea = app.images.firstMatch+        let target: XCUIElement = imageArea.exists ? imageArea : app.otherElements.firstMatch+        target.doubleTap()+        Thread.sleep(forTimeInterval: 0.5)++        // The double-tap must have moved away from the Reset state, which is+        // observable through the now-enabled Reset button.+        XCTAssertTrue(resetButton.isEnabled,+                      "Double-tap should change the scale (Reset becomes enabled), not be swallowed by drag/pinch")+    }+}+#endif
prismUITests/MermaidDiagramSheetUITests.swift Modified +122 / -13
diff --git a/prismUITests/MermaidDiagramSheetUITests.swift b/prismUITests/MermaidDiagramSheetUITests.swiftindex 834a594..d2ccdcb 100644--- a/prismUITests/MermaidDiagramSheetUITests.swift+++ b/prismUITests/MermaidDiagramSheetUITests.swift@@ -13,13 +13,14 @@ import XCTest /// - Loading state shows progress indicator /// - Success state shows rendered diagram /// - Error state shows MermaidErrorView-/// - Reset Zoom button functionality+/// - Zoom toolbar controls (in/out/fit/reset), disabled-at-limit, and double-tap+///   (native WKWebView scroll-view zoom — decision 14) /// /// Requirements covered:-/// - 4.4: Modal sheet with rendered diagram-/// - 4.5: Pinch-to-zoom gestures-/// - 4.6: Pan gestures+/// - 1.2: zoom in/out/fit/reset buttons+/// - 3: disabled-at-limit affordances /// - 4.11: Error display with source code+/// - 5: double-tap-to-zoom final class MermaidDiagramSheetUITests: XCTestCase {      var app: XCUIApplication!@@ -95,7 +96,7 @@ final class MermaidDiagramSheetUITests: XCTestCase {          // Wait for rendering to complete         // Look for the SVG content area or the Reset Zoom button (indicates success)-        let resetZoomButton = app.buttons["Reset Zoom"]+        let resetZoomButton = app.buttons["zoom.reset"]          if resetZoomButton.waitForExistence(timeout: 12) {             // Success state - Reset Zoom button appears with successful render@@ -112,8 +113,9 @@ final class MermaidDiagramSheetUITests: XCTestCase {         }     } -    /// Test that Reset Zoom button is present in success state.-    /// Requirement 4.5: Pinch-to-zoom with reset capability+    /// Test that the Reset Zoom button is present in the success state and is+    /// disabled at the initial Reset state, becoming enabled after a zoom-in and+    /// functional thereafter (Req 3.3).     @MainActor     func testResetZoomButtonExists() throws {         guard openDiagramSheet() else {@@ -121,15 +123,18 @@ final class MermaidDiagramSheetUITests: XCTestCase {         }          // Wait for rendering-        let resetZoomButton = app.buttons["Reset Zoom"]+        let resetZoomButton = app.buttons["zoom.reset"]          if resetZoomButton.waitForExistence(timeout: 12) {-            XCTAssertTrue(resetZoomButton.isHittable, "Reset Zoom button should be tappable")+            // Req 3.3: reset is disabled in the initial Reset state.+            XCTAssertFalse(resetZoomButton.isEnabled, "Reset should be disabled at 100%") -            // Tap the button to verify it's functional+            // Zoom in so reset becomes enabled, then tap it.+            app.buttons["zoom.in"].tap()+            XCTAssertTrue(resetZoomButton.isEnabled, "Reset should be enabled after zooming")             resetZoomButton.tap() -            // Button should still exist after tapping+            // Button should still exist after tapping.             XCTAssertTrue(resetZoomButton.exists, "Reset Zoom button should remain after tapping")         } else {             // May be in error state@@ -184,7 +189,7 @@ final class MermaidDiagramSheetUITests: XCTestCase {         }          // Wait for either success or error state-        let resetZoomButton = app.buttons["Reset Zoom"]+        let resetZoomButton = app.buttons["zoom.reset"]         let errorBanner = app.staticTexts["Unable to render diagram"]          // Wait for one of the states@@ -251,7 +256,7 @@ final class MermaidDiagramSheetUITests: XCTestCase {         }          // Wait for success state-        let resetZoomButton = app.buttons["Reset Zoom"]+        let resetZoomButton = app.buttons["zoom.reset"]          guard resetZoomButton.waitForExistence(timeout: 12) else {             throw XCTSkip("Diagram did not render successfully")@@ -264,6 +269,110 @@ final class MermaidDiagramSheetUITests: XCTestCase {         XCTAssertTrue(resetZoomButton.isHittable, "Diagram area should support interactions")     } +    // MARK: - Zoom Control Tests++    /// Waits for the diagram to reach the success state (zoom controls present)+    /// or throws `XCTSkip` if it does not render in time.+    /// - Returns: Whether the success state was reached.+    @MainActor+    private func waitForSuccessState() -> Bool {+        // The reset button (zoom.reset identifier) appears only in the success+        // state, so it is a reliable success signal.+        app.buttons["zoom.reset"].waitForExistence(timeout: 12)+    }++    /// Test that all four zoom controls exist in the success state.+    /// Requirement 1.2: zoom in/out/fit/reset buttons.+    @MainActor+    func testZoomButtonsExist() throws {+        guard openDiagramSheet() else {+            throw XCTSkip("No mermaid diagram card found - requires test document with mermaid content")+        }++        guard waitForSuccessState() else {+            throw XCTSkip("Diagram did not render successfully")+        }++        XCTAssertTrue(app.buttons["zoom.out"].exists, "Zoom Out button should exist")+        XCTAssertTrue(app.buttons["zoom.in"].exists, "Zoom In button should exist")+        XCTAssertTrue(app.buttons["zoom.fit"].exists, "Fit to View button should exist")+        XCTAssertTrue(app.buttons["zoom.reset"].exists, "Reset Zoom button should exist")+    }++    /// Test that reset is disabled at 100% (the initial Reset state) and the+    /// zoom-out limit disables zoom-out after enough zoom-out steps.+    /// Requirements 3.2, 3.3.+    @MainActor+    func testDisabledStatesAtLimits() throws {+        guard openDiagramSheet() else {+            throw XCTSkip("No mermaid diagram card found - requires test document with mermaid content")+        }++        guard waitForSuccessState() else {+            throw XCTSkip("Diagram did not render successfully")+        }++        let zoomIn = app.buttons["zoom.in"]+        let zoomOut = app.buttons["zoom.out"]+        let reset = app.buttons["zoom.reset"]++        // Req 3.3: reset is disabled in the initial Reset state (100%, zero offset).+        XCTAssertFalse(reset.isEnabled, "Reset should be disabled at 100% with zero offset")++        // Zoom in once: reset becomes enabled, zoom-in is still enabled below max.+        XCTAssertTrue(zoomIn.isEnabled, "Zoom In should be enabled below the maximum")+        zoomIn.tap()+        XCTAssertTrue(reset.isEnabled, "Reset should be enabled once zoomed away from 100%")++        // Req 3.1: drive to the maximum scale; zoom-in must disable at the top.+        var guardCount = 0+        while zoomIn.isEnabled && guardCount < 40 {+            zoomIn.tap()+            guardCount += 1+        }+        XCTAssertFalse(zoomIn.isEnabled, "Zoom In should be disabled at the maximum scale")++        // Req 3.2: drive to the minimum scale; zoom-out must disable at the bottom.+        guardCount = 0+        while zoomOut.isEnabled && guardCount < 80 {+            zoomOut.tap()+            guardCount += 1+        }+        XCTAssertFalse(zoomOut.isEnabled, "Zoom Out should be disabled at the minimum scale")+    }++    /// Test that double-tap changes the zoom level rather than being swallowed by+    /// the pinch/drag gestures.+    /// Requirement 5.4: double-tap coexists with pinch and drag.+    @MainActor+    func testDoubleTapChangesZoom() throws {+        guard openDiagramSheet() else {+            throw XCTSkip("No mermaid diagram card found - requires test document with mermaid content")+        }++        guard waitForSuccessState() else {+            throw XCTSkip("Diagram did not render successfully")+        }++        // At 100% (Reset state) reset is disabled. A double-tap should zoom in,+        // which moves the viewer away from the Reset state and enables reset.+        let reset = app.buttons["zoom.reset"]+        XCTAssertFalse(reset.isEnabled, "Reset should be disabled before the double-tap")++        // Double-tap the diagram content area.+        let diagram = app.otherElements+            .matching(NSPredicate(format: "label CONTAINS 'Rendered'")).firstMatch+        let target = diagram.exists ? diagram : app.windows.firstMatch+        target.doubleTap()++        // The double-tap is not swallowed if the scale changed: reset becomes+        // enabled because the viewer is no longer at 100%/zero-offset.+        XCTAssertTrue(+            reset.waitForExistence(timeout: 2) && reset.isEnabled,+            "Double-tap should change the zoom level (Reset becomes enabled)"+        )+    }+     // MARK: - Sheet Dismissal Tests      /// Test that Done button dismisses the sheet.
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex b357825..da45313 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -70,6 +70,7 @@ | [Tables in List Items](#tables-in-list-items) | 2026-05-17 | Done | Render markdown tables that appear inside list items (currently shown as raw markdown); per-row notes deferred (T-1034) | | [macOS URL Drop](#macos-url-drop) | 2026-05-18 | Planned | Drag-and-drop entry point for opening markdown files and `http(s)` URLs onto the Prism window on macOS (T-432) | | [Render HTML Comments](#render-html-comments) | 2026-05-22 | Done | Opt-in toggle to render HTML comments as styled inline annotations (T-450) |+| [iOS Zoom Controls](#ios-zoom-controls) | 2026-05-31 | Done | Zoom buttons, % overlay, double-tap, pan clamping, and a mermaid gesture-responsiveness fix for the iOS image and mermaid fullscreen viewers, matching macOS (T-1419) |  --- @@ -710,3 +711,12 @@ Replace today's raw-HTML fall-through for `<!-- ... -->` with explicit handling: - [design.md](render-html-comments/design.md) - [decision_log.md](render-html-comments/decision_log.md) - [tasks.md](render-html-comments/tasks.md)++## iOS Zoom Controls++Bring the iOS fullscreen image and mermaid viewers (`ImageDetailSheet`, `MermaidDiagramSheet`) to behavioural parity with the macOS windows: zoom in/out/fit/reset buttons, a locale-aware zoom-percentage overlay, double-tap-to-zoom, disabled-at-limit affordances, content-bounded pan clamping, rotation handling, and VoiceOver announcements. Also fixes the laggy mermaid pinch/pan by replacing the per-gesture-frame WKWebView `evaluateJavaScript` transform with a hybrid: live SwiftUI `.scaleEffect` during interaction, committed to the web view's JS at rest so the SVG stays crisp. macOS viewers are unchanged. Covers Transit ticket T-1419.++- [requirements.md](ios-zoom-controls/requirements.md)+- [design.md](ios-zoom-controls/design.md)+- [tasks.md](ios-zoom-controls/tasks.md)+- [decision_log.md](ios-zoom-controls/decision_log.md)
specs/ios-zoom-controls/decision_log.md Added +453 / -0
diff --git a/specs/ios-zoom-controls/decision_log.md b/specs/ios-zoom-controls/decision_log.mdnew file mode 100644index 0000000..4f273d5--- /dev/null+++ b/specs/ios-zoom-controls/decision_log.md@@ -0,0 +1,453 @@+# Decision Log: iOS Zoom Controls++## Decision 1: Match macOS scale ranges and zoom step on iOS++**Date**: 2026-05-30+**Status**: accepted++### Context++iOS image/mermaid viewers clamp pinch-zoom to a 0.5x minimum, while macOS uses 0.25x (images) and 0.25x–5.0x (mermaid) with a 0.25 zoom step for button controls. The feature adds button controls to iOS, which need defined step and limit values.++### Decision++iOS viewers adopt the macOS scale ranges (images 0.25–10.0, mermaid 0.25–5.0) and the 0.25 zoom step. Pinch-zoom is clamped to the same per-viewer limits as the buttons.++### Rationale++T-1419 explicitly asks for parity with macOS. Sharing limits and step keeps the two platforms behaving identically and avoids a mismatch where a button limit differs from the pinch limit on the same viewer.++### Alternatives Considered++- **Keep iOS 0.5x floor, add buttons only**: Preserves current pinch behaviour - Rejected because it leaves a platform inconsistency and contradicts the parity goal.++### Consequences++**Positive:**+- Identical zoom behaviour across platforms.+- Single set of constants can be reasoned about per content type.++**Negative:**+- iOS pinch behaviour changes slightly (can now zoom out further to 0.25x).++---++## Decision 2: Add full macOS affordance set to iOS (double-tap, % overlay, disable-at-limit, VoiceOver announcements)++**Date**: 2026-05-30+**Status**: accepted++### Context++macOS viewers offer double-tap-to-zoom, a zoom-percentage overlay, disabled-at-limit buttons, and VoiceOver announcements. iOS has none of these beyond a single reset button.++### Decision++iOS gains all four affordances in addition to the in/out/fit/reset buttons.++### Rationale++The user selected all four when asked. They are the behaviours that make the macOS viewer feel complete, and the existing iOS reset-zoom code already establishes the VoiceOver-announcement and Reduce-Motion patterns to extend.++### Alternatives Considered++- **Buttons only**: Minimal change - Rejected; the user wants full parity and the affordances are low-cost given existing patterns.++### Consequences++**Positive:**+- Full behavioural parity with macOS.+- Reuses established accessibility and Reduce-Motion patterns.++**Negative:**+- More surface area to implement and test on two iOS viewers.++---++## Decision 3: Tune gesture responsiveness as part of this feature++**Date**: 2026-05-30+**Status**: accepted++### Context++The ticket reports iOS zoom "isn't very responsive". The mermaid viewer applies transforms to an SVG inside a WKWebView via JavaScript, a likely source of stutter; the image viewer uses bare `MagnificationGesture`/`DragGesture`.++### Decision++Investigate and improve gesture responsiveness (e.g. transform-application path for the SVG, gesture clamping) alongside adding the buttons, rather than treating buttons as the sole fix.++### Rationale++The responsiveness complaint is the originating motivation for the ticket; shipping only buttons would leave the core grievance unaddressed.++### Alternatives Considered++- **Buttons only, defer responsiveness**: Smaller scope - Rejected because it does not address the stated problem.++### Consequences++**Positive:**+- Directly addresses the reported pain.++**Negative:**+- Responsiveness work is harder to verify objectively; the design phase must define how it will be validated.++---++## Decision 4: Define fit-to-view as "entire content visible", accepting overlap with reset++**Date**: 2026-05-30+**Status**: accepted++### Context++The iOS viewers lay content out to fit the viewport at scale 1.0 (`.aspectRatio(.fit)` for raster, `max-width:100%` CSS for the SVG), unlike the macOS windows, which display content at intrinsic size in a scrollable window. The macOS fit math (`viewSize / intrinsicSize`) is therefore meaningless on iOS, and a literal port would make Fit and Reset visually identical for most content. Both the design-critic and peer-review-validator reviews flagged this as the central requirements blocker.++### Decision++Fit-to-view is defined as the scale/offset at which the entire content is visible within the viewport. For raster images and for diagrams that already fit at 100%, this equals Reset (1.0). It differs from Reset only for tall mermaid diagrams that overflow the viewport vertically at 100%, where the fit scale is below 1.0. The four-button macOS set is retained.++### Rationale++This gives Fit, Reset, and the double-tap fit endpoint coherent, distinct meanings without copying the broken macOS math, and keeps the button set consistent across platforms. Dropping the Fit button was considered but rejected to preserve the parity the ticket asks for.++### Alternatives Considered++- **Drop the Fit button on iOS (3 buttons)**: Simpler, avoids Fit≈Reset overlap - Rejected because it diverges from the macOS button set the ticket asks to match.+- **Port macOS fit math verbatim**: Maximum parity - Rejected because the math is dimensionally meaningless for CSS-fitted SVG content.++### Consequences++**Positive:**+- Fit is meaningful for tall diagrams; behaviour is well-defined for both content types.++**Negative:**+- Fit and Reset are indistinguishable for most content, which users may notice.++---++## Decision 5: Clamp pan to keep content visible++**Date**: 2026-05-30+**Status**: accepted++### Context++The existing iOS (and macOS) drag gesture applies an unbounded offset, and retaining offset between gestures (cumulative pan) lets a user pan content fully off-screen with only Reset to recover.++### Decision++Constrain the pan offset so at least part of the content always remains within the viewport, and re-clamp on viewport size changes (rotation).++### Rationale++An off-screen-and-stuck state is a usability defect; iOS users expect panning to stay anchored to content. macOS does not clamp, but this is an accepted iOS-specific divergence.++### Alternatives Considered++- **Leave pan unbounded (match macOS)**: Less code - Rejected because it preserves the off-screen-and-stuck defect.++### Consequences++**Positive:**+- Content cannot be lost off-screen.++**Negative:**+- Clamping math must account for current scale and viewport, adding complexity.++---++## Decision 6: Recompute fit and re-clamp offset on viewport change++**Date**: 2026-05-30+**Status**: accepted++### Context++Device rotation changes the viewport size, invalidating any computed fit scale and any retained pan offset. macOS sidesteps this because window resize is rare; iOS rotation is common.++### Decision++On viewport size change, recompute the fit scale and re-clamp the pan offset to the new viewport.++### Rationale++Without recomputation, rotation can leave content clipped or off-centre with no automatic correction.++### Consequences++**Positive:**+- Content stays sensible across rotation.++**Negative:**+- Requires observing viewport size changes in both viewers.++---++## Decision 7: Locale-aware percentage formatting (intentional divergence from macOS)++**Date**: 2026-05-30+**Status**: accepted++### Context++The macOS overlay renders the zoom percentage with raw `Text("\(Int(scale*100))%")`, which violates the project's localisation rules (no raw interpolation in user-facing strings). AC 4.3 requires locale-aware formatting, which contradicts a literal "parity with macOS" reading.++### Decision++The iOS percentage display uses locale-aware number formatting. This is an intentional improvement over the macOS overlay, not a parity match.++### Rationale++The project's localisation rules are mandatory and PR review enforces them; copying the macOS raw interpolation would fail review.++### Consequences++**Positive:**+- Complies with localisation rules.++**Negative:**+- The two platforms format the percentage differently until macOS is updated separately.++---++## Decision 8: Hybrid transform for iOS mermaid — live SwiftUI layer, commit to JS at rest++**Date**: 2026-05-31+**Status**: accepted++### Context++The responsiveness complaint stems from `SVGWebView` calling `evaluateJavaScript("updateTransform(...)")` on every gesture frame (an async cross-process round-trip). The first design proposed applying the transform purely with SwiftUI `.scaleEffect`/`.offset`. Design review found this blurs mermaid text at high zoom, because scaling the web-view layer magnifies a bitmap instead of re-rasterizing the SVG vectors — a regression on the exact content the ticket targets.++### Decision++Use a hybrid: continuous gestures and animated transitions drive a SwiftUI live layer (`.scaleEffect`/`.offset`) over the web view (no per-frame JS); when the interaction settles (gesture end or animation completion), the committed transform is pushed to the web view via a single JS call so the SVG re-rasterizes crisp at rest. The web view always renders at the committed transform; the live layer shows only in-flight motion.++### Rationale++This satisfies Req 7.1 (no per-gesture-frame round-trip) and keeps the diagram crisp whenever the user is not actively manipulating it. Blur is confined to the brief moments of active pinch/drag, which is an acceptable trade.++### Alternatives Considered++- **Pure SwiftUI `.scaleEffect`**: Simplest - Rejected; blurs text at rest.+- **Throttle the JS calls, keep CSS transform**: Stays crisp - Rejected; only reduces, does not eliminate, the per-frame round-trip, so it would not meet Req 7.1.++### Consequences++**Positive:**+- Smooth during gestures, crisp at rest, no per-frame JS.++**Negative:**+- Two-layer transform (committed JS scale × live SwiftUI factor) is more intricate; a one-frame flash is possible at the commit boundary if the live layer resets before the JS transform paints. Requires ordering care and manual verification.++---++## Decision 9: Center-anchored pinch on iOS; focal tracking only for double-tap++**Date**: 2026-05-31+**Status**: accepted++### Context++`MagnificationGesture` has no focal point. The design must state whether pinch zooms about the pinch midpoint (focal) or the view center.++### Decision++iOS pinch zooms about the view center (matching the `.scaleEffect(anchor: .center)` layer). Focal-point tracking (keeping a specific point stationary) is implemented only for double-tap-to-zoom, mirroring macOS. Focal pinch is out of scope.++### Rationale++Center-anchored pinch is simple, predictable, and matches the SwiftUI transform layer's anchor. Focal pinch adds gesture-midpoint math for marginal benefit and is not required by any acceptance criterion.++### Consequences++**Positive:**+- Simple, predictable pinch; one anchor model for the live layer.++**Negative:**+- Pinching does not zoom toward the pinch location; users re-pan after zooming. Acceptable for a reader.++### Implementation note (pre-push review)++The two iOS viewers diverge on double-tap: the image viewer applies `ZoomMath.focalOffset` (keeps the tapped point stationary), while the mermaid viewer's double-tap is centre-anchored (offset goes to `.zero` when zooming to fit, otherwise retains the current offset) because the diagram's hybrid live layer is centre-anchored. Both honour the "focal tracking is double-tap only, never pinch" rule; the diagram simply does not yet apply the focal offset. This is an accepted minor inter-viewer inconsistency, not a regression — centre-anchored double-tap is predictable. A future change could route the diagram double-tap through `focalOffset` for parity.++---++## Decision 10: Disable interactive sheet dismissal while zoomed or panned++**Date**: 2026-05-31+**Status**: accepted++### Context++The iOS viewers are sheets; the system swipe-down-to-dismiss gesture competes with vertical drag-to-pan when the content is zoomed.++### Decision++Disable interactive dismissal (`interactiveDismissDisabled`) whenever the viewer is away from the Reset state (scale ≠ 1.0 or offset ≠ zero); allow it at rest so the sheet still swipes away normally when not zoomed.++### Rationale++Resolves the pan-versus-dismiss conflict (Req 5 coexistence intent) without removing the familiar swipe-to-dismiss when the user is not zoomed. The Done button always dismisses regardless.++### Consequences++**Positive:**+- Vertical pan works when zoomed; swipe-to-dismiss works at rest.++**Negative:**+- A zoomed user must zoom/reset out or tap Done to dismiss; mitigated by Done always being present. (Confirmed: both `ImageDetailSheet` and `MermaidDiagramSheet` already have a Done button in `cancellationAction`.)++---++## Decision 11: Bake the committed transform into the initial SVG HTML and apply/measure via a navigation delegate++**Date**: 2026-05-31+**Status**: accepted++### Context++`SVGWebView.loadHTMLString` is asynchronous, but `updateWebView` calls `evaluateJavaScript("updateTransform(...)")` synchronously straight after. Today the diagram still ends up transformed only because SwiftUI fires `updateWebView` again later and re-pushes unconditionally. The proposed de-dupe guard (decision 8 / Req 7.1) removes that safety net: the single post-reload JS call would land before the page defines `updateTransform()`, fail silently, and the guard would then record the transform as applied — stranding the diagram at identity on both the iOS initial load and the macOS theme-change-while-zoomed path.++### Decision++`makeHTML()` injects the current committed transform into the container's inline style so the very first paint is correct without any JS. `SVGWebView` gains a `WKNavigationDelegate`; on `didFinish` it marks the page loaded, applies the current committed transform once, and reads the rendered SVG's bounding rect (`getBoundingClientRect`) to report the true painted size. While loaded, committed-transform changes go through the guarded `evaluateJavaScript`; the recorded transform is reset whenever `loadHTMLString` is called (on either the svg or the background-hex change), so it re-applies to the fresh DOM.++### Rationale++Injecting the transform into the HTML makes first paint correct with zero flash and removes the load-ordering dependency. Gating JS re-application on `didFinish` guarantees the function exists before it is called. Measuring the painted box makes fit-to-view and pan-clamp accurate for narrow/square/wide diagrams instead of assuming the SVG fills the viewport width.++### Alternatives Considered++- **Record `lastApplied` only in the `evaluateJavaScript` completion handler**: Simpler - Rejected; on a not-yet-loaded page the call errors, so it never records and never retries without extra logic.+- **Keep assuming `displayedSize = (viewportW, viewportW·vbH/vbW)`**: No measurement plumbing - Rejected as the primary path; over-clamps narrow diagrams. Retained only as the pre-`didFinish` fallback.++### Consequences++**Positive:**+- Correct first paint, no per-frame JS, accurate content size; fixes a latent macOS reload strand.++**Negative:**+- Adds a navigation delegate and a measured-size callback to a shared view; more surface, though behaviour-preserving for macOS.++### Implementation note (pre-push review)++The navigation delegate is set on both platforms and denies any navigation whose type is not `.other` (i.e. only the initial `loadHTMLString` proceeds; link clicks inside an SVG are blocked). `SVGWebView` previously had no navigation delegate, so this is technically a behaviour change on macOS as well — but a benign, security-positive one that aligns with the project's documented WKWebView posture ("navigation blocked except initial load"). No macOS diagram relies on intra-SVG link navigation, so the macOS viewers' observable behaviour is unchanged in practice.++---++## Decision 12: Use MagnifyGesture (not the deprecated MagnificationGesture) for new iOS gesture code++**Date**: 2026-05-31+**Status**: accepted++### Context++The shared views use `MagnificationGesture`, deprecated since iOS 17 in favour of `MagnifyGesture`. The project's pre-push gate requires zero warnings, and this feature adds new iOS gesture code.++### Decision++New iOS pinch handling uses `MagnifyGesture`. The macOS path is left on its current gesture to avoid editing a macOS viewer; only the iOS `#if` branch adopts `MagnifyGesture`.++### Rationale++Avoids introducing deprecation warnings in new code while honouring the macOS Non-Goal.++### Consequences++**Positive:**+- No new deprecation warnings; access to `MagnifyGesture` focal data if needed later.++**Negative:**+- The two platforms use different gesture types until macOS is migrated separately.++---++## Decision 13: Fix logic-level review findings; defer hybrid-transform visual/timing polish to manual on-device verification++**Date**: 2026-06-01+**Status**: accepted++### Context++A design-critic review of the merged implementation (tasks 1–12) surfaced several findings. They split cleanly into two groups: logic-level issues verifiable headlessly (by compile, unit test, or reasoning), and visual/timing behaviours of the iOS mermaid hybrid WKWebView transform whose correctness can only be judged on a running device. The implementation was produced by parallel subagents in a headless environment with no interactive simulator, and the spec already routes the hybrid-transform behaviours to a manual verification pass (Req 7.3; design "Manual verification" on the iPhone 17 Pro simulator).++### Decision++Fix the three logic-level findings immediately; document the four hybrid-transform findings as known items for the manual verification task (task 13) rather than implementing delicate animation/timing changes blind.++Fixed now:+1. The iOS image double-tap now routes through the shared `applyZoom` applier so it respects Reduce Motion (Req 9.3) and posts a VoiceOver announcement (Req 9.2), matching the diagram viewer.+2. `SVGWebView`'s `didFinish` measurement divides the `getBoundingClientRect` box by the committed scale, so the reload-while-zoomed path (e.g. an iOS theme change at scale ≠ 1) reports the content's true unscaled layout size, not a scale-inflated one.+3. `ImageDetailSheetZoomUITests` matches the zoom controls by accessibility identifier (`zoom.out/in/fit/reset`) rather than label, for locale stability and consistency with `MermaidDiagramSheetUITests`.++Deferred to the manual pass:+- **Discrete mermaid zoom animation (Req 7.2)**: buttons/double-tap mutate the committed `scale`/`offset` inside `withAnimation`, but on iOS those bindings drive the WKWebView CSS transform (not SwiftUI-animatable) while the live SwiftUI layer is at identity, so discrete diagram changes snap rather than animate. A correct fix drives the live layer current→target then commits — coupled to the flash-timing item below.+- **Diagram rotation re-clamp (Req 8.2)**: on a viewport change with no HTML reload, no new `getBoundingClientRect` fires, so the offset is re-clamped against the last measured/estimated content size.+- **Percentage overlay during a continuous diagram pinch (Req 4.2)**: the overlay reads the committed scale, which the live layer holds constant during a pinch, so it updates at commit rather than continuously (the image viewer updates live and is unaffected).+- **Commit-boundary flash (Req 7.3)**: the live layer collapses on the next run-loop tick (the design's documented fallback) rather than in the `evaluateJavaScript` completion handler; whether a flash is visible is exactly what the manual pass checks.++### Rationale++The logic-level fixes are clear requirement/correctness gaps that compile-and-reason verification covers, so fixing them headlessly is safe. The deferred items are visual and frame-timing behaviours of the WKWebView hybrid transform; the design explicitly mandates judging them on-device (Req 7.3), and blind changes risk introducing the very commit-boundary flash the hybrid design was built to avoid. The image viewer — the more common path — has none of the deferred gaps.++### Alternatives Considered++- **Implement the live-layer discrete animation blind**: Rejected — unverifiable headlessly and likely to reintroduce a commit-boundary flash; the trade-off needs on-device tuning.+- **Leave all findings unfixed and undocumented**: Rejected — hides known partial gaps against Req 7.2 / 8.2 / 4.2 / 7.3.+- **Block the feature until a human runs the simulator**: Rejected — the safe fixes and the bulk of the feature are complete and independently verifiable; the remaining items are precisely the spec's existing manual-verification scope.++### Consequences++**Positive:**+- The accessibility, measurement, and test-stability gaps are closed and verifiable now.+- The deferred items are recorded with file-level precision for the manual pass, not lost.++**Negative:**+- Until the manual pass, the mermaid viewer's discrete zoom snaps instead of animating, its percentage overlay lags during a continuous pinch, and rotation re-clamp on diagrams may use a slightly stale content size.++### Impact++`prism/Views/ZoomableImageView.swift`, `prism/Views/SVGWebView.swift`, `prismUITests/ImageDetailSheetZoomUITests.swift` (fixes); `prism/Views/DiagramView.swift`, `prism/Views/MermaidPlaceholderCard.swift` (deferred items, to be confirmed/addressed during task 13).++---++## Decision 14: Use the WKWebView's native scroll-view zoom for iOS mermaid (supersedes the hybrid transform)++**Date**: 2026-06-01+**Status**: accepted (supersedes the iOS-mermaid portions of Decisions 8 & 11)++### Context++On-device testing of the merged feature showed that iOS image zoom is smooth, but iOS mermaid zoom still lags during a pinch and the zoom magnitude per pinch feels far too large. Root cause: the hybrid transform (Decision 8) applies a SwiftUI `.scaleEffect`/`.offset` to the `WKWebView` host. The design assumed that is a cheap GPU layer transform like it is for a native `Image` (why the image path is smooth), but `WKWebView` composites its content out-of-process, so transforming the host re-rasterises/re-composites it badly — the lag — and the live scale does not track the fingers, so a pinch gives no gradual feedback and snaps to the committed value on release (the "too big" jump). Per-frame JS CSS transforms (the pre-hybrid approach) had the same root problem from the other direction. You cannot get smooth, finger-tracking pinch-zoom of a web view by transforming it from the SwiftUI/JS side.++### Decision++The iOS mermaid viewer uses the `WKWebView`'s built-in `UIScrollView` zoom — the same engine Safari uses to pinch-zoom a page — instead of the hybrid transform. A new iOS-only `DiagramZoomWebView` enables native pinch-zoom/pan (viewport allows user scaling; `scrollView.minimum/maximumZoomScale` set); a small `@Observable DiagramZoomController` bridges the toolbar buttons and double-tap to `setZoomScale` and mirrors the live `zoomScale` back for the percentage overlay and disabled-at-limit states. The live value comes from the scroll view's pinch gesture recogniser plus an optimistic update on each command — `UIScrollView.zoomScale` is not reliably KVO-observable, and the synced value is clamped so an over-pinch bounce-back does not leave a stale out-of-range reading. `DiagramView` becomes macOS-only (it still drives the macOS window's CSS-transform path, unchanged); the iOS hybrid code is removed from it and from `MermaidPlaceholderCard`'s sheet.++### Rationale++Native scroll-view zoom is the platform's purpose-built mechanism for zooming web content: it is smooth, tracks the fingers 1:1 (fixing the "too big" magnitude), and re-rasterises the SVG vectors crisply at any zoom (no blur, no per-frame JS). It also removes the entire hybrid live-layer/fold/commit/guard machinery for the diagram, simplifying the code. The image viewer keeps its pure-SwiftUI path (a native layer, already smooth). macOS is untouched.++### Alternatives Considered++- **Keep the hybrid, tune it**: Rejected — the lag is inherent to transforming a `WKWebView` from the host, not a tuning issue.+- **Rasterise the SVG and reuse the image path**: Rejected — would blur beyond the raster resolution at high zoom (loses vector crispness); native scroll-view zoom keeps vectors crisp.++### Consequences++**Positive:**+- Smooth, finger-tracking, crisp pinch-zoom and pan for mermaid; correct zoom magnitude; far less code on the iOS diagram path; resolves the Decision-13 deferred mermaid items (animation/overlay-lag/flash are moot — there is no live layer or commit boundary).++**Negative:**+- The iOS `SVGWebView` struct and its hybrid-transform machinery (baked transform, `onMeasure`, the apply-transform guard) are now unused on iOS — they remain only for the macOS path and `SVGWebViewTests`; a follow-up can trim the now-iOS-dead surface. The fit-to-view and double-tap targets are computed against the scroll view's measured content rather than `ZoomMath`, so some `ZoomMath` helpers (`intrinsicSVGSize`, `displayedSize(.fitWidth)`) are now exercised only by the image path and tests.+- `isAtRest` keys off zoom only (`abs(zoomScale - 1) < 0.01`), not the scroll view's `contentOffset` — observing offset reactively would require becoming the scroll view's delegate, which risks breaking the (device-confirmed) native zoom. Consequence: for a tall diagram panned at 100%, Reset stays disabled until the user zooms. `reset()` resets `contentOffset` when invoked, and Fit (which the tall-diagram case calls for) works; accepted as a minor limitation.+- Verified on device by the user across two rounds: round 1 confirmed pinch/pan are smooth and crisp; round 2 surfaced that the toolbar controls were inert (the overlay stuck at 100%, Reset hidden, zoom in/out moving once) because `UIScrollView.zoomScale` is not KVO-observable — fixed by tracking zoom via the pinch recogniser plus optimistic per-command updates.++### Impact++New `prism/Views/DiagramZoomWebView.swift` (iOS); `prism/Views/DiagramView.swift` (now macOS-only); `prism/Views/MermaidPlaceholderCard.swift` (sheet rewired to the controller). `prism/Views/SVGWebView.swift` unchanged (macOS owner; iOS struct now unused).++---
specs/ios-zoom-controls/design.md Added +149 / -0
diff --git a/specs/ios-zoom-controls/design.md b/specs/ios-zoom-controls/design.mdnew file mode 100644index 0000000..b05e776--- /dev/null+++ b/specs/ios-zoom-controls/design.md@@ -0,0 +1,149 @@+# Design: iOS Zoom Controls++> **Implementation note (decision 14):** the iOS mermaid viewer ships with the+> WKWebView's **native scroll-view zoom**, not the SwiftUI/JS hybrid transform+> this document originally specified. On-device testing showed that transforming+> a `WKWebView` from the host (SwiftUI `.scaleEffect` or per-frame JS) cannot+> track the fingers smoothly. The hybrid sections below are retained for history+> but are superseded for iOS mermaid by the "native scroll-view zoom" sections.+> The image viewer is unchanged from the original design.++## Overview++Add zoom buttons, a zoom-percentage display, double-tap-to-zoom, pan clamping, rotation handling, and VoiceOver announcements to the two iOS fullscreen viewers (`ImageDetailSheet`, `MermaidDiagramSheet`). The image viewer zooms a native SwiftUI `Image` (already smooth). The mermaid viewer uses the `WKWebView`'s built-in `UIScrollView` zoom (the engine Safari uses) so pinch/pan are smooth and the SVG stays crisp at any zoom. The macOS viewers are unchanged.++## Architecture++### Where the logic lives++- **Image path** (both platforms): `ZoomableImageView` + a pure, unit-tested `ZoomMath`/`ZoomConfig` layer in `ZoomControls.swift` (clamp/fit/step/double-tap/focal math). The iOS image sheet drives buttons through the shared `zoomToolbarButtons` and `ZoomPercentageOverlay`.+- **iOS mermaid path**: a new iOS-only `DiagramZoomWebView.swift` containing `DiagramZoomWebView` (a `UIViewRepresentable` enabling the web view's native zoom) and `DiagramZoomController` (an `@Observable @MainActor` bridge). The mermaid sheet reuses the same `zoomToolbarButtons` and `ZoomPercentageOverlay`, but driven by the controller's `zoomScale` rather than SwiftUI transform state.+- **macOS mermaid path**: `DiagramView` (now macOS-only) + `SVGWebView`'s CSS/JS transform, unchanged.++```+ZoomControls.swift           ZoomConfig, ZoomMath (image-path math),+                             ZoomPercentageOverlay, zoomToolbarButtons, announceZoom+DiagramZoomWebView.swift     DiagramZoomController (@Observable), DiagramZoomWebView (iOS)+ZoomableImageView.swift      iOS pinch/pan/double-tap via ZoomMath; macOS unchanged+DiagramView.swift            macOS-only (mermaid window CSS-transform path)+SVGWebView.swift             macOS mermaid renderer (iOS no longer uses it)+```++### Platform gating (macOS Non-Goal compliance)++No macOS view file is changed in behaviour. `ZoomableImageView` gains a defaulted `config:` (macOS keeps `.legacyImage`). `DiagramView` is made macOS-only and keeps its existing gesture/CSS-transform body. `SVGWebView` keeps its de-dupe guard and reset-on-reload (behaviour-preserving for the macOS window, and it protects the macOS theme-change-while-zoomed path). The iOS mermaid path is a brand-new view that does not touch any macOS code.++### Call-site / parity audit++| Symbol | Callers | Change | Rationale |+|---|---|---|---|+| `ZoomableImageView` | `ImageDetailSheet` (iOS), `ImageDetailWindow` (macOS) | iOS call site + `config:` param (defaults to `0.5...10.0`) | iOS passes `.iosImage` (`0.25...10.0`) |+| `DiagramView` | `MermaidDiagramWindow` (macOS) only | made macOS-only; iOS hybrid removed | iOS uses `DiagramZoomWebView` instead (decision 14) |+| `DiagramZoomWebView` / `DiagramZoomController` | `MermaidDiagramSheet` (iOS) | new (iOS-only) | native scroll-view zoom |+| `SVGWebView` | `DiagramView` (macOS) | macOS owner; guard + reset-on-reload retained | iOS no longer uses it; its iOS struct is now unused (follow-up to trim) |+| `ZoomableImageViewTests` | — | updated | corrected the stale `upperBound == 5.0` assertion to the `.iosImage` values |+| `extractSVGSize` (macOS `MermaidDiagramWindow`) | — | none | macOS copy left untouched |++`MermaidDiagramSheet` is defined in `prism/Views/MermaidPlaceholderCard.swift`; the image sheet is `prism/Views/ImageDetailSheet.swift`.++### Mermaid responsiveness — native scroll-view zoom (Req 7, decision 14)++The `WKWebView`'s internal `UIScrollView` zooms web content natively — smooth, finger-tracking, and crisp (the web engine re-rasterises the SVG vectors at the new scale). The earlier code disabled it; `DiagramZoomWebView` enables it:++- The viewport meta allows user scaling (`minimum-scale=0.25, maximum-scale=5.0`, no `user-scalable=no`); `scrollView.minimumZoomScale`/`maximumZoomScale` are set to the same bounds so programmatic `setZoomScale` works. There is no host transform — the scroll view owns zoom and pan.+- **Pinch and pan**: handled entirely by the scroll view (no per-gesture-frame JavaScript from our code — Req 7.1). Crisp at any zoom (Req 7, the originating complaint).+- **Live zoom tracking**: `UIScrollView.zoomScale` is **not reliably KVO-observable**, so the controller's `zoomScale` is updated two ways instead: (a) a target on the scroll view's `pinchGestureRecognizer` mirrors the live scale during a pinch (`syncFromScrollView`), and (b) every control-driven zoom updates `zoomScale` **optimistically** to the commanded value (the animated `setZoomScale` settles there). This is the fix for the controls being inert when KVO never fired.++### DiagramZoomController (iOS mermaid)++```swift+@MainActor @Observable final class DiagramZoomController {+    var zoomScale: CGFloat = 1.0           // 1.0 == fit-to-width+    var reduceMotion: Bool = false+    let config: ZoomConfig                 // .iosDiagram (0.25–5.0, step 0.25)+    @ObservationIgnored weak var scrollView: UIScrollView?+    @ObservationIgnored var contentHeight: CGFloat = 0   // unscaled painted height (measured on load)++    var canZoomIn: Bool   { zoomScale < config.maxScale - 0.001 }+    var canZoomOut: Bool  { zoomScale > config.minScale + 0.001 }+    var isAtRest: Bool    { abs(zoomScale - 1.0) < 0.01 }            // Reset disabled / dismiss allowed+    var fitScale: CGFloat // clamp(min(1, viewportH / contentHeight), minScale, 1)++    func zoomIn(); func zoomOut(); func reset(); func fit(); func toggleZoom()   // -> setZoom+    func syncFromScrollView()             // pinch recogniser -> zoomScale = scrollView.zoomScale+    // setZoom(_:): clamp -> scrollView.setZoomScale(_, animated: !reduceMotion);+    //             zoomScale = clamped (optimistic); announceZoom(clamped)  (Req 9.2/9.3)+}+```++- **Fit** is computed on demand from the measured `contentHeight` and the current `scrollView.bounds.height`, so it is correct after rotation without a re-measure. `contentHeight` is the SVG's painted height at scale 1.0, measured once via `getBoundingClientRect` on `WKNavigationDelegate.didFinish`.+- **Double-tap** (`UITapGestureRecognizer(numberOfTapsRequired: 2)` on the scroll view) toggles between `fitScale` and `min(doubleTapTarget, maxScale)`. The web view's own double-tap-to-zoom does not act on a standalone SVG, so this drives it (Req 5).+- **Pan limits and rotation** (Req 6, 8) are handled by the scroll view natively; there is no `ZoomMath.clampOffset` on this path.++### Image path — displayed size, fit, and pan clamp (Req 1.6, 6, 8; decision 4)++Unchanged from the original design and still implemented via `ZoomMath`:++- `displayedSize` = aspect-fit of `image.size` into the viewport.+- **Fit scale** = `1.0` for images (already fit; Fit coincides with Reset — decision 4).+- **Pan clamp** = single per-axis rule on `displayedSize` (content edges, not the frame, bound the pan), recomputed and re-clamped on rotation.++### Pinch anchor and gesture composition (decision 9, 12, 14; Req 5.4)++- **Image (iOS)**: `MagnifyGesture` (not the deprecated `MagnificationGesture` — decision 12) centre-anchored + `DragGesture` (`simultaneousGesture`) + `SpatialTapGesture(count: 2)` focal double-tap.+- **Mermaid (iOS)**: pinch/pan are the scroll view's native recognisers; a `UITapGestureRecognizer(2)` adds double-tap. The three coexist natively (Req 5.4).+- **Sheet dismissal**: `interactiveDismissDisabled` is applied when the viewer is away from rest — the image sheet uses the `approxEqual` rule; the mermaid sheet uses `!controller.isAtRest`. The Done button always dismisses (decision 10).++### Tolerances and disabled-state (Req 3)++Image-path disabled predicates use `ZoomMath.approxEqual` (eps `1e-3`) rather than exact `CGFloat` equality. The mermaid controller uses small epsilons in `canZoomIn`/`canZoomOut`/`isAtRest` for the same reason. Disabled-at-limit and Reset/dismiss state derive from the true scale, never the rounded percentage (Req 3.4).++### Home-state semantics for tall diagrams (documented divergence)++For a tall diagram whose `fitScale < 1.0`: **Fit** → `fitScale` (whole diagram visible), **Reset** → 1.0 (fit-to-width, vertically pannable), **double-tap restore** → `fitScale`. Deliberate; for images and short diagrams all three coincide at 100%.++## Components and Interfaces++### ZoomConfig+```swift+struct ZoomConfig {+    let minScale: CGFloat; let maxScale: CGFloat; let zoomStep: CGFloat; let doubleTapTarget: CGFloat // 2.0+    static let iosImage   = ZoomConfig(minScale: 0.25, maxScale: 10.0, zoomStep: 0.25, doubleTapTarget: 2.0)+    static let iosDiagram = ZoomConfig(minScale: 0.25, maxScale: 5.0,  zoomStep: 0.25, doubleTapTarget: 2.0)+    static let legacyImage   = ZoomConfig(minScale: 0.5, maxScale: 10.0, zoomStep: 0.25, doubleTapTarget: 2.0)+    static let legacyDiagram = ZoomConfig(minScale: 0.5, maxScale: 5.0,  zoomStep: 0.25, doubleTapTarget: 2.0)+}+```++### ZoomMath (pure, testable — image path)+`clampScale`, `steppedScale`, `displayedSize(content:viewport:mode:)`, `fitScale`, `clampOffset`, `doubleTapTarget`, `focalOffset`, `approxEqual` — used by the image viewer. `intrinsicSVGSize` and `displayedSize(.fitWidth)` remain but are now exercised only by tests (the diagram seeding that used them was removed when the mermaid path moved to native zoom). Contracts: `clamp*` idempotent and within bounds; `fitScale ∈ [minScale, 1.0]`; `doubleTapTarget` strictly greater than `fit` when toggling up; `focalOffset` keeps the focal point stationary pre-clamp.++### ZoomPercentageOverlay (Req 4)+Bottom-right capsule, locale-aware: `Text(scale, format: .percent.precision(.fractionLength(0)))`. The mermaid sheet passes `controller.zoomScale`; the image sheet passes its committed scale.++### zoomToolbarButtons + announceZoom (Req 1, 3, 9)+`@ToolbarContentBuilder` for zoom-out/in/fit/reset with the macOS SF Symbols and localised accessibility labels; disabled rules as above. `announceZoom(_:)` posts a localised VoiceOver announcement (`zoom.announcement.%@`, default `"Zoomed to …"`) — called by the image applier and by the mermaid controller's `setZoom`.++### DiagramZoomWebView (iOS)+`UIViewRepresentable` wrapping `WKWebView`: enables native scroll-view zoom, sets the controller's `scrollView`, adds the pinch-recogniser target and the double-tap recogniser, loads the SVG via `SVGCSS.zoomableHTML` (user-scalable viewport, no transform), and on `didFinish` measures the painted height into `controller.contentHeight`. A `WKNavigationDelegate` denies non-initial navigation (matching the project's WKWebView posture). `SVGCSS` holds the shared HTML template and `Color`→hex helper.++### ZoomableImageView changes (iOS)+`var config: ZoomConfig = .legacyImage`; `#if os(iOS)` adds `GeometryReader` viewport, aspect-fit `displayedSize`, `MagnifyGesture` (live-product clamped) + drag clamp + focal double-tap. Raster path stays pure SwiftUI. macOS unchanged.++### DiagramView (now macOS-only) + SVGWebView+`DiagramView` is wrapped in `#if os(macOS)` and keeps its `MagnificationGesture`/`DragGesture` + `SVGWebView` CSS-transform body for the macOS window. `SVGWebView` keeps the de-dupe guard, reset-on-reload, baked initial transform, and optional `onMeasure` (default no-op); these serve/protect the macOS path. The iOS `SVGWebView` struct compiles but is unused (follow-up to trim).++### Sheet changes+- **`ImageDetailSheet`**: `viewSize`/content-size state, `zoomToolbarButtons`, `ZoomPercentageOverlay`, rotation re-clamp, `interactiveDismissDisabled` via `approxEqual`, keeps `ShareLink` + Done.+- **`MermaidDiagramSheet`**: owns `@State DiagramZoomController(config: .iosDiagram)`, hosts `DiagramZoomWebView`, drives `zoomToolbarButtons` actions to the controller, reads `controller.zoomScale` for the overlay and disabled states, `interactiveDismissDisabled(!controller.isAtRest)`, syncs `reduceMotion` to the controller, keeps render-state gating + Done. The previous committed-scale/`ZoomMath` machinery on this path is removed.++## Testing Strategy++### Unit tests (Swift Testing) — `ZoomMathTests`+Cover the image-path math: `clampScale`/`steppedScale` (incl. a non-grid value) at both limits; `displayedSize` (aspect-fit + fit-width); `fitScale` (short=1.0, tall<1, extreme-tall clamped to `minScale`); `clampOffset` (pin at ≤fit, bounded at >1, tall-axis pan, never exceeds bounds, no letterbox over-pan); `doubleTapTarget` (at-fit→target, off-fit→fit, fit≥target→max); `focalOffset` (stationary pre-clamp); `approxEqual`; plus parameterised idempotence/bounds invariants. `SVGWebViewTests` cover the macOS transform guard + reset-on-reload. `ZoomableImageViewTests` updated to the `.iosImage` config.++### UI tests (`prismUITests`)+`MermaidDiagramSheetUITests` and the image-sheet test assert the four zoom buttons exist (by accessibility identifier), zoom-in/out toggle disabled state at the limits, reset is disabled at 100%, and double-tap changes scale (Req 5.4).++### Manual / on-device verification (Req 7.3 — device-only)+The mermaid native-zoom behaviour reproduces only on a physical device, so it is verified by hand (the lag and control regressions were both found and confirmed fixed this way): (a) pinch/pan are smooth and the diagram is crisp at any zoom; (b) the % overlay tracks pinch and buttons; (c) zoom-in/out step repeatedly to the limits and disable there; (d) Reset appears once off 100% and returns to 100%; (e) Fit shows the whole of a tall diagram (and equals Reset for a diagram that already fits); (f) double-tap toggles fit↔target without fighting the native pinch.
specs/ios-zoom-controls/requirements.md Added +119 / -0
diff --git a/specs/ios-zoom-controls/requirements.md b/specs/ios-zoom-controls/requirements.mdnew file mode 100644index 0000000..ab429db--- /dev/null+++ b/specs/ios-zoom-controls/requirements.md@@ -0,0 +1,119 @@+# Requirements: iOS Zoom Controls++## Introduction++The iOS fullscreen image viewer (`ImageDetailSheet`) and mermaid diagram viewer (`MermaidDiagramSheet`) currently expose only pinch-to-zoom, drag-to-pan, and a single Reset button, while the macOS equivalents (`ImageDetailWindow`, `MermaidDiagramWindow`) offer zoom-in/out/fit/reset buttons, a live zoom-percentage overlay, double-tap-to-zoom, and disabled-at-limit affordances. This feature brings the iOS viewers to behavioural parity with macOS where it makes sense for touch, and addresses reports that the iOS pinch/pan gestures feel unresponsive. Because the iOS viewers lay content out to fit the viewport at 100% (unlike the macOS windows, which show content at intrinsic size), some behaviours are defined for iOS on their own terms rather than copied literally from macOS; these divergences are called out per requirement.++## Definitions++- **Fit scale**: the scale at which the entire content is visible within the available viewport. For a raster image laid out to fit, and for a mermaid diagram no taller than the viewport at 100%, the fit scale is 1.0 (100%). For a mermaid diagram that overflows the viewport vertically at 100%, the fit scale is below 1.0.+- **Reset**: scale exactly 1.0 (100%) with zero pan offset.+- **Viewport**: the content area of the viewer available for displaying the image or diagram.++## Non-Goals++- Changing the macOS zoom viewers, which already have the target behaviour.+- Adding scroll-wheel / trackpad zoom on iOS (a desktop input affordance with no touch equivalent).+- Altering the inline preview cards (`ImageBlockView`, `MermaidPreviewCard`) or how the fullscreen viewers are launched.+- Changing what the viewers display (image loading, SVG rendering, sharing) beyond zoom and pan.+- Introducing zoom controls for any view other than the two iOS fullscreen viewers.++## Requirements++### 1. iOS Zoom Buttons++**User Story:** As an iOS reader, I want zoom-in, zoom-out, fit, and reset buttons in the fullscreen image and mermaid viewers, so that I can adjust zoom precisely without relying on pinch gestures.++**Acceptance Criteria:**++1. <a name="1.1"></a>The iOS image viewer (`ImageDetailSheet`) SHALL provide zoom-in, zoom-out, fit-to-view, and reset controls.  +2. <a name="1.2"></a>The iOS mermaid viewer (`MermaidDiagramSheet`) SHALL provide zoom-in, zoom-out, fit-to-view, and reset controls.  +3. <a name="1.3"></a>WHEN the user activates zoom-in, the system SHALL increase the scale by one zoom step, clamped to the maximum scale.  +4. <a name="1.4"></a>WHEN the user activates zoom-out, the system SHALL decrease the scale by one zoom step, clamped to the minimum scale.  +5. <a name="1.5"></a>WHEN the user activates reset, the system SHALL return the viewer to the Reset state (scale 1.0, zero offset).  +6. <a name="1.6"></a>WHEN the user activates fit-to-view AND the viewport size and content size are known, the system SHALL set the scale to the fit scale and the pan offset to zero. Fit-to-view and reset MAY produce the same visible result when the fit scale is 1.0.  +7. <a name="1.7"></a>WHEN the viewport size or content size required to compute the fit scale is not yet available, the fit-to-view control SHALL be disabled.  +8. <a name="1.8"></a>The zoom controls SHALL use the same SF Symbols as the macOS viewers (`plus.magnifyingglass`, `minus.magnifyingglass`, `arrow.up.left.and.arrow.down.right`, `1.magnifyingglass`).  ++### 2. Scale Range and Step++**User Story:** As a reader who uses both platforms, I want zoom limits to behave consistently with macOS, so that the viewers feel familiar.++**Acceptance Criteria:**++1. <a name="2.1"></a>The iOS image viewer SHALL use a scale range of 0.25 to 10.0, matching `ImageDetailWindow`.  +2. <a name="2.2"></a>The iOS mermaid viewer SHALL use a scale range of 0.25 to 5.0, matching `MermaidDiagramWindow`.  +3. <a name="2.3"></a>The button-driven zoom step SHALL be 0.25, matching the macOS viewers.  +4. <a name="2.4"></a>The zoom step SHALL apply to the button-driven zoom-in and zoom-out controls only; pinch-zoom SHALL remain continuous and SHALL NOT snap to step increments.  +5. <a name="2.5"></a>Pinch-zoom on each iOS viewer SHALL be clamped to the same minimum and maximum scale as that viewer's button controls.  ++### 3. Disabled-at-Limit Affordances++**User Story:** As an iOS reader, I want zoom buttons to indicate when a limit is reached, so that I understand why further zooming has no effect.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN the current scale is at or above the maximum, the zoom-in control SHALL be disabled.  +2. <a name="3.2"></a>WHEN the current scale is at or below the minimum, the zoom-out control SHALL be disabled.  +3. <a name="3.3"></a>WHEN the viewer is already in the Reset state (scale 1.0, zero offset), the reset control SHALL be disabled. For content whose fit scale is 1.0, the pan clamp in [6.1](#6.1) forces zero offset at scale 1.0 (the zero-offset consequence in [6.2](#6.2)), so this control is enabled only after the user zooms or pans away from the Reset state; this is intended.  +4. <a name="3.4"></a>The enabled/disabled state of every control SHALL be derived from the true scale and offset, not from the rounded percentage shown in the display ([4.1](#4.1)).  ++### 4. Zoom Percentage Display++**User Story:** As an iOS reader, I want to see the current zoom level, so that I know how far I have zoomed.++**Acceptance Criteria:**++1. <a name="4.1"></a>Both iOS viewers SHALL display the current scale as a whole-number percentage. The display is informational only; control state and limits are governed by [3.4](#3.4).  +2. <a name="4.2"></a>The percentage display SHALL update as the scale changes via buttons, pinch, or double-tap.  +3. <a name="4.3"></a>The percentage value SHALL be produced with locale-aware number formatting rather than raw string interpolation. This is an intentional divergence from the macOS overlay, which uses raw interpolation; the iOS implementation follows the project's localisation rules.  ++### 5. Double-Tap to Zoom++**User Story:** As an iOS reader, I want to double-tap to toggle zoom, so that I can quickly enlarge or restore the content without buttons or pinch.++**Acceptance Criteria:**++1. <a name="5.1"></a>The double-tap zoomed target SHALL be 200% clamped to the viewer's maximum scale; WHEN the fit scale is at or above 200%, the zoomed target SHALL instead be the viewer's maximum scale, so the target is always greater than the fit scale.  +2. <a name="5.2"></a>WHEN the current scale is at (within a small tolerance of) the fit scale, a double-tap SHALL animate the viewer to the zoomed target defined in [5.1](#5.1).  +3. <a name="5.3"></a>WHEN the current scale is not at the fit scale, a double-tap SHALL animate the viewer to the fit scale with zero pan offset.  +4. <a name="5.4"></a>The double-tap gesture SHALL coexist with pinch-zoom and drag-to-pan such that none of the three cancels another.  ++### 6. Pan Behaviour++**User Story:** As an iOS reader, I want panning to stay anchored to the content, so that I cannot lose the content off-screen.++**Acceptance Criteria:**++1. <a name="6.1"></a>The pan offset SHALL be clamped per axis by a single rule: along an axis where the content at the current scale is larger than the viewport, the offset SHALL be constrained so the content's edges cannot move inside the corresponding viewport edges (no empty gap appears); along an axis where the content fits within the viewport, the offset SHALL be constrained to zero so the content stays in its fitted position.  +2. <a name="6.2"></a>A consequence of [6.1](#6.1): WHEN the content fits entirely within the viewport on both axes, the pan offset SHALL be zero and dragging SHALL have no lasting effect.  +3. <a name="6.3"></a>Pan offset SHALL be retained between successive drag gestures so that panning is cumulative rather than resetting on each touch, subject to the clamp in [6.1](#6.1).  ++### 7. Gesture Responsiveness++**User Story:** As an iOS reader, I want pinch and pan to track my fingers smoothly, so that zooming does not feel laggy.++**Acceptance Criteria:**++1. <a name="7.1"></a>WHEN a continuous pinch or drag gesture updates the scale or offset, the mermaid viewer SHALL apply the transform to the rendered diagram without a per-gesture-frame asynchronous round-trip to the web view's JavaScript context.  +2. <a name="7.2"></a>WHEN a button, double-tap, or reset changes the scale or offset, the change SHALL be animated; WHEN a continuous pinch or drag changes the scale or offset, the change SHALL be applied without an added animation.  +3. <a name="7.3"></a>A documented manual verification SHALL confirm that continuous pinch and pan on the mermaid viewer track the gesture without visible stutter on a reference device defined in the design document.  ++### 8. Viewport Changes++**User Story:** As an iOS reader, I want zoom and pan to stay sensible when I rotate the device, so that the content does not end up clipped or off-centre.++**Acceptance Criteria:**++1. <a name="8.1"></a>WHEN the viewport size changes (e.g. device rotation), the system SHALL recompute the fit scale.  +2. <a name="8.2"></a>WHEN the viewport size changes, the system SHALL re-clamp the pan offset to the clamp in [6.1](#6.1) for the new viewport, applied without additional animation beyond the system's own rotation transition.  ++### 9. Accessibility++**User Story:** As a VoiceOver user, I want zoom changes announced and controls labelled, so that I can operate the viewer without sight.++**Acceptance Criteria:**++1. <a name="9.1"></a>Each zoom control SHALL have a localised accessibility label describing its action.  +2. <a name="9.2"></a>WHEN a zoom control, double-tap, or reset changes the scale, the system SHALL post a VoiceOver announcement stating the resulting zoom percentage, extending the existing iOS reset-zoom announcement pattern in `ImageDetailSheet`.  +3. <a name="9.3"></a>WHEN Reduce Motion is enabled, button, double-tap, and reset zoom changes SHALL apply without animation, matching the existing `ImageDetailSheet` reset behaviour.  
specs/ios-zoom-controls/tasks.md Added +134 / -0
diff --git a/specs/ios-zoom-controls/tasks.md b/specs/ios-zoom-controls/tasks.mdnew file mode 100644index 0000000..c114d68--- /dev/null+++ b/specs/ios-zoom-controls/tasks.md@@ -0,0 +1,134 @@+---+references:+    - requirements.md+    - design.md+    - decision_log.md+---+# iOS Zoom Controls++## Foundation++- [x] 1. Write ZoomMathTests (red) <!-- id:k1jhqck -->+  - New prismTests/ZoomMathTests.swift using Swift Testing+  - Cover: clampScale, steppedScale (incl. stepping from non-grid value e.g. 0.37), displayedSize (aspectFit + fitWidth), fitScale (short=1.0, tall<1, extreme-tall clamped to minScale), clampOffset (pin at scale<=fit, bounded at scale>1, tall-axis pan at scale 1, never exceeds bounds, no letterbox over-pan), doubleTapTarget (at-fit->target, off-fit->fit, fit>=target->maxScale), focalOffset (stationary PRE-clamp, in isolation), approxEqual, fold formula (pure-drag/pure-pinch/simultaneous), live-product clamp+  - Parameterised @Test(arguments:) invariants: idempotence of clampScale/clampOffset, minScale<=clampScale<=maxScale, fitScale in [minScale,1.0], |clamped offset|<=allowed+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [8.1](requirements.md#8.1)+  - References: prismTests/ZoomMathTests.swift, design.md++- [x] 2. Implement ZoomConfig and ZoomMath (green) <!-- id:k1jhqcl -->+  - Pure dependency-free types in new prism/Views/ZoomControls.swift (no SwiftUI import needed for the math)+  - ZoomConfig with presets .iosImage (0.25-10, step .25, dtt 2.0), .iosDiagram (0.25-5), .legacyImage (0.5-10), .legacyDiagram (0.5-5)+  - ZoomMath enum statics: clampScale, steppedScale, displayedSize(content:viewport:mode), fitScale (clamped [minScale,1.0]), clampOffset, doubleTapTarget, focalOffset, intrinsicSVGSize(from:), approxEqual(eps 1e-3)+  - fold helper math: newScale=clampScale(committed*m); newOffset=clampOffset(m*committedOffset + d)+  - Blocked-by: k1jhqck (Write ZoomMathTests (red))+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4)+  - References: prism/Views/ZoomControls.swift, design.md++- [x] 3. Build ZoomControls SwiftUI helpers and shared zoom applier <!-- id:k1jhqcm -->+  - ZoomPercentageOverlay: bottom-right capsule matching macOS layout, value via Text(scale, format: .percent.precision(.fractionLength(0)))+  - zoomToolbarButtons @ToolbarContentBuilder: out/in/fit/reset with macOS SF Symbols + localised accessibilityLabels; disabled rules (out scale<=min, in scale>=max, fit when sizes unknown, reset via approxEqual)+  - Shared zoom-action applier: respects accessibilityReduceMotion, posts VoiceOver announcement String(localized: "zoom.announcement...") with locale-aware percent; add the announcement catalog key to Localizable.xcstrings+  - SwiftUI/wiring task: no preceding unit test+  - Blocked-by: k1jhqcl (Implement ZoomConfig and ZoomMath (green))+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)+  - References: prism/Views/ZoomControls.swift, prism/Localizable.xcstrings++## Mermaid viewer++- [x] 4. Write SVGWebView transform-guard tests (red) <!-- id:k1jhqcn -->+  - Extend prismTests/SVGWebViewTests.swift+  - Test the transform-application decision: applies when loaded and (scale,offset) changed; skips when unchanged; reset-on-reload forces re-apply after an HTML reload on EITHER svg or background-hex change+  - If needed, extract the changed/should-apply decision into a testable function so it can be exercised without a live web view+  - Stream: 2+  - Requirements: [7.1](requirements.md#7.1)+  - References: prismTests/SVGWebViewTests.swift++- [x] 5. Implement SVGWebView load-ordering fix and guard (green) <!-- id:k1jhqco -->+  - makeHTML(): inject committed transform into container inline style for correct first paint (no JS dependency)+  - Coordinator conforms to WKNavigationDelegate; didFinish marks loaded, applies current transform once, runs getBoundingClientRect and reports painted size via new optional onMeasure:(CGSize)->Void (default no-op so macOS callers unaffected); deny non-initial navigation+  - Coordinator gains lastAppliedTransform + isLoaded; evaluateJavaScript only when loaded and transform changed; reset lastAppliedTransform/isLoaded on every loadHTMLString (svg AND background-hex)+  - Public signature otherwise unchanged; macOS JS-transform path behaviour preserved+  - Blocked-by: k1jhqcn (Write SVGWebView transform-guard tests (red))+  - Stream: 2+  - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2)+  - References: prism/Views/SVGWebView.swift, design.md++- [x] 6. Implement DiagramView iOS hybrid transform path <!-- id:k1jhqct -->+  - Add var config: ZoomConfig = .legacyDiagram+  - #if os(iOS): render SVGWebView(scale:committed, offset:committed, onMeasure:) sized to measured/estimated displayedSize; overlay live .scaleEffect(liveMagnification, anchor:.center).offset(liveDragOffset)+  - MagnifyGesture + drag with per-frame live clamp; SpatialTapGesture(count:2) double-tap; fold-on-settle via ZoomMath fold; commit pushes one JS transform and resets live layer in the evaluateJavaScript completion handler (next-tick fallback)+  - onMeasure updates displayedSize; #else keep current macOS JS path unchanged+  - Blocked-by: k1jhqcl (Implement ZoomConfig and ZoomMath (green)), k1jhqco (Implement SVGWebView load-ordering fix and guard (green))+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+  - References: prism/Views/DiagramView.swift++- [x] 7. Integrate zoom controls into MermaidDiagramSheet <!-- id:k1jhqcu -->+  - MermaidDiagramSheet lives in MermaidPlaceholderCard.swift+  - Add @State viewSize + contentSize (from onMeasure, ZoomMath.intrinsicSVGSize fallback) + ZoomConfig.iosDiagram; capture viewSize via GeometryReader; recompute fit + re-clamp on change+  - Replace single Reset toolbar item with zoomToolbarButtons (gated on .success state); add ZoomPercentageOverlay; keep Done button; interactiveDismissDisabled via approxEqual at-rest predicate+  - Route actions through the shared applier (animation + VoiceOver announcement)+  - Blocked-by: k1jhqcm (Build ZoomControls SwiftUI helpers and shared zoom applier), k1jhqct (Implement DiagramView iOS hybrid transform path)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)+  - References: prism/Views/MermaidPlaceholderCard.swift++- [x] 8. Add MermaidDiagramSheet zoom UI tests <!-- id:k1jhqcv -->+  - Extend MermaidDiagramSheetUITests: four buttons exist; disabled states at limits; reset disabled at 100%; double-tap changes scale (Req 5.4)+  - Blocked-by: k1jhqcu (Integrate zoom controls into MermaidDiagramSheet)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [5.1](requirements.md#5.1), [5.4](requirements.md#5.4)+  - References: prismUITests/MermaidDiagramSheetUITests.swift++## Image viewer++- [x] 9. Update ZoomableImageViewTests for iOS config (red) <!-- id:k1jhqcp -->+  - Replace the stale upperBound==5.0 assertion; assert .iosImage values (0.25-10) and that disabled predicates use approxEqual (Reset stays disabled at drifted ~1.0)+  - Assert clamp/double-tap behaviour expectations via ZoomMath for the image case+  - Blocked-by: k1jhqcl (Implement ZoomConfig and ZoomMath (green))+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+  - References: prismTests/ZoomableImageViewTests.swift++- [x] 10. Implement ZoomableImageView iOS clamping and double-tap (green) <!-- id:k1jhqcq -->+  - Add var config: ZoomConfig = .legacyImage; minScale/maxScale read from it; keep static scaleRange for tests+  - #if os(iOS): GeometryReader viewport; displayedSize from image.size (aspect-fit); MagnifyGesture centre-anchored with per-frame live-product clamp; drag clamp via clampOffset; SpatialTapGesture(count:2) double-tap (doubleTapTarget + focalOffset + clamp)+  - macOS path unchanged (default config, existing MagnificationGesture)+  - Blocked-by: k1jhqcp (Update ZoomableImageViewTests for iOS config (red))+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+  - References: prism/Views/ZoomableImageView.swift++- [x] 11. Integrate zoom controls into ImageDetailSheet <!-- id:k1jhqcr -->+  - Add @State viewSize + contentSize (image.size) + ZoomConfig.iosImage; capture viewSize via GeometryReader background; on change recompute fit and re-clamp offset (no added animation)+  - Replace single Reset toolbar item with zoomToolbarButtons; add ZoomPercentageOverlay; keep existing ShareLink and Done button+  - interactiveDismissDisabled bound to the same approxEqual at-rest predicate as Reset+  - Route button/double-tap/reset through the shared applier (animation + VoiceOver announcement)+  - Blocked-by: k1jhqcm (Build ZoomControls SwiftUI helpers and shared zoom applier), k1jhqcq (Implement ZoomableImageView iOS clamping and double-tap (green))+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)+  - References: prism/Views/ImageDetailSheet.swift++- [x] 12. Add ImageDetailSheet zoom UI tests <!-- id:k1jhqcs -->+  - Assert the four zoom buttons exist; zoom-in/out toggle disabled at limits; reset disabled at 100%; double-tap changes scale (not swallowed by drag/pinch, Req 5.4)+  - Match via accessibility identifiers, not positions+  - Blocked-by: k1jhqcr (Integrate zoom controls into ImageDetailSheet)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [5.1](requirements.md#5.1), [5.4](requirements.md#5.4)+  - References: prismUITests++## Verification++- [x] 13. Verify build, lint, and tests (zero warnings) <!-- id:k1jhqcw -->+  - Run make test-quick, make build-ios, make build-macos, make lint; resolve any warnings (incl. MagnifyGesture deprecation must be gone from new iOS code)+  - Confirm macOS image/diagram windows behave unchanged+  - Record the manual responsiveness/flash/load-ordering checks (Req 7.3) in the task verification notes+  - Verification (2026-06-01): make lint clean; make build-ios and make build-macos succeed with zero warnings (no MagnifyGesture deprecation in new iOS code — decision 12); make test-quick passes (full macOS unit suite green). ZoomMathTests, ZoomableImageViewTests, and the SVGWebView pure decision/reset tests pass on both macOS and iOS Simulator. (SVGWebViewTests' pre-existing WKWebView-construction cases crash Swift Testing only in narrow -only-testing subset runs; they pass inside the full make test-quick suite — a pre-existing test-infra quirk unchanged on main, not a feature regression.) macOS viewers untouched except the behaviour-preserving SVGWebView guard.+  - Pending manual on-device verification (Req 7.3, iPhone 17 Pro simulator) per decision 13: continuous pinch/pan tracks without stutter; no evaluateJavaScript fires during a gesture; diagram crisp at rest; no commit-boundary flash (incl. simultaneous pinch+drag); correct transform on first open and after a theme change while zoomed; double-tap/pinch/drag coexist. Decision 13 also defers four hybrid-transform polish items (discrete mermaid zoom animation Req 7.2; diagram rotation re-clamp Req 8.2; % overlay during a continuous diagram pinch Req 4.2; live-layer collapse timing Req 7.3) to this on-device pass.+  - Blocked-by: k1jhqcu (Integrate zoom controls into MermaidDiagramSheet), k1jhqcv (Add MermaidDiagramSheet zoom UI tests), k1jhqcr (Integrate zoom controls into ImageDetailSheet), k1jhqcs (Add ImageDetailSheet zoom UI tests)+  - Stream: 1+  - Requirements: [7.1](requirements.md#7.1), [7.3](requirements.md#7.3)+  - References: Makefile

Things to double-check

On-device re-test of this round's fixes.

The announceZoom (VoiceOver) and over-pinch-bounce fixes are device-relevant. Re-run make install and confirm VoiceOver speaks the percentage and the overlay settles correctly after an over-pinch.

Simulator-only suites.

make test-quick (pure logic) passes. SVGWebViewTests and the UI tests run on the simulator; run make test / make test-ui there to confirm.