Sibling chore to T-1042 (PR #270, merged as 3a46945). Same audit finding (H2), same shape of fix, applied to the linked-image overlay in ImageBlockView instead of MermaidPreviewCard.
ImageBlockView.swift) + one new test file + spec/decision-log + CHANGELOG entry. ~25 LOC of production code.MermaidPreviewCard (same defaultDismissDelay / reduceMotionDismissDelay / dismissDelay(reduceMotion:) shape).@Environment(\.accessibilityReduceMotion) directly rather than threading an init parameter (Decision 1 — no sibling consumer to amortise a lift).xcodebuild test (viewConstructionSucceeds, dismissDelayConstants, dismissDelaySelection × 2). Full make test-quick hit the known intermittent runner-cascade — pre-push review will rerun the full suite from CI.Ready to push
Four parallel review agents (code reuse, code quality, efficiency, spec adherence) returned no blockers and no major findings. One actionable nit (stale outcome text in tasks.md task 2) was fixed in a follow-up commit; rune CLI also introduced spurious tokens in two Blocked-by lines, which were cleaned up at the same time. Three non-blocking minor findings (test #expect tautology, env-read invalidation surface, internal-vs-private visibility) were deliberately not addressed in this commit because each would require a parallel change in MermaidPreviewCard to keep the two overlay implementations symmetric — they are noted as future cleanup candidates.
301b7d2 T-1336: Extend linked-image overlay auto-dismiss timer for Reduce Motion 6cf920d T-1336: Apply pre-push review fixes ad17dfa T-1336: Add three-level implementation explanation If you tap a linked image on iOS in Prism, two buttons ("View Image" / "Follow Link") slide in over the image. They used to disappear by themselves after 3 seconds. For people with motor disabilities — folks who can't reliably tap a small target in 3 seconds — that's too fast. Now the timer extends to 30 seconds when the user has turned on the system's Reduce Motion accessibility setting.
The accessibility audit (finding H2) flagged this gap. Apple recommends using Reduce Motion as a proxy for users who need more time to interact. The 30 s value comes from WCAG 2.2.1 "Timing Adjustable" (Level A) — at least 10× the default when a timer is required. Sibling fix to T-1042 (which did the same for the Mermaid card).
One production file changed (prism/Views/ImageBlockView.swift, +26/-3), one new test file (prismTests/ImageBlockViewAccessibilityTests.swift, 90 lines).
// 1. New env read alongside the existing nine
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// 2. Named constants + selector
static let defaultDismissDelay: UInt64 = 3_000_000_000
static let reduceMotionDismissDelay: UInt64 = 30_000_000_000
static func dismissDelay(reduceMotion: Bool) -> UInt64 {
reduceMotion ? reduceMotionDismissDelay : defaultDismissDelay
}
// 3. Wired at the Task.sleep call site
let dismissDelay = Self.dismissDelay(reduceMotion: reduceMotion)
Task.sleep(nanoseconds: dismissDelay)
Mirrors MermaidPreviewCard's T-1042 fix shape exactly (same constants, same selector signature). Plumbing diverges in one place: env read instead of init parameter. Justification in Decision 1.
TextualBlockView with no behavioural benefit since ImageBlockView has no sibling consumer at its call site (whereas MermaidPreviewCard shares the parameter with MermaidPlaceholderCard).reduceMotion is not an init argument (env read). Behavioural fork covered by dismissDelaySelection.Adding a tenth @Environment to ImageBlockView widens the re-evaluation surface by one signal. For non-linked images in a LazyVStack this is wasted work — the value is only consumed inside an iOS-only branch on a discrete user-tap event. Accepted: Reduce Motion toggles fire at human-decision frequency, the view already reads nine env values, and contentView short-circuits on loadState so the re-eval for .loaded images is a cheap successView rebuild (no image reload, no SVG re-rasterisation — those are gated on colorScheme/displayScale).
Constants and selector are exposed at internal so the test target can reach them via @testable. Promoting to private (still reachable via @testable) would tighten the API surface, but should land in both ImageBlockView and MermaidPreviewCard together to keep the parallel — deferred as a future cleanup.
Identical to the 3 s case: dismissOverlay(), the second-tap branch, and .onDisappear all cancel the in-flight task. On scroll-back, showOverlay persists as @State but no fresh timer fires; the next tap rebuilds the task. The 30 s window does make "scroll away mid-window, scroll back" more likely to land while the overlay is still nominally visible — documented as a follow-up if user feedback flags it.
The repo now has three patterns for the same env signal across overlay views: init parameter (MermaidPreviewCard, MermaidPlaceholderCard); direct env read (ImageBlockView, both layouts, nested MermaidPlaceholderCard). This pre-dates T-1336; the decision log notes that this commit does not introduce the heterogeneity but does not consolidate it either.
UInt64 nanoseconds + Task.sleep(nanoseconds:) mirrors MermaidPreviewCard. Modernising to Duration + Task.sleep(for:) is a coordinated cross-file refactor — out of scope.
ImageBlockView.swift
Why it matters. This is the behavioural change: the overlay now stays for 30 s under Reduce Motion instead of always 3 s. Reviewers should confirm the selector is called at tap time (not per frame), and that the cancel-and-reschedule pattern survives the new window.
What to look at. prism/Views/ImageBlockView.swift:300-307 (the Task.sleep call site inside handleiOSOverlayTap)
ImageBlockView.swift
Why it matters. Diverges from T-1042's init-parameter plumbing. Reviewers should confirm the SwiftUI re-eval cost is acceptable (every ImageBlockView re-renders on Reduce Motion toggle) and that the divergence is justified.
What to look at. prism/Views/ImageBlockView.swift:50 (the new @Environment property)
ImageBlockView.swift
Why it matters. Deliberate duplication of 10 LOC from MermaidPreviewCard. Reviewers should confirm the in-sync comment update is sufficient guard against future drift, and that no shared helper would obviously be better.
What to look at. prism/Views/ImageBlockView.swift:272-288 (the new MARK section) and 222-228 (the extended in-sync comment)
ImageBlockViewAccessibilityTests.swift
Why it matters. The construction smoke test is intentionally single-shot here vs. parameterised in T-1042's MermaidPreviewCardAccessibilityTests. Reviewers should verify the explanation in the test file's doc comment matches the smolspec Tests subsection.
What to look at. prismTests/ImageBlockViewAccessibilityTests.swift (full file, 90 lines)
tasks.md
Why it matters. Pre-push review caught that tasks.md task 2's outcome still described the construction test as parameterised. Also cleans up rune CLI quirk that injected 'overlay, dismiss, extends' tokens into Blocked-by lines.
What to look at. specs/linked-image-auto-dismiss-a11y/tasks.md (commit 6cf920d)
MermaidPreviewCard's init-parameter approach (T-1042) exists because its parent layouts also construct MermaidPlaceholderCard, which consumes the same value — the lift at the call site amortises across two consumers. ImageBlockView has no such sibling; reading env directly keeps the diff contained to one source file and matches the file's existing style of declaring all inputs as @Environment properties.
See specs/linked-image-auto-dismiss-a11y/decision_log.md Decision 1.
10-line bounded duplication; WCAG-derived stable values; existing "kept in sync with MermaidPreviewCard's overlay pattern" comment convention preserved and extended to call out the deliberate duplication. A shared helper would create module-level coupling between two unrelated views or move the constants to a sibling view's internals (hurts discoverability).
See specs/linked-image-auto-dismiss-a11y/decision_log.md Decision 2.
Env-read plumbing means reduceMotion is not an init argument, so parameterising construction over it would not exercise any additional compile path. The selector's behavioural fork is covered by the parameterised dismissDelaySelection test.
Rationale is documented at the test file's doc comment (lines 33–41) and in the smolspec Tests subsection.
Code-quality agent suggested promoting defaultDismissDelay / reduceMotionDismissDelay / dismissDelay(reduceMotion:) from internal (default) to private — they'd still be reachable from the test target via @testable import prism, but the public surface would be tighter. Deferred because the promotion would also need to land on MermaidPreviewCard.dismissDelay to keep the two implementations symmetric; mixing it into a focused accessibility fix would dilute the diff and split the change across two PRs.
Modern API would express the intent more cleanly, but matching MermaidPreviewCard's nanoseconds form is the documented mirror. Migration would be a coordinated cross-file refactor (both views, both test files) and belongs in its own cleanup ticket.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | tasks.md task 2 outcome text | Outcome line described the construction smoke test as 'parameterised ... exercising both reduceMotion: false and reduceMotion: true', which contradicted both the smolspec's updated Tests subsection and the implemented test file. | Rewrote outcome text in commit 6cf920d to match the implementation (single-shot construction smoke test with the env-read rationale stated). Verification line also updated to be specific about the four test cases that pass. |
| minor | tasks.md Blocked-by lines | After `rune batch --input` rewrote the file, the two Blocked-by lines accumulated spurious 'overlay, dismiss, extends' tokens (the keyword scanner appears to mis-tokenise parens inside title hints). | Cleaned up by hand in commit 6cf920d. On-disk Blocked-by lines now read as single clean title-hint entries per dependency. |
| minor | ImageBlockViewAccessibilityTests.viewConstructionSucceeds — tautological #expect | Code-quality agent: '#expect(type(of: view) == ImageBlockView.self)' can never fail once `let view = ImageBlockView(...)` compiles. The construction call itself is what enforces compilation; the assertion line adds nothing. | Skipped. The identical pattern lives in MermaidPreviewCardAccessibilityTests.swift; changing only the new file would diverge from the sibling test convention. If the assertion is wrong, both files should change together — out of scope for this commit. |
| minor | Static constants exposed at default internal visibility | Code-quality agent: defaultDismissDelay / reduceMotionDismissDelay / dismissDelay(reduceMotion:) could be `private static` and still be reachable via `@testable import prism`, tightening the module's public surface. | Skipped. The promotion should land in both ImageBlockView and MermaidPreviewCard together (mirror the T-1042 surface), so doing it only here would create exactly the kind of drift Decision 2 is trying to avoid. Tracked as a future cleanup. |
| minor | @Environment(\.accessibilityReduceMotion) re-eval surface | Efficiency agent: adding a tenth @Environment property to ImageBlockView means every block-level ImageBlockView in a LazyVStack — linked or not — re-evaluates body when the user toggles Reduce Motion. For non-linked images this is wasted work. | Skipped. Reduce Motion toggles fire at human-decision frequency, the view already reads nine env values, and contentView short-circuits on loadState so the .loaded re-eval is cheap. Extracting the iOS linked overlay into a child view to localise the env read is a hypothetical perf win for a hypothetical bottleneck — yagni applies. Efficiency agent explicitly recommended shipping as-is. |
Click to expand.
diff --git a/prism/Views/ImageBlockView.swift b/prism/Views/ImageBlockView.swiftindex 79325c6..46e9019 100644--- a/prism/Views/ImageBlockView.swift+++ b/prism/Views/ImageBlockView.swift@@ -47,6 +47,7 @@ struct ImageBlockView: View { @Environment(\.openURL) private var openURL @Environment(\.displayScale) private var displayScale @Environment(\.colorScheme) private var colorScheme+ @Environment(\.accessibilityReduceMotion) private var reduceMotion #if os(macOS) @Environment(\.openWindow) private var openWindow@@ -219,7 +220,12 @@ struct ImageBlockView: View { } // NOTE: Overlay button styling, animation durations, and auto-dismiss timeout- // are kept in sync with MermaidPreviewCard's overlay pattern.+ // are kept in sync with MermaidPreviewCard's overlay pattern. The+ // defaultDismissDelay / reduceMotionDismissDelay constants and the+ // dismissDelay(reduceMotion:) selector below are intentionally duplicated+ // from MermaidPreviewCard.dismissDelay(reduceMotion:) rather than shared+ // via a helper — see specs/linked-image-auto-dismiss-a11y/decision_log.md+ // Decision 2. private func overlayButtons(linkURL: URL) -> some View { HStack(spacing: 8) { Button {@@ -266,8 +272,24 @@ struct ImageBlockView: View { #endif } + // MARK: - Auto-Dismiss Timing (T-1336)++ /// Default auto-dismiss interval for the overlay buttons.+ static let defaultDismissDelay: UInt64 = 3_000_000_000+ /// Extended auto-dismiss interval used when the user has enabled Reduce+ /// Motion. 10× the default per WCAG 2.2.1 ("Timing Adjustable", Level A).+ static let reduceMotionDismissDelay: UInt64 = 30_000_000_000++ /// Returns the appropriate auto-dismiss interval (in nanoseconds) for+ /// the overlay buttons. Extracted so the constant selection can be+ /// asserted in unit tests without timing dependencies.+ static func dismissDelay(reduceMotion: Bool) -> UInt64 {+ reduceMotion ? reduceMotionDismissDelay : defaultDismissDelay+ }+ #if os(iOS)- /// First tap shows overlay (3s auto-dismiss), second tap on background follows the link.+ /// First tap shows overlay (auto-dismiss after 3 s, or 30 s under+ /// Reduce Motion); second tap on background follows the link. private func handleiOSOverlayTap(linkURL: URL) { if showOverlay { // Second tap on background → dismiss overlay and follow the link@@ -278,10 +300,11 @@ struct ImageBlockView: View { withAnimation(.easeInOut(duration: 0.2)) { showOverlay = true }+ let dismissDelay = Self.dismissDelay(reduceMotion: reduceMotion) overlayDismissTask?.cancel() overlayDismissTask = Task { do {- try await Task.sleep(nanoseconds: 3_000_000_000)+ try await Task.sleep(nanoseconds: dismissDelay) } catch { return }
diff --git a/prismTests/ImageBlockViewAccessibilityTests.swift b/prismTests/ImageBlockViewAccessibilityTests.swiftnew file mode 100644index 0000000..93257f3--- /dev/null+++ b/prismTests/ImageBlockViewAccessibilityTests.swift@@ -0,0 +1,90 @@+//+// ImageBlockViewAccessibilityTests.swift+// prismTests+//+// Tests for ImageBlockView linked-image overlay accessibility (T-1336).+//++import Foundation+import Testing+import SwiftUI+@testable import prism++/// Tests for the linked-image overlay auto-dismiss timer in ImageBlockView.+///+/// `.accessibilityAction(named:)` modifiers attached to the linked-image+/// overlay expose "View Image" and "Follow Link" to VoiceOver / Voice+/// Control even when the overlay buttons are hidden (iOS tap-to-reveal,+/// macOS hover-only). Those actions ship today and are already covered by+/// the existing en-base catalog; this file does not duplicate the+/// catalog-key sentinel tests from MermaidPreviewCardAccessibilityTests+/// because T-1336 introduces no new user-facing strings.+///+/// SwiftUI does not expose its view tree without ViewInspector (not a+/// project dependency), so the construction test is a compilation smoke+/// test — `viewConstructionSucceeds` will stop building if the modifier+/// chain or the new T-1336 dismiss-delay selector stops type-checking.+///+/// The construction test is intentionally not parameterised over+/// `reduceMotion: Bool`. T-1336 reads `@Environment(\.accessibilityReduceMotion)`+/// directly in `ImageBlockView` (see+/// specs/linked-image-auto-dismiss-a11y/decision_log.md Decision 1), so+/// the value is not an init argument and parameterising construction over+/// it would not exercise any additional compile path. The behavioural+/// difference between `reduceMotion = true` and `reduceMotion = false` is+/// covered by `dismissDelaySelection` below.+@Suite("ImageBlockViewAccessibility", .serialized)+@MainActor+struct ImageBlockViewAccessibilityTests {++ // MARK: - View construction (compilation smoke test)++ // ImageBlockView declares these modifiers on the ZStack returned by+ // linkedImageView(image:linkURL:):+ // .accessibilityAction(named: "View Image") { openImageDetail() }+ // .accessibilityAction(named: "Follow Link") { openURL(linkURL) }+ // and inside handleiOSOverlayTap(linkURL:) the T-1336 selector call:+ // let dismissDelay = Self.dismissDelay(reduceMotion: reduceMotion)+ // try await Task.sleep(nanoseconds: dismissDelay)+ // If any of those sites breaks type-checking, this test stops building.+ // Behavioural verification of action invocation / timer duration+ // requires ViewInspector or a manual VoiceOver / Reduce Motion run on+ // iOS.+ @Test("Linked-image view constructs with the overlay accessibility modifiers in place")+ func viewConstructionSucceeds() {+ let view = ImageBlockView(+ source: "image.png",+ alt: "Example image",+ title: nil,+ link: "https://example.com",+ width: nil,+ height: nil,+ isSearchHighlighted: false+ )+ #expect(type(of: view) == ImageBlockView.self)+ }++ // MARK: - Auto-dismiss delay selection (T-1336)++ @Test("Auto-dismiss delay constants match the WCAG 2.2.1 10× rule")+ func dismissDelayConstants() {+ #expect(ImageBlockView.defaultDismissDelay == 3_000_000_000)+ #expect(ImageBlockView.reduceMotionDismissDelay == 30_000_000_000)+ #expect(+ ImageBlockView.reduceMotionDismissDelay+ == 10 * ImageBlockView.defaultDismissDelay,+ "WCAG 2.2.1 'Timing Adjustable' (Level A) — the extended timer must be at least 10× the default"+ )+ }++ @Test(+ "Auto-dismiss delay selector returns the right constant for each reduceMotion value",+ arguments: [+ (false, ImageBlockView.defaultDismissDelay),+ (true, ImageBlockView.reduceMotionDismissDelay)+ ]+ )+ func dismissDelaySelection(reduceMotion: Bool, expected: UInt64) {+ #expect(ImageBlockView.dismissDelay(reduceMotion: reduceMotion) == expected)+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 166d261..c8b3335 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Debug-only:** Settings → About now shows a "Build" row beneath "Version" with the short git commit hash the build was produced from, suffixed `-dirty` when the working tree had uncommitted changes (and `unknown` when built outside a git checkout). A new `Tools/stamp-commit-hash.sh` build phase writes `GitCommit` into the bundled `Info.plist` using `PlistBuddy`; the script bails early when `CONFIGURATION` isn't `Debug`, so Release/TestFlight/App Store builds never carry the key. The Settings UI and `Bundle.appCommitHash` accessor are wrapped in `#if DEBUG` as a second line of defence. The phase is marked always-out-of-date so the Debug value refreshes on every build. - `render-html-comments` spec (T-450) covering an opt-in Settings toggle that replaces today's raw-HTML fall-through for `<!-- ... -->`. When the toggle is on, both block-level and inline HTML comments render as italic, dimmed annotations prefixed with the `info.circle` SF Symbol and with the comment markers stripped; when off, comments are hidden entirely (new default). Toggling re-renders the open document without re-parsing the AST; search, copy/export, and accessibility honour the toggle. Requirements, design, decision log, and implementation tasks live under `specs/render-html-comments/`. - Footnote badges are now discoverable and operable for VoiceOver (T-1036). New `FootnoteAccessibility` helper (`prism/Services/FootnoteAccessibility.swift`) extracts unique footnote references in first-occurrence order and rebuilds a paragraph-level accessibility label that substitutes each resolvable `[^id]` with the localised inline phrase "footnote N". `HighlightedInlineText` applies a private `FootnoteAccessibilityModifier` that overrides the paragraph's accessibility label and exposes one "Show footnote N" action per unique reference via `.accessibilityActions { … }`. Each action opens the existing `prism://footnote/{id}` URL through `@Environment(\.openURL)`, reusing the tap-routing flow already wired in `DocumentReaderView`. The modifier is a no-op when no resolvable references are present, so paragraphs without footnotes keep their auto-derived narration. New localised keys `a11y.footnote.inline %lld` and `a11y.footnote.action %lld` added to `prism/Localizable.xcstrings`. Helpers covered by new unit tests in `prismTests/FootnoteAccessibilityTests.swift`.+- The iOS linked-image overlay (the "View Image" / "Follow Link" two-button affordance shown over a linked image) now stays visible for 30 seconds before auto-dismissing when the user has enabled the system Reduce Motion setting, instead of the default 3 seconds (T-1336, sibling to T-1042 — the audit finding H2 named only the Mermaid card, but `ImageBlockView` shares the same pattern and the same gap). The 30 s value follows WCAG 2.2.1 ("Timing Adjustable", Level A), matching the choice in T-1042. `ImageBlockView.handleiOSOverlayTap(linkURL:)` now selects between two named duration constants — `defaultDismissDelay` (3 s) and `reduceMotionDismissDelay` (30 s) — via a new `dismissDelay(reduceMotion:)` selector. The Reduce Motion signal is read directly from `@Environment(\.accessibilityReduceMotion)` rather than threaded as an init parameter (the linked-image overlay has no sibling consumer that would amortise a lift at the call site; rationale in `specs/linked-image-auto-dismiss-a11y/decision_log.md` Decision 1). The visible reveal animation, macOS hover-based overlay behaviour, the existing card-level accessibility actions ("View Image" / "Follow Link"), and the second-tap-follows-link behaviour are unchanged. New `ImageBlockViewAccessibilityTests` Swift Testing suite covers construction (compilation smoke) and the parameterised dismiss-delay selector (WCAG 2.2.1 10× relationship + constant selection per `reduceMotion` value). Spec lives under `specs/linked-image-auto-dismiss-a11y/`. - The iOS Mermaid card overlay now stays visible for 30 seconds before auto-dismissing when the user has enabled the system Reduce Motion setting, instead of the default 3 seconds (T-1042, audit finding H2). The 30 s value follows WCAG 2.2.1 ("Timing Adjustable", Level A), which recommends supporting at least 10× the default time when a timer is retained. `MermaidPreviewCard.handleiOSOverlayTap()` now selects between two named duration constants — `defaultDismissDelay` (3 s) and `reduceMotionDismissDelay` (30 s) — using the existing `reduceMotion: Bool` parameter that both `CompactDocumentLayout` and `RegularDocumentLayout` derive from `@Environment(\.accessibilityReduceMotion)`. The visible reveal animation, macOS hover-based overlay behaviour, and the T-1041 card-level accessibility actions are unchanged. `MermaidPreviewCardAccessibilityTests` gains a construction smoke test for the `reduceMotion: true` path so the conditional stays compiled. Spec lives under `specs/mermaid-card-auto-dismiss-a11y/`. - Translucent overlay surfaces now honour the system Reduce Transparency accessibility setting on both iOS and macOS (T-1044, audit finding H4). New `prism/Theme/AdaptiveMaterial.swift` introduces two `ViewModifier` types — `AdaptiveMaterialBackground<S: Shape>` and `AdaptiveGlassEffect` — that read `@Environment(\.accessibilityReduceTransparency)` and swap `.background(.regularMaterial, in:)` for an opaque `themeColors.surface1` fill, and drop `.glassEffect(.regular.interactive())` to a no-op (the underlying `.buttonStyle(.bordered)` renders its own opaque background) when the setting is on. Applied at 12 sites: metadata header, inline search bar, toast notifications, reload banner, macOS footnote popover, note popover, macOS image-detail zoom badge, accessible-table mode-toggle button, and the four mermaid/image overlay buttons in `MermaidPreviewCard` and `ImageBlockView`. The `image-detail` `WindowGroup` in `prism/prismApp.swift` also gains `.applyTheme(...)` so it correctly resolves `themeColors` (and now honours the user's selected theme — a latent-bug fix). New `prismTests/AdaptiveMaterialTests.swift` locks in the invariant that `surface1` is fully opaque (alpha == 1) for every concrete theme (`PrismLight`, `PrismDark`, `ClassicLight`, `ClassicDark`, `Refraction`) so future translucent-ification of `surface1` cannot silently defeat the fallback. Reuses the cross-platform colour-component bridge already in `ContrastChecker` via a new `alphaComponent(_:)` sibling.
diff --git a/specs/linked-image-auto-dismiss-a11y/smolspec.md b/specs/linked-image-auto-dismiss-a11y/smolspec.mdnew file mode 100644index 0000000..07b7e8a--- /dev/null+++ b/specs/linked-image-auto-dismiss-a11y/smolspec.md@@ -0,0 +1,209 @@+# Linked-Image Overlay Auto-Dismiss Accessibility++Source: Transit ticket T-1336 (chore, Prism, milestone 1.0.0). Tracks parent+epic T-782. Audit reference: `docs/accessibility-review.md` finding H2 —+the audit only named MermaidPreviewCard, but the linked-image overlay in+`ImageBlockView` shares the same hard-coded 3 s auto-dismiss pattern and+the same accessibility gap. Sibling chore to T-1042 (merged via PR #270),+which fixed the equivalent pattern in `MermaidPreviewCard`.++## Overview++On iOS, the linked-image overlay in `ImageBlockView` (the two-button+"View Image" / "Follow Link" affordance shown over a linked image) appears+on first tap and auto-dismisses silently after 3 seconds; a second tap on+the underlying image during the window dismisses the overlay and follows+the link. 3 seconds is too short for users with motor accessibility needs+to reach either overlay button. The card-level `.accessibilityAction(named:)`+entries at lines 202–203 already expose "View Image" and "Follow Link" to+VoiceOver / Voice Control regardless of overlay visibility, so the+remaining gap is sighted motor-impaired users whose visible affordance+vanishes before they can tap. This change extends the auto-dismiss to+30 seconds (10× the default, matching WCAG 2.2.1 "Timing Adjustable")+when the user has enabled the system Reduce Motion setting, while+keeping the 3-second timer for the default case. The timer remains+bounded in both modes so the overlay never persists indefinitely.++## Requirements++- The iOS linked-image overlay auto-dismiss timer MUST extend to 30 seconds+ when the user has enabled the system Reduce Motion setting.+- The iOS linked-image overlay auto-dismiss timer MUST remain at 3 seconds+ when Reduce Motion is not enabled (current behaviour preserved for the+ default case).+- The visible tap-to-reveal behaviour, fade-in animation duration, and+ overlay styling MUST NOT change. Only the dismiss-timer duration is+ affected.+- macOS hover-based overlay visibility MUST NOT be affected (the change is+ iOS-only, inside the existing `#if os(iOS)` branch of+ `handleiOSOverlayTap(linkURL:)`).+- The change MUST NOT regress the existing card-level accessibility+ actions (`.accessibilityAction(named: "View Image")` and+ `.accessibilityAction(named: "Follow Link")` at lines 202–203).+- The second-tap-follows-link behaviour MUST be preserved. Under the+ extended 30-second window, a second tap during the visible-overlay+ state SHOULD still dismiss the overlay and follow the link+ (current behaviour at `handleiOSOverlayTap(linkURL:)` lines 273–275).+- The overlay MUST continue to dismiss within a bounded interval in both+ modes — at no point should the overlay persist indefinitely. (Persistence+ was rejected during T-1042 review for the same reason: a persistent+ overlay obscures the image content.)+- No new user-facing strings are introduced. The change MUST NOT require+ edits to `prism/Localizable.xcstrings`.++## Implementation Approach++**Key file:** `prism/Views/ImageBlockView.swift` (function+`handleiOSOverlayTap(linkURL:)`, currently lines 271–295 inside+`#if os(iOS)`).++**Pattern to follow:** Mirror `MermaidPreviewCard`'s T-1042 fix exactly.+`MermaidPreviewCard.swift` defines two `static let` constants+(`defaultDismissDelay` = `3_000_000_000` at line 207,+`reduceMotionDismissDelay` = `30_000_000_000` at line 210) and a+`static func dismissDelay(reduceMotion: Bool) -> UInt64` selector at+lines 215–217. `handleiOSOverlayTap()` calls the selector at line 231 and+passes the result to `Task.sleep(nanoseconds:)` inside the spawned task+(lines 233–244).++**Reduce Motion signal:** Read `@Environment(\.accessibilityReduceMotion)`+directly in `ImageBlockView`. The file already declares the bulk of its+inputs as `@Environment` reads (lines 43–54, including the macOS-only+block) — adding one more matches that style. The alternative — threading+a `reduceMotion: Bool` init parameter from `TextualBlockView.swift:144` —+would touch a second view file for a value consumed only inside an+iOS-only branch. The chosen plumbing differs from `MermaidPreviewCard`,+which lifts `reduceMotion` out of `CompactDocumentLayout`/+`RegularDocumentLayout` once at the call site; that lift exists because+its sibling view `MermaidPlaceholderCard` also consumes the value. The+linked-image overlay has no such sibling, so the env read is cleaner.+See decision_log.md Decision 1.++**Change:** In `ImageBlockView`:++1. Add `@Environment(\.accessibilityReduceMotion) private var reduceMotion`+ to the existing environment-property block (between lines 43–49 and+ the macOS-only `#if` block at line 51).+2. Add the two `static let` constants and the `static func+ dismissDelay(reduceMotion:)` selector, following the exact shape of+ `MermaidPreviewCard.swift:204–217` (`// MARK: - Auto-Dismiss Timing+ (T-1042)` header + constants + selector). Place the section under a+ `// MARK: - Auto-Dismiss Timing (T-1336)` header positioned+ immediately above the `#if os(iOS)` `handleiOSOverlayTap(linkURL:)`+ block at line 269 (i.e., after `dismissOverlay` at line 249 and+ after the existing in-sync comment block).+3. Inside `handleiOSOverlayTap(linkURL:)`, replace the bare+ `Task.sleep(nanoseconds: 3_000_000_000)` literal at line 284 with a+ call to `Self.dismissDelay(reduceMotion: reduceMotion)`. Mirror+ `MermaidPreviewCard.swift` line 231 (selector call) → 235 (passes+ value to `Task.sleep(nanoseconds:)`).+4. Update the doc comment at line 270 ("First tap shows overlay (3s+ auto-dismiss), second tap on background follows the link.") so it+ no longer claims the timer is unconditionally 3 seconds — e.g.,+ "First tap shows overlay (auto-dismiss after 3 s, or 30 s under+ Reduce Motion); second tap on background follows the link."+5. Update the existing in-sync comment at line 222 ("Overlay button+ styling, animation durations, and auto-dismiss timeout are kept in+ sync with MermaidPreviewCard's overlay pattern.") to add a brief note+ that the auto-dismiss constants are intentionally duplicated rather+ than shared, and point at `MermaidPreviewCard.dismissDelay(reduceMotion:)`+ as the in-sync counterpart. Rationale lives in decision_log.md+ Decision 2.++The 30-second value is derived from WCAG 2.2.1 ("Timing Adjustable",+Level A), which recommends at least 10× the default time limit when a+timer must be retained. It is expressed as a named constant rather than+a bare numeric literal so the source justifies itself on inspection.++The fade-in animation that brings the overlay on screen (line 278,+`withAnimation(.easeInOut(duration: 0.2))`) is intentionally left+untouched to keep this spec narrowly scoped to H2; reduce-motion+handling for that animation is a separate concern (see Out of Scope).++**Tests:** Add `prismTests/ImageBlockViewAccessibilityTests.swift` that+mirrors the structure of `MermaidPreviewCardAccessibilityTests.swift`+(see `dismissDelayConstants` at lines 76–85 and `dismissDelaySelection`+at lines 87–96 on origin/main). Use the same Swift Testing form+(`@Test(arguments:)` for the parameterised selector case), the same+WCAG-2.2.1 10× relationship assertion, and the same+compilation-smoke-test rationale documented in the existing file's+preamble (ViewInspector is not a project dependency).++The construction smoke test is *not* parameterised over `reduceMotion`+(unlike T-1042's `cardConstructionSucceeds`). T-1042 parameterised the+init argument; this fix reads `accessibilityReduceMotion` from+environment (Decision 1) so it is not an init argument, and+parameterising construction over an unused bool would not exercise any+additional compile path. The selector's behavioural fork is covered by+the parameterised `dismissDelaySelection` test below.++The action names "View Image" and "Follow Link" already exist in+`prism/Localizable.xcstrings` (lines 6329 and 2419), and no new+user-facing strings are introduced, so the catalog-key sentinel tests+from `MermaidPreviewCardAccessibilityTests.swift` are not duplicated+into this file — the existing catalog coverage is sufficient.++**Out of scope:**++- Removing the auto-dismiss timer entirely (the persistent-overlay UX+ was rejected during T-1042 review for both modes; the same reasoning+ applies here — the overlay obscures the image content).+- Honoring `accessibilityReduceMotion` for the 0.2 s fade-in animation+ that reveals the overlay — separate concern, not flagged by H2.+- Reduce-Transparency support for the `adaptiveGlassEffect()` overlay+ buttons (tracked under T-1044 / H4).+- Changing the macOS hover-based overlay behaviour.+- Sharing the dismiss-delay constants between `MermaidPreviewCard` and+ `ImageBlockView` via a new shared helper. Rejected to keep the diff+ isolated and to preserve the existing "kept in sync with+ MermaidPreviewCard's overlay pattern" comment-based convention. See+ decision_log.md Decision 2.+- Threading `reduceMotion` through `TextualBlockView` as an init+ parameter rather than reading it from environment in `ImageBlockView`.+ See decision_log.md Decision 1.+- Other accessibility audit findings (M-series tickets, L-series polish).+- User-configurable timeout duration in Settings — out of scope for a+ chore; if user feedback shows 30 s is wrong, a follow-up can revisit.++## Risks and Assumptions++- **Risk:** 30 seconds is still a fixed value and will not be right for+ every motor-impaired user. **Mitigation:** WCAG 2.2.1 explicitly+ endorses 10× the default as an acceptable extended value, so the choice+ is principled rather than arbitrary. A configurable preference is out+ of scope. Reduce Motion is the audit-recommended signal for the+ extension trigger.+- **Risk:** Reduce Motion is Apple's vestibular / motion-sensitivity+ setting; using it as the trigger conflates motion sensitivity with+ motor-interaction needs. **Mitigation:** This is the trigger the+ accessibility audit named (it was also used for T-1042) and Apple HIG+ does not provide a separate "interaction time" preference. The cost of+ a false positive (a reduce-motion user who does not need extra time)+ is a longer-visible overlay that still dismisses; no functional+ regression.+- **Risk:** During the 30-second window, scrolling the linked image off+ screen still cancels the dismiss task via the existing `.onDisappear`+ handler (`ImageBlockView.swift:97–100`). On re-appearance, the overlay+ state (`showOverlay`) persists as `@State` but no fresh timer fires,+ so the overlay would stay visible until the next user tap.+ **Mitigation:** The same edge case exists today for the 3-second+ window; it is not new. A 30 s window does make "scroll away mid-window,+ scroll back" more likely to land while the overlay is still nominally+ visible (a 3 s window almost never overlaps a typical scroll-and-return+ gesture). The trade-off is accepted: the bounded-timer requirement above+ is preserved (the timer cannot exceed 30 s in any path), and resetting+ `showOverlay` from `onDisappear` is documented as a follow-up if user+ feedback identifies the larger window as disruptive.+- **Risk:** Duplicating the two constants in `ImageBlockView` could+ drift out of sync with `MermaidPreviewCard` over time. **Mitigation:**+ The existing in-sync comment (line 222) is updated to call out the+ duplication explicitly. The values are WCAG-derived and stable; a+ future audit-driven change would touch both files together.+- **Assumption:** `@Environment(\.accessibilityReduceMotion)` reflects+ the current system setting and triggers a SwiftUI re-render when it+ changes. Verified: both `CompactDocumentLayout.swift:69` and+ `RegularDocumentLayout.swift:58` already use this pattern in the+ project, and `MermaidPlaceholderCard.swift:254` reads it directly.+- **Prerequisite:** T-1042 is merged so the `MermaidPreviewCard` pattern+ exists as a reference. Confirmed: merged 2026-05-24 via PR #270+ (commit `3a46945`).
diff --git a/specs/linked-image-auto-dismiss-a11y/decision_log.md b/specs/linked-image-auto-dismiss-a11y/decision_log.mdnew file mode 100644index 0000000..12b1b90--- /dev/null+++ b/specs/linked-image-auto-dismiss-a11y/decision_log.md@@ -0,0 +1,165 @@+# Decision Log: Linked-Image Overlay Auto-Dismiss Accessibility++## Decision 1: Read `accessibilityReduceMotion` from environment in `ImageBlockView` rather than thread an init parameter++**Date**: 2026-05-25+**Status**: accepted++### Context++`MermaidPreviewCard` exposes `reduceMotion: Bool` as an init parameter+(line 20), populated by both `CompactDocumentLayout.swift` (line 86) and+`RegularDocumentLayout.swift` (line 77) which read+`@Environment(\.accessibilityReduceMotion)` and pass the value down.+The sibling fix T-1042 used this existing parameter as its motion signal.++`ImageBlockView` does not currently take a `reduceMotion: Bool` parameter.+Its single call site is `TextualBlockView.swift:144`. `TextualBlockView`+itself already takes `reduceMotion: Bool` (line 24), so threading the+parameter through is mechanically straightforward — one new constructor+argument on `ImageBlockView`, one new line at the call site.++The alternative is to read `@Environment(\.accessibilityReduceMotion)`+directly inside `ImageBlockView`. The file already declares nine+`@Environment(...)` properties across lines 43–54 (seven unconditional+plus two inside a `#if os(macOS)` block).++### Decision++Add `@Environment(\.accessibilityReduceMotion) private var reduceMotion`+directly to `ImageBlockView`. Do not change `ImageBlockView`'s+initializer; do not modify `TextualBlockView.swift:144`.++### Rationale++The value is consumed only inside the `#if os(iOS)` branch of+`handleiOSOverlayTap(linkURL:)`. Reading it from environment keeps the+diff isolated to `ImageBlockView.swift` and one new test file, matches+the file's existing environment-read style, and avoids touching an+unrelated structural view (`TextualBlockView`) for a value that is+already published into the environment by SwiftUI's accessibility+system.++`MermaidPreviewCard`'s init-parameter approach exists because its+parent layouts (`CompactDocumentLayout.swift:86`,+`RegularDocumentLayout.swift:77`) already read+`@Environment(\.accessibilityReduceMotion)` to drive their own+animation gating, and the value is then lifted once at the construction+of *both* mermaid views (`MermaidPreviewCard` and `MermaidPlaceholderCard`).+For two consumers built at the same call site, the lift is reasonable.+The linked-image overlay has no such sibling — `ImageBlockView` is the+sole consumer that would need the value — so the lift gives no+amortisation benefit and the env read is the smaller change.++### Alternatives Considered++- **Add `reduceMotion: Bool` init parameter to `ImageBlockView`, plumb+ from `TextualBlockView`**: Mirrors `MermaidPreviewCard` literally.+ Rejected because the value is consumed in one iOS-only branch, the+ environment read is the project's existing alternative pattern, and+ the init-parameter version expands the diff to a second view file+ without any behavioural benefit.+- **Add a separate `accessibilityNeedsExtendedTiming` environment key+ that fans out to both views**: Would let both views read the same+ derived signal. Rejected as scope creep — neither audit finding asked+ for this abstraction, and SwiftUI's own `accessibilityReduceMotion`+ key is already the right signal per the audit.++### Consequences++**Positive:**++- Diff is limited to `ImageBlockView.swift` and the new test file.+- `TextualBlockView.swift:144` (and the dozen-line `ImageBlockView(...)`+ constructor call there) is untouched.+- Pattern matches existing usage in `MermaidPlaceholderCard.swift`.++**Negative:**++- The plumbing diverges from `MermaidPreviewCard`'s init-parameter+ approach for a sibling H2 fix. A future maintainer comparing the two+ fixes may briefly wonder why they differ; the in-sync comment at+ `ImageBlockView.swift:222` is updated to call out the deliberate+ divergence and point at this decision.+- The codebase now has three different shapes for the same env value+ across overlay views: env read (`MermaidPlaceholderCard.swift:254`,+ and now `ImageBlockView`), init parameter (`MermaidPreviewCard.swift:20`),+ and lift-and-pass (the two layouts). This is already the de-facto state+ of the repo — this decision does not introduce the heterogeneity — but+ it does not consolidate it either.++---++## Decision 2: Duplicate the dismiss-delay constants in `ImageBlockView` rather than share them via a helper++**Date**: 2026-05-25+**Status**: accepted++### Context++`MermaidPreviewCard.swift:207–216` defines two `static let` constants+(`defaultDismissDelay`, `reduceMotionDismissDelay`) and a `static func+dismissDelay(reduceMotion:) -> UInt64` selector. The T-1336 ticket+description explicitly raises the trade-off: "Add `defaultDismissDelay`+(3 s) and `reduceMotionDismissDelay` (30 s) constants (or share with+MermaidPreviewCard via a small helper if the duplication is bothersome)."++A shared helper would mean either a new file (e.g.,+`Services/AccessibilityTiming.swift`) or moving the constants out of+`MermaidPreviewCard` into a shared namespace.++### Decision++Duplicate the two `static let` constants and the+`dismissDelay(reduceMotion:)` selector inside `ImageBlockView`, with the+same names and values. Update the existing in-sync comment at+`ImageBlockView.swift:222` to document the deliberate duplication and+point at `MermaidPreviewCard.dismissDelay(reduceMotion:)`.++### Rationale++The duplication is ~10 lines per view. The existing comment at+`ImageBlockView.swift:221–222` already declares the in-sync convention+between the two views ("Overlay button styling, animation durations,+and auto-dismiss timeout are kept in sync with MermaidPreviewCard's+overlay pattern"). Sharing would require a new module-level coupling+between two otherwise-independent views to save those ~10 lines.++The values are WCAG-derived (Timing Adjustable, Level A — at least 10×+the default). A future change to either constant would necessarily be+audit-driven, which means the change would touch both files together+regardless of how the constants are organised.++### Alternatives Considered++- **New shared file (e.g., `AutoDismissTiming` namespace)**: Rejected as+ premature abstraction for two call sites. Adds an import to both+ views and a new file to maintain.+- **Move constants into `MermaidPreviewCard` and reference them from+ `ImageBlockView`**: Rejected because it creates a one-way dependency+ from `ImageBlockView` on a sibling view's internals. Discoverability+ would suffer (the next reader of `ImageBlockView` would not expect+ the constant to live in `MermaidPreviewCard`).+- **Inline the literals in `ImageBlockView` without named constants**:+ Rejected because the WCAG-2.2.1 rationale is invisible at the call+ site, and the unit-test surface that T-1042's review added (selector+ function exposed via `static`) would be lost.++### Consequences++**Positive:**++- No new file, no new module-level coupling.+- The unit-test pattern carries over identically — the new+ `ImageBlockViewAccessibilityTests` can mirror the T-1042 selector+ tests without needing to know about `MermaidPreviewCard`.+- Each view's auto-dismiss behaviour is fully discoverable from a+ single file.++**Negative:**++- Two definitions of the same numeric values. Drift risk is mitigated+ by the in-sync comment and by the fact that both values are+ audit-driven (any change would update both call sites together).++---
diff --git a/specs/linked-image-auto-dismiss-a11y/tasks.md b/specs/linked-image-auto-dismiss-a11y/tasks.mdnew file mode 100644index 0000000..72d672e--- /dev/null+++ b/specs/linked-image-auto-dismiss-a11y/tasks.md@@ -0,0 +1,25 @@+---+references:+ - specs/linked-image-auto-dismiss-a11y/smolspec.md+ - specs/linked-image-auto-dismiss-a11y/decision_log.md+---+# Linked-Image Overlay Auto-Dismiss Accessibility++- [x] 1. iOS linked-image overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise) <!-- id:vnry7qc -->+ - Reference: specs/linked-image-auto-dismiss-a11y/smolspec.md (Requirements 1; 2; 3; 4; 5; 6; Implementation Approach steps 1-3; 4; 5).+ - Outcome: ImageBlockView.handleiOSOverlayTap(linkURL:) selects between 3s and 30s sleep durations based on a newly added @Environment(\.accessibilityReduceMotion) read. Durations are expressed as named static let constants (defaultDismissDelay; reduceMotionDismissDelay) with a static dismissDelay(reduceMotion:) selector — the same shape as MermaidPreviewCard.dismissDelay. The visible overlay reveal animation; macOS hover behaviour; second-tap-follows-link behaviour; and existing card-level accessibility actions are unchanged. The stale doc comment at line 270 is updated to no longer claim an unconditional 3s timer; the in-sync comment at line 222 is extended to note the deliberate duplication.+ - File: prism/Views/ImageBlockView.swift.+ - Pattern reference: prism/Views/MermaidPreviewCard.swift (lines 204-217 for constants/selector; line 231 for the selector call; lines 233-244 for the surrounding Task block).+ - Verification: manual iOS Simulator pass — enable Settings > Accessibility > Motion > Reduce Motion; tap a linked image; confirm the overlay stays for ~30s; with Reduce Motion off it dismisses at ~3s.++- [x] 2. ImageBlockViewAccessibilityTests cover construction and dismiss-delay selector for both reduceMotion values <!-- id:vnry7qd -->+ - Reference: specs/linked-image-auto-dismiss-a11y/smolspec.md (Tests subsection).+ - Outcome: prismTests/ImageBlockViewAccessibilityTests.swift mirrors MermaidPreviewCardAccessibilityTests with three Swift Testing cases — a (non-parameterised) construction smoke test (the env-read plumbing means reduceMotion is not an init argument; the behavioural fork is covered by dismissDelaySelection); a dismissDelayConstants test asserting the WCAG-2.2.1 10× relationship; and a parameterised dismissDelaySelection test for the selector function. No catalog-key sentinel tests (no new strings; the action names already exist in the catalog).+ - File: prismTests/ImageBlockViewAccessibilityTests.swift (new).+ - Verification: xcodebuild build-for-testing succeeds; targeted test-without-building run executes all four cases green on macOS (viewConstructionSucceeds; dismissDelayConstants; dismissDelaySelection × 2).+ - Blocked-by: vnry7qc (iOS linked-image overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise))++- [x] 3. Lint, build-ios, build-macos, and test-quick pass with zero warnings <!-- id:vnry7qe -->+ - Reference: specs/linked-image-auto-dismiss-a11y/smolspec.md (whole spec).+ - Verification: make lint — clean; make build-macos — Build Succeeded; make build-ios — Build Succeeded; make test-quick green (or, if the known intermittent hang recurs per memory note blitz_test_timeout, xcodebuild build-for-testing as substitute and pre-push review re-runs the full suite).+ - Blocked-by: vnry7qc (iOS linked-image overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise)), vnry7qd (ImageBlockViewAccessibilityTests cover construction and dismiss-delay selector for both reduceMotion values)
diff --git a/specs/linked-image-auto-dismiss-a11y/implementation.md b/specs/linked-image-auto-dismiss-a11y/implementation.mdnew file mode 100644index 0000000..d7e9666--- /dev/null+++ b/specs/linked-image-auto-dismiss-a11y/implementation.md@@ -0,0 +1,347 @@+# Implementation Explanation: Linked-Image Overlay Auto-Dismiss Accessibility (T-1336)++Branch: `worktree-T-1336-linked-image-auto-dismiss-a11y`+Commits:+- `301b7d2` T-1336: Extend linked-image overlay auto-dismiss timer for Reduce Motion+- `6cf920d` T-1336: Apply pre-push review fixes++Sibling to: T-1042 (PR #270 / commit `3a46945` on main) — same audit finding (H2),+same shape of fix, applied to the Mermaid card overlay.++---++## Beginner Level++### What Changed++If you tap an image in Prism that has a link attached on iOS, two buttons+slide in over the image: "View Image" and "Follow Link". They used to+disappear by themselves after 3 seconds. For people with motor+disabilities — folks who can't reliably tap a small target in 3 seconds —+that's too fast. The buttons would vanish before they could pick one.++This change keeps the 3-second timer for everyone else, but extends it+to 30 seconds when the user has turned on the system's "Reduce Motion"+setting (Settings → Accessibility → Motion → Reduce Motion on iOS).++### Why It Matters++"Reduce Motion" is the system signal Apple recommends as a proxy for+"this user benefits from less aggressive animations and longer+interaction windows." It's also what the accessibility audit+(internal document `docs/accessibility-review.md`, finding H2) named as+the trigger for extending timers.++The 30-second figure isn't arbitrary: it comes from WCAG 2.2.1+("Timing Adjustable", Level A) — the global web-accessibility+guideline that says if a timer is required, the user should get at+least 10× the default time as an option.++Without this fix, sighted users with motor needs were quietly+locked out of the overlay buttons; they could still reach the actions+via VoiceOver/Voice Control (those work regardless of overlay+visibility), but the visible affordance was inaccessible to them.++### Key Concepts++- **Linked image**: A markdown image that's also a link, like+ `[](https://example.com)`. Tap the image, choose+ to view it large or follow the link.+- **Overlay**: A small floating panel with buttons that appears on top+ of the image once you tap it. On iOS it's "tap once to reveal, tap+ again on the image to follow the link." On macOS it appears on+ hover (no timer needed there).+- **Auto-dismiss timer**: A countdown that hides the overlay+ automatically if the user doesn't interact, so the overlay doesn't+ permanently cover the image.+- **Reduce Motion**: A system-wide iOS/macOS accessibility preference.+ Originally about disabling animations for users with vestibular+ sensitivity; Apple now treats it as a broader "I want less motion+ and more time" signal.+- **Environment value (SwiftUI)**: A way to read settings — like the+ current colour scheme or the user's Reduce Motion preference —+ without manually passing them through every view.++---++## Intermediate Level++### Changes Overview++One source file modified, one test file added:++| File | Change | Lines |+|---|---|---|+| `prism/Views/ImageBlockView.swift` | +1 env property, +3 static members, wire selector at sleep call, update two doc/in-sync comments | +26 / -3 |+| `prismTests/ImageBlockViewAccessibilityTests.swift` | New Swift Testing suite | +90 |+| `CHANGELOG.md` | Entry under `[Unreleased]` → `Added` | +1 |+| `specs/linked-image-auto-dismiss-a11y/{smolspec,decision_log,tasks}.md` | New spec folder | +400 |++The production change in `ImageBlockView.swift`:++```swift+// 1. New env read alongside the existing eight+@Environment(\.accessibilityReduceMotion) private var reduceMotion++// 2. New named constants + selector under a fresh MARK section+static let defaultDismissDelay: UInt64 = 3_000_000_000+static let reduceMotionDismissDelay: UInt64 = 30_000_000_000++static func dismissDelay(reduceMotion: Bool) -> UInt64 {+ reduceMotion ? reduceMotionDismissDelay : defaultDismissDelay+}++// 3. Selector wired at the existing Task.sleep call site+let dismissDelay = Self.dismissDelay(reduceMotion: reduceMotion)+overlayDismissTask = Task {+ try await Task.sleep(nanoseconds: dismissDelay)+ ...+}+```++### Implementation Approach++The change mirrors `MermaidPreviewCard`'s T-1042 fix shape exactly — same+constant names, same selector signature, same `Task.sleep(nanoseconds:)`+call pattern — so the two overlay implementations stay structurally+parallel.++The plumbing diverges in one place: `MermaidPreviewCard` takes+`reduceMotion: Bool` as an init parameter (lifted out of+`CompactDocumentLayout` / `RegularDocumentLayout` at the construction+call site), whereas `ImageBlockView` reads+`@Environment(\.accessibilityReduceMotion)` directly. Both patterns are+already in use in this codebase. The env-read approach was chosen for+`ImageBlockView` because:++1. Its sole call site (`TextualBlockView.swift:144`) only constructs+ one view — there is no sibling consumer to amortise a lift.+2. The file already declares nine `@Environment(...)` properties at+ the same struct level, so adding one matches the established style.+3. The diff is contained to one source file plus the new test, with+ zero touch on the unrelated parent view.++### Trade-offs++| Decision | Chosen | Alternative | Why chosen |+|---|---|---|---|+| Reduce Motion signal | Env read in `ImageBlockView` | Thread init param via `TextualBlockView` | No sibling consumer; smaller diff; matches the file's existing env-read pattern |+| Dismiss-delay constants | Duplicate in `ImageBlockView` | Share via a helper file/namespace | Bounded 10-line duplication; WCAG-derived stable values; preserves existing "kept in sync" comment-based convention; tighter discoverability per view |+| Fade-in animation | Untouched | Honour Reduce Motion for the 0.2s reveal | Out of scope — audit named only the dismiss timer; parity with T-1042 |+| Bounded vs persistent | Bounded at 30 s | Disable timer entirely under Reduce Motion | Persistence was rejected during T-1042 review (overlay obscures image content); same reasoning applies here |+| Construction test shape | Single-shot smoke test | Parameterised over both `reduceMotion` values (mirrors T-1042) | Env-read plumbing means `reduceMotion` is not an init arg — parameterising adds no compile-path coverage. Behavioural fork covered by `dismissDelaySelection` |++The two design choices are written up in+`specs/linked-image-auto-dismiss-a11y/decision_log.md` (Decisions 1 and 2).++### Tests++`ImageBlockViewAccessibilityTests` adds three Swift Testing cases:++1. **`viewConstructionSucceeds`** — compilation smoke test. Confirms the+ modifier chain plus the new dismiss-delay selector call site+ type-check. (ViewInspector is not a project dependency, so+ behavioural verification of the modifier set requires manual+ VoiceOver / Reduce Motion runs on iOS.)+2. **`dismissDelayConstants`** — asserts the two named constants equal+ `3_000_000_000` / `30_000_000_000` and that the 10× WCAG 2.2.1+ relationship holds.+3. **`dismissDelaySelection`** — parameterised over both `Bool` inputs,+ asserts the static selector returns the right constant.++No catalog-key sentinel tests are duplicated from+`MermaidPreviewCardAccessibilityTests` — T-1336 introduces no new+user-facing strings (the "View Image" / "Follow Link" action keys+already exist).++---++## Expert Level++### Technical Deep Dive++#### SwiftUI environment-read invalidation surface++Adding a tenth `@Environment` property to `ImageBlockView` widens the+re-evaluation surface by one signal: any block-level `ImageBlockView`+in a `LazyVStack` will now re-run `body` when the user toggles Reduce+Motion in Control Center. For non-linked images this is wasted work —+the value is only consumed inside `#if os(iOS)` →+`handleiOSOverlayTap`, on a discrete user-tap event.++The trade-off was accepted because:++- Reduce Motion toggles fire at human-decision frequency (once per+ session at most), not per-frame.+- The view already reads eight environment values; one more does not+ materially change the invalidation profile.+- `contentView` short-circuits on `loadState`, so the re-eval for+ `.loaded` images is a cheap `successView` rebuild — no image+ reload, no SVG re-rasterisation (those are gated on `colorScheme`+ / `displayScale` via `.onChange`).+- Extracting the iOS linked overlay into a separate child view to+ localise the env read is a hypothetical perf win for a hypothetical+ bottleneck on a rare user event. Yagni applies.++#### `static let` + `static func` on a `View` struct++The constants and selector are exposed at `internal` (default)+visibility so the test target (which uses `@testable import prism`)+can reach them. The pattern was established by T-1042's review of+`MermaidPreviewCard` — the static selector was extracted from an+inline ternary specifically so it could be asserted in unit tests+without timing dependencies or ViewInspector.++A `private` visibility would also be reachable via `@testable`, and+would tighten the API surface. The promotion was deferred because:++- It would need to land in both `ImageBlockView` and+ `MermaidPreviewCard` together to keep the parallel.+- Out of scope for the chore; a follow-up cleanup ticket can address+ both files at once.++#### `Task.sleep(nanoseconds: UInt64)` vs `Task.sleep(for: Duration)`++The modern `Task.sleep(for: Duration)` API would express the intent+more cleanly:++```swift+static let defaultDismissDelay: Duration = .seconds(3)+static let reduceMotionDismissDelay: Duration = .seconds(30)+```++The nanoseconds form was retained to mirror `MermaidPreviewCard`+verbatim. A migration to `Duration` is a coordinated cross-file change+(both views, both test files) and would be its own cleanup ticket —+not appropriate to mix into a focused accessibility fix.++#### Race semantics of the dismiss-task++The Task is cancelled-and-rescheduled on every re-tap. Under the 30 s+window, the cancellation surface is identical to the 3 s case:++- `dismissOverlay()` (called from second tap and from the second-tap+ branch) cancels the in-flight task.+- `.onDisappear` (line 97–100) cancels on scroll-out.+- On scroll-back, `showOverlay` persists as `@State` but no fresh+ timer fires; the next tap re-enters the `else` branch and rebuilds+ the task.++The bounded-timer requirement is preserved: the timer cannot exceed+30 s in any code path. The 30 s window does make "scroll away+mid-window, scroll back" more likely to land while `showOverlay` is+still nominally true (compared to the 3 s case which almost never+overlaps a typical gesture). The fix for that — resetting `showOverlay`+in `.onDisappear` — is documented in the spec as a follow-up if user+feedback identifies it as disruptive.++### Architecture Impact++The repo now has three patterns for the same env-derived signal across+its overlay views:++| View | Pattern |+|---|---|+| `MermaidPreviewCard` | Init parameter (lifted from layout) |+| `MermaidPlaceholderCard` | Init parameter (lifted, paired with sibling) |+| `ImageBlockView` | Direct env read |+| `CompactDocumentLayout` / `RegularDocumentLayout` | Direct env read (the source of the lift) |+| `MermaidPlaceholderCard` (nested view) | Direct env read |++This heterogeneity pre-dates T-1336 — the decision log Decision 1 notes+this explicitly. T-1336 does not introduce the heterogeneity, but it+also does not consolidate it. A future "unify reduceMotion plumbing"+chore could pick a single pattern; the existing heterogeneity is more+historical-debt than intentional design.++### Potential Issues++1. **Visibility creep**: `internal static let` constants on a view+ struct are part of the module's API surface. Extending the test+ surface here without locking it down to `private` (reachable via+ `@testable`) is a small tax — every static added "for testing" is+ a future-refactor obstacle. Acceptable for one selector; worth+ reconsidering if the pattern spreads.++2. **Inferred audit signal**: Reduce Motion is being used as a proxy+ for motor-interaction needs. This is the trigger the audit (and+ T-1042) named, and Apple HIG provides no separate "interaction+ timing" preference. False positives (Reduce Motion enabled but no+ motor need) only widen the overlay window; no functional+ regression. False negatives (motor need but Reduce Motion not+ enabled) are not addressed — they would need a separate signal+ that doesn't exist in the platform.++3. **Test divergence from T-1042**: The construction smoke test is+ single-shot here vs. parameterised in T-1042. A future reader+ comparing the two test files will notice the asymmetry. The+ smolspec and the test file's doc comment both explain why; the+ tasks.md outcome text was updated in the second commit+ (`6cf920d`) after pre-push review caught the stale wording.++4. **Cross-file drift on the constants**: With the values duplicated+ in two files (`MermaidPreviewCard.dismissDelay` and+ `ImageBlockView.dismissDelay`), a future change to either value+ that doesn't update both would silently desynchronise. Mitigation:+ the WCAG-derived values are stable, and the in-sync comment in+ each file points at the other. A future audit-driven change would+ necessarily touch both files together.++---++## Completeness Assessment++### Fully implemented++- ✅ iOS auto-dismiss extends to 30 s under Reduce Motion (smolspec+ Requirement 1)+- ✅ iOS auto-dismiss stays at 3 s when Reduce Motion off (Requirement 2)+- ✅ Visible reveal animation / fade-in unchanged (Requirement 3)+- ✅ macOS hover overlay unaffected (Requirement 4)+- ✅ Card-level accessibility actions preserved (Requirement 5)+- ✅ Second-tap-follows-link behaviour preserved (Requirement 6)+- ✅ Timer remains bounded in both modes (Requirement 7)+- ✅ No new user-facing strings (Requirement 8)+- ✅ Constants + selector + env read in source (Implementation steps 1–3)+- ✅ Stale doc comment updated (Implementation step 4)+- ✅ In-sync comment extended with deliberate-duplication callout+ (Implementation step 5)+- ✅ Three Swift Testing cases passing under targeted `xcodebuild+ test`: `viewConstructionSucceeds`, `dismissDelayConstants`,+ `dismissDelaySelection` × 2 arg pairs+- ✅ Lint clean, `make build-macos` and `make build-ios` both Build+ Succeeded+- ✅ CHANGELOG entry+- ✅ Spec folder with smolspec, decision log, tasks (all marked `[x]`)++### Partial / deferred++- ⚠ `make test-quick` full-suite run hit the known intermittent+ runner-cascade issue documented in memory notes+ `blitz_test_timeout` and `feedback_test_runner_crashes`. The new+ tests were verified green via a targeted+ `xcodebuild test -only-testing:prismTests/ImageBlockViewAccessibilityTests`+ run. The pre-push review skill / pr-pilot will re-run the full+ suite from CI.++### Out of scope (intentionally not done — per spec)++- 🚫 Sharing dismiss-delay constants between `MermaidPreviewCard` and+ `ImageBlockView` via a helper (Decision 2)+- 🚫 Threading `reduceMotion` through `TextualBlockView` as an init+ parameter (Decision 1)+- 🚫 Removing the auto-dismiss timer entirely+- 🚫 Honouring Reduce Motion for the 0.2 s fade-in animation+- 🚫 Reduce-Transparency support for the `adaptiveGlassEffect()`+ overlay buttons (tracked separately under T-1044 / H4)+- 🚫 Changing macOS hover-based overlay behaviour+- 🚫 User-configurable timeout duration in Settings+- 🚫 Promoting the static constants from `internal` to `private`+ (would need parallel change in `MermaidPreviewCard` — separate cleanup)+- 🚫 Migrating `Task.sleep(nanoseconds:)` to `Task.sleep(for: Duration)`+ (cross-file refactor — separate cleanup)++### Missing / open++None. All smolspec requirements are covered by code, all decision-log+decisions match the implementation, all tasks marked complete.
Local make test-quick hit the known intermittent runner-cascade (memory notes blitz_test_timeout / feedback_test_runner_crashes) — many tests reported as 'failed' in 0.000 seconds, which is the cascade signature. Targeted xcodebuild test -only-testing:prismTests/ImageBlockViewAccessibilityTests confirmed all four cases green. The pr-pilot run will rerun the full suite from CI; double-check the cascade does not recur on a fresh runner.
The behavioural change (30 s window under Reduce Motion vs 3 s default) is not unit-testable without ViewInspector. Smolspec Task 1 calls for a manual iOS Simulator pass: Settings → Accessibility → Motion → Reduce Motion ON, tap a linked image, confirm overlay stays for ~30 s; toggle OFF, confirm ~3 s. This has not yet been run on simulator in the worktree; it should happen before merge.