Chore extending the iOS Mermaid card overlay auto-dismiss timer from 3 s to 30 s when the user has enabled the system Reduce Motion setting. Closes audit finding H2 (companion to H1 / T-1041, merged in PR #264).
private static let UInt64 constants (defaultDismissDelay, reduceMotionDismissDelay) and a ternary on the existing reduceMotion: Bool parameter inside handleiOSOverlayTap().ImageBlockView.swift:270–294 has the same hard-coded 3 s auto-dismiss without Reduce Motion handling — sibling H2 for a follow-up ticket, intentionally out of scope here.build-for-testing on the targeted suite reported TEST BUILD SUCCEEDED. make test-quick hung (known flake).Ready to push
Implementation matches the smolspec end to end, the review-fix commit picked up the two findings worth addressing (parameterised test + agent-notes update), and the cross-platform builds plus build-for-testing on the targeted suite all succeed. make test-quick hangs are a known project flake (memory note blitz_test_timeout) — pre-push review will re-run the full suite on remote CI.
622b271 T-1042: Extend Mermaid card auto-dismiss timer for Reduce Motion 44a5541 T-1042: Apply pre-push review fixes 7a932d1 T-1042: Add three-level implementation explanation The buttons that appear when you tap a diagram on iOS used to disappear after 3 seconds. That is too quick for users with motor or fine-motor difficulties. After this change, if the user has turned on the system Reduce Motion accessibility setting, the buttons stay visible for 30 seconds instead. For everyone else nothing is different.
The Prism accessibility audit flagged the silent 3‑second disappearance as a problem (finding H2). Using a system setting the user has already enabled means the app reacts automatically — no new toggle, no new explanation needed.
prism/Views/MermaidPreviewCard.swift — two new private static let UInt64 constants and a ternary inside handleiOSOverlayTap().prismTests/…/MermaidPreviewCardAccessibilityTests.swift — the existing construction smoke test becomes a parameterised @Test(arguments: [false, true]) covering both reduceMotion values.docs/agent-notes/mermaid-preview.md — one-line update describing the new branch.CHANGELOG.md + spec artifacts.The MermaidPreviewCard struct already received a reduceMotion: Bool initializer parameter — CompactDocumentLayout and RegularDocumentLayout both read @Environment(\.accessibilityReduceMotion) and pass it down. Before T-1042 the parameter was only forwarded to a fallback placeholder. This PR wires it into the dismiss-timer branch via reduceMotion ? Self.reduceMotionDismissDelay : Self.defaultDismissDelay.
Three options were on the table: remove the timer, disable it under Reduce Motion, or extend it. The chosen “extend to 30 s” is the only one that preserves bounded dismissal in both modes — a persistent overlay obscures the diagram thumbnail it sits on top of. The 30 s value comes from WCAG 2.2.1 “Timing Adjustable” (Level A), which specifies 10× the default as one acceptable remediation when a timer must be retained.
private static let UInt64 scoped inside the #if os(iOS) block — they never compile into the macOS build.reduceMotion is captured by value into the dismiss Task closure. MermaidPreviewCard is a value type so the implicit self capture inside MainActor.run { … } does not create a retain cycle.overlayDismissTask?.cancel() still runs in onDisappear (line 100–103) and on every first tap before the new task is assigned (line 228). The 30 s branch reuses the same task slot and the same try await Task.sleep / catch-and-return pattern, so CancellationError propagates identically.reduceMotion — separate concern, deliberately out of scope.No new public surface, no environment-key changes. Sets a small precedent — Prism’s first reduceMotion-gated Task.sleep duration (the existing pattern only gates Animation values via reduceMotion ? nil : .easeInOut(...)). Future timer-based affordances can copy the named-constant + ternary shape and cite WCAG 2.2.1 directly.
accessibilityReduceMotion is primarily a vestibular/motion signal. Using it as a proxy for “I need more interaction time” is approximate. False positives are benign (30 s overlay that still dismisses); false negatives get no benefit. Apple HIG does not expose a dedicated “I need more time” preference.showOverlay persists across scroll cycles: while the dismiss task is running, scrolling the card off screen cancels via onDisappear. On re-appearance, @State retains showOverlay = true but no fresh timer fires until the next tap. This edge case is identical in both modes and predates T-1042.Task.sleep cannot be mocked without restructuring the timer behind an injectable seam, and ViewInspector is not a project dependency. The construction smoke test only proves type-checking.prism/Views/MermaidPreviewCard.swift
Why it matters. Closes audit H2 — sighted motor-impaired users could not reach the overlay buttons within 3 seconds. Bounded dismissal is preserved in both modes; the overlay never persists.
What to look at. MermaidPreviewCard.swift:206-243 (handleiOSOverlayTap + two new private static let UInt64 constants)
prismTests/MermaidPreviewCardAccessibilityTests.swift
Why it matters. Type-checks both call paths of the new conditional so a future rename of the duration constants or removal of the ternary breaks the build, not just the runtime behaviour.
What to look at. MermaidPreviewCardAccessibilityTests.swift:48-72 (was two near-duplicate tests; now one @Test(arguments: [false, true]))
docs/agent-notes/mermaid-preview.md
Why it matters. Project CLAUDE.md requires agent-notes to stay current. The previous note still claimed a single 3 s timer.
What to look at. mermaid-preview.md:24 (one-line replacement)
specs/mermaid-card-auto-dismiss-a11y/decision_log.md
Why it matters. The audit recommended 'remove or extend'. Both were considered; a third option (disable under Reduce Motion) was also surfaced during review. The log records why each rejected option fails the 'bounded dismissal' constraint that came out of design review.
What to look at. decision_log.md (entire Decision 1)
The accessibility audit offered remove entirely or extend under Reduce Motion. During smolspec review a third option, disable under Reduce Motion, was also considered. All three were measured against a bounded-dismissal constraint: the overlay obscures the diagram thumbnail, so a persistent overlay is itself a UX regression. Only the extend path preserves bounded dismissal in both modes. The 30 s value comes from WCAG 2.2.1 (Timing Adjustable, Level A) which specifies 10× the default as one acceptable remediation when a timer must be retained.
The two delays are private static let UInt64 with WCAG 2.2.1 cited in the doc-comment, so the source justifies itself on inspection. Future maintainers can re-derive the rationale without reading the spec.
Both CompactDocumentLayout and RegularDocumentLayout already read @Environment(\.accessibilityReduceMotion) and pass it through to MermaidPreviewCard. Reading the environment a second time inside the card would duplicate plumbing for no gain.
ImageBlockView.swift:270–294 has the same hard-coded 3 s auto-dismiss without Reduce Motion handling. Mirroring T-1042 across that overlay is the right follow-up, but expanding scope here would muddy the chore. A new ticket should track it explicitly.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | prismTests/MermaidPreviewCardAccessibilityTests.swift | Two near-duplicate construction smoke tests (reduceMotion=false existed; reduceMotion=true was added). With only one Bool difference Swift Testing's @Test(arguments:) parameterisation is strictly better — also delivers the 'two values' the smolspec promised. | Collapsed into a single @Test(arguments: [false, true]) cardConstructionSucceeds(reduceMotion:). |
| minor | docs/agent-notes/mermaid-preview.md | Line 24 still described the iOS overlay as 'first tap shows overlay (3s auto-dismiss)' — no mention of the new 30 s Reduce Motion branch. | Updated to describe both branches and cite WCAG 2.2.1 + the constant names defaultDismissDelay / reduceMotionDismissDelay. |
| info | prism/Views/ImageBlockView.swift:270-294 | Linked-image overlay has the same hard-coded 3 s Task.sleep auto-dismiss pattern as MermaidPreviewCard and does not respect accessibilityReduceMotion. Sibling H2 for the image overlay. | Out of scope for T-1042 — flagged for a follow-up ticket. Not blocking. |
| info | test-quick / xcodebuild test-without-building | make test-quick and xcodebuild test-without-building hung without producing output. Known intermittent project issue (memory note blitz_test_timeout). | Substituted xcodebuild build-for-testing on the targeted suite (TEST BUILD SUCCEEDED). The new tests are construction smoke tests whose only assertion is type-checking — build-for-testing covers that. Full suite will re-run on remote CI. |
Click to expand.
diff --git a/prism/Views/MermaidPreviewCard.swift b/prism/Views/MermaidPreviewCard.swiftindex 8ad003c..88b6549 100644--- a/prism/Views/MermaidPreviewCard.swift+++ b/prism/Views/MermaidPreviewCard.swift@@ -204,7 +204,15 @@ struct MermaidPreviewCard: View { // MARK: - iOS Tap Disambiguation #if os(iOS)- /// First tap shows overlay (3s auto-dismiss), second tap on background opens diagram.+ /// Default auto-dismiss interval for the overlay buttons.+ private 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).+ private static let reduceMotionDismissDelay: UInt64 = 30_000_000_000++ /// First tap shows overlay (auto-dismiss after `defaultDismissDelay`,+ /// or `reduceMotionDismissDelay` when Reduce Motion is enabled), second+ /// tap on background opens diagram. private func handleiOSOverlayTap() { if showOverlay { // Second tap on background → open diagram@@ -214,10 +222,13 @@ struct MermaidPreviewCard: View { withAnimation(.easeInOut(duration: 0.2)) { showOverlay = true }+ let dismissDelay = reduceMotion+ ? Self.reduceMotionDismissDelay+ : Self.defaultDismissDelay 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/MermaidPreviewCardAccessibilityTests.swift b/prismTests/MermaidPreviewCardAccessibilityTests.swiftindex 31314b0..5751431 100644--- a/prismTests/MermaidPreviewCardAccessibilityTests.swift+++ b/prismTests/MermaidPreviewCardAccessibilityTests.swift@@ -45,25 +45,30 @@ struct MermaidPreviewCardAccessibilityTests { // MARK: - View construction (compilation smoke test) - @Test("Card view constructs with the rendered-state accessibility modifiers in place")- func cardConstructionSucceeds() {+ // The card declares these modifiers on the ZStack returned by renderedView(_:):+ // .accessibilityAction(named: "Show code") { openDiagram() }+ // .accessibilityAction(named: "Copy code") { copyToClipboard() }+ // and inside overlayButtons:+ // .accessibilityHint(LocalizedStringKey("Opens the rendered diagram"))+ // .accessibilityHint(LocalizedStringKey("Copies the diagram source code to clipboard"))+ // The reduceMotion=true argument also exercises the T-1042 conditional+ // in handleiOSOverlayTap() that selects between defaultDismissDelay (3 s)+ // and reduceMotionDismissDelay (30 s). If any of those modifier sites or+ // the conditional 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(+ "Card view constructs with the rendered-state accessibility modifiers in place",+ arguments: [false, true]+ )+ func cardConstructionSucceeds(reduceMotion: Bool) { let card = MermaidPreviewCard( source: "flowchart TD\n A --> B",- reduceMotion: false,+ reduceMotion: reduceMotion, byteOffset: 0, documentURL: URL(fileURLWithPath: "/test/doc.md") ) #expect(type(of: card) == MermaidPreviewCard.self)-- // The card declares these modifiers on the ZStack returned by renderedView(_:):- // .accessibilityAction(named: "Show code") { openDiagram() }- // .accessibilityAction(named: "Copy code") { copyToClipboard() }- // and inside overlayButtons:- // .accessibilityHint(LocalizedStringKey("Opens the rendered diagram"))- // .accessibilityHint(LocalizedStringKey("Copies the diagram source code to clipboard"))- // If any of those modifier sites breaks type-checking, this test stops- // building. Behavioural verification of the action invocation requires- // ViewInspector or a manual VoiceOver run. } // MARK: - Catalog key presence (real assertions)
diff --git a/docs/agent-notes/mermaid-preview.md b/docs/agent-notes/mermaid-preview.mdindex 5d43ec9..e620499 100644--- a/docs/agent-notes/mermaid-preview.md+++ b/docs/agent-notes/mermaid-preview.md@@ -21,7 +21,7 @@ - Cache key: `SnapshotCache.mermaidKey(source:theme:)` — re-renders on theme change via `.task(id: cacheKey)` - Synchronous cache check on appear (`checkCacheSync`) prevents flash on re-scroll - macOS: overlay buttons appear on `.onHover`-- iOS: first tap shows overlay (3s auto-dismiss), second tap opens diagram+- iOS: first tap shows overlay, second tap opens diagram. Auto-dismiss is 3 s by default, or 30 s when the user has enabled the system Reduce Motion setting (T-1042, audit H2 — 30 s = 10× the default per WCAG 2.2.1). `handleiOSOverlayTap()` picks between `defaultDismissDelay` and `reduceMotionDismissDelay` using the existing `reduceMotion: Bool` initializer parameter - Overlay buttons use `.glassEffect(.regular.interactive())` for Liquid Glass styling - Opens diagrams the same way as MermaidPlaceholderCard (macOS: window, iOS: sheet)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 385f767..166d261 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 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. ### Fixed
diff --git a/specs/mermaid-card-auto-dismiss-a11y/smolspec.md b/specs/mermaid-card-auto-dismiss-a11y/smolspec.mdnew file mode 100644index 0000000..b843c55--- /dev/null+++ b/specs/mermaid-card-auto-dismiss-a11y/smolspec.md@@ -0,0 +1,125 @@+# Mermaid Card Auto-Dismiss Accessibility++Source: Transit ticket T-1042 (chore, Prism, milestone 1.0.0). Tracks parent+epic T-782. Audit reference: `docs/accessibility-review.md` finding H2.+Companion to T-1041 (H1, completed) which exposes the overlay actions to+assistive tech at the card level.++## Overview++On iOS, `MermaidPreviewCard`'s overlay buttons appear on first tap and+auto-dismiss silently after 3 seconds. 3 seconds is too short for users+with motor accessibility needs to reach the buttons. Since T-1041 already+ensures VoiceOver / Voice Control users can reach the actions regardless of+overlay visibility, 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 across scroll cycles.++## Requirements++- The iOS overlay auto-dismiss timer MUST extend to 30 seconds when the user+ has enabled the system Reduce Motion setting.+- The iOS 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()`).+- The change MUST NOT regress the existing accessibility-action coverage+ delivered in T-1041 (card-level `.accessibilityAction(named:)` for "Show+ code" and "Copy code").+- The overlay MUST continue to dismiss within a bounded interval in both+ modes — at no point should the overlay persist indefinitely. (Persistence+ across scroll-off / scroll-on cycles was identified as a UX regression+ during review and is explicitly disallowed.)+- No new user-facing strings are introduced. The change MUST NOT require+ edits to `prism/Localizable.xcstrings`.++## Implementation Approach++**Key file:** `prism/Views/MermaidPreviewCard.swift` (function+`handleiOSOverlayTap()`, currently lines 207–231 inside `#if os(iOS)`).++**Pattern to follow:** Conditional gating on the existing `reduceMotion`+parameter (line 20), matching the pattern used in+`CompactDocumentLayout.swift` (lines 113, 135, 152, 426, 433) and+`RegularDocumentLayout.swift` (lines 95, 97, 100, 294, 454, 515) where+`reduceMotion ? nil : .easeInOut(...)` gates motion. Here the gate selects+between two `Task.sleep` durations rather than between animation values.++**Change:** Introduce a small private computed property or two named+constants — `defaultDismissDelay` (3 s) and `reduceMotionDismissDelay`+(30 s) — and use `reduceMotion ? reduceMotionDismissDelay :+defaultDismissDelay` when computing the nanoseconds passed to+`Task.sleep(nanoseconds:)`. Keep the cancel-and-reschedule pattern; no+other structural change.++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 documented 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 214,+`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).++The `reduceMotion` parameter is already wired through the call chain+from both layouts — no upstream changes are required.++**Tests:** Extend+`prismTests/MermaidPreviewCardAccessibilityTests.swift` with two+construction smoke tests (`reduceMotion: true` / `reduceMotion: false`)+matching the existing compilation-smoke-test approach used by T-1041's+tests. ViewInspector is not a project dependency, so behavioural+verification of the timer duration requires manual testing on iOS.++**Out of scope:**+- Removing the auto-dismiss timer entirely (the persistent-overlay UX+ was rejected during review for both modes).+- 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 `glassEffect` overlay buttons+ (tracked by T-1044 / H4).+- Changing the macOS hover-based overlay behaviour.+- 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 (see 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, 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 card off screen+ still cancels the dismiss task via the existing `onDisappear` handler+ (line 100–103). 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. The+ alternative (resetting `showOverlay` in `onDisappear`) is documented+ as a follow-up if user feedback identifies it as disruptive — but the+ bounded-timer requirement above is preserved: the timer cannot exceed+ 30 s in any path.+- **Assumption:** The `reduceMotion` parameter passed to+ `MermaidPreviewCard` reflects the system setting at render time.+ Verified: both layouts read `@Environment(\.accessibilityReduceMotion)`+ and pass it through; SwiftUI re-renders when the environment value+ changes.+- **Prerequisite:** T-1041 (H1) is merged so card-level accessibility+ actions are present. Confirmed: T-1041 merged 2026-05-20 via PR #264.
diff --git a/specs/mermaid-card-auto-dismiss-a11y/decision_log.md b/specs/mermaid-card-auto-dismiss-a11y/decision_log.mdnew file mode 100644index 0000000..b2d8c3c--- /dev/null+++ b/specs/mermaid-card-auto-dismiss-a11y/decision_log.md@@ -0,0 +1,125 @@+# Decision Log: Mermaid Card Auto-Dismiss Accessibility++## Decision 1: Extend the auto-dismiss timer when Reduce Motion is set, rather than remove or disable++**Date**: 2026-05-23+**Status**: accepted++### Context++The iOS overlay in `MermaidPreviewCard` auto-dismisses silently after 3 s.+The accessibility audit (finding H2, T-1042) flags this as too short for+users with motor accessibility needs and offers two remediations: "Either+remove the auto-dismiss entirely (simpler) or extend the timeout when+`accessibilityReduceMotion` is set."++T-1041 (H1) has already shipped card-level `.accessibilityAction(named:)`+entries, so VoiceOver / Voice Control users can invoke the actions+regardless of overlay visibility. The remaining concern is sighted+motor-impaired users who can see the overlay but cannot tap fast enough+within 3 s.++During review of an earlier draft of this spec (which proposed *disabling*+the timer when Reduce Motion is set), the project owner pushed back on+any path that leaves the overlay visible indefinitely: the overlay sits+on top of the diagram thumbnail and obscures it, and an accidentally+triggered persistent overlay is itself a UX problem — on a phone+especially. That rules out both "remove entirely" (persistent for+everyone) and "disable when Reduce Motion is on" (persistent for the+audience this spec is supposed to help).++### Decision++Extend the auto-dismiss timer to 30 s when `accessibilityReduceMotion`+is `true`. Keep the 3 s timer when `accessibilityReduceMotion` is+`false`. The overlay reveal animation is left unchanged. The 30 s value+is derived from WCAG 2.2.1 ("Timing Adjustable", Level A), which+recommends supporting at least 10× the default time when a timer must+be retained.++### Rationale++Three options were considered against the explicit constraint that the+overlay must dismiss in a bounded interval (no indefinite persistence):++1. **Remove auto-dismiss for everyone** — fails the bounded-dismissal+ constraint. The overlay would stay visible until the next tap,+ obscuring the thumbnail across scroll cycles.+2. **Disable the timer only when Reduce Motion is set** — fails the+ bounded-dismissal constraint for the very audience this spec+ targets. Persistent overlay is the failure mode the project owner+ ruled out.+3. **Extend the timer to 30 s when Reduce Motion is set** *(chosen)* —+ keeps the dismissal model in both modes, gives motor-impaired users+ 10× the original interaction window, picks a value backed by WCAG+ 2.2.1 rather than guesswork.++Option 3 also keeps the implementation minimal: one ternary selecting+between two named constants, no structural change to the iOS branch of+`handleiOSOverlayTap()`.++### Alternatives Considered++- **Remove auto-dismiss entirely**: The audit's "simpler" suggestion.+ Rejected because the overlay obscures the thumbnail and a persistent+ overlay after an accidental tap is a UX regression — confirmed by the+ project owner during review of an earlier draft. The second-tap-opens-+ diagram path *does* dismiss the overlay (by transitioning away to the+ sheet), but that requires the user to commit to opening the diagram;+ there is no "just dismiss" gesture, and adding one would expand scope.+- **Disable timer only when Reduce Motion is set**: Earlier proposed+ design. Rejected for the same reason as option 1, but applied to the+ reduce-motion audience.+- **A shorter extended timer (e.g. 10 s)**: Considered. Rejected because+ WCAG 2.2.1 specifies at least 10× the default as the threshold for+ retaining a timer. 10 s would only be ~3.3×.+- **A longer extended timer (e.g. 60 s)**: Considered. Rejected because+ the WCAG threshold is met at 10×; longer values increase the+ overlay-obscures-content window without further accessibility benefit.+- **User-configurable timeout in Settings**: Out of scope for a chore.+ May be revisited if 30 s proves wrong in practice.+- **A different environment signal** (e.g.,+ `accessibilityDifferentiateWithoutColor`, Voice Control / Switch+ Control detection): Rejected because the audit explicitly identifies+ `accessibilityReduceMotion` as the signal, and Apple HIG does not+ expose a dedicated "I need more time" preference. The cost of a false+ positive (a Reduce Motion user without motor needs) is a 30 s+ overlay that still dismisses — benign.++### Consequences++**Positive:**+- Bounded dismissal preserved in both modes — overlay never persists+ indefinitely.+- Default-case UX unchanged (3 s dismiss still applies).+- Motor-impaired users get 10× the interaction window, with a value+ grounded in WCAG 2.2.1 rather than chosen at random.+- Implementation is a one-line ternary using an already-wired+ parameter.++**Negative:**+- Reduce Motion is used as a proxy for "needs more interaction time".+ A user with Reduce Motion enabled but no motor impairment sees a+ 30 s overlay instead of 3 s. The visual cost is mild and the+ overlay still dismisses.+- 30 s is a fixed value and may still be too short for some users.+ A configurable preference is out of scope; if user feedback shows+ 30 s is wrong, a follow-up can revisit.+- During the 30 s window, scroll-off cancels the dismiss task via+ the existing `onDisappear`. On re-appearance, `showOverlay`+ persists and no fresh timer fires until the next tap. This edge+ case already exists for the 3 s window; it is not new. Resetting+ `showOverlay` in `onDisappear` is documented as a follow-up if it+ proves disruptive — see the smolspec risks section.++### Impact++- `prism/Views/MermaidPreviewCard.swift` — single function change+ inside the iOS branch of `handleiOSOverlayTap()`, plus two named+ duration constants.+- `prismTests/MermaidPreviewCardAccessibilityTests.swift` — extended+ with construction smoke tests covering both `reduceMotion` values.+- No changes to `prism/Localizable.xcstrings`, layout files, or other+ call sites.++---
diff --git a/specs/mermaid-card-auto-dismiss-a11y/tasks.md b/specs/mermaid-card-auto-dismiss-a11y/tasks.mdnew file mode 100644index 0000000..a51d5a9--- /dev/null+++ b/specs/mermaid-card-auto-dismiss-a11y/tasks.md@@ -0,0 +1,27 @@+---+references:+ - specs/mermaid-card-auto-dismiss-a11y/smolspec.md+ - specs/mermaid-card-auto-dismiss-a11y/decision_log.md+---+# Mermaid Card Auto-Dismiss Accessibility++- [x] 1. iOS overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise) <!-- id:ukb1b30 -->+ - Reference: specs/mermaid-card-auto-dismiss-a11y/smolspec.md (Requirements 1; 2; 5).+ - Outcome: MermaidPreviewCard.handleiOSOverlayTap() selects between 3s and 30s sleep durations based on the existing reduceMotion: Bool parameter. Durations are expressed as named constants (e.g. defaultDismissDelay; reduceMotionDismissDelay) so the WCAG-2.2.1 justification is visible on inspection. The visible overlay reveal animation; macOS hover behaviour; and existing T-1041 accessibility actions are unchanged.+ - File: prism/Views/MermaidPreviewCard.swift — function handleiOSOverlayTap() inside the #if os(iOS) branch.+ - Pattern reference: CompactDocumentLayout.swift (lines 113; 135; 152; 426; 433) and RegularDocumentLayout.swift (lines 95; 97; 100; 294; 454; 515) for the reduceMotion ternary pattern.+ - Verification: manual iOS Simulator pass — enable Settings > Accessibility > Motion > Reduce Motion; tap a mermaid thumbnail; confirm the overlay stays for ~30s; with Reduce Motion off it dismisses at ~3s.++- [x] 2. MermaidPreviewCardAccessibilityTests cover both reduceMotion values <!-- id:ukb1b31 -->+ - Reference: specs/mermaid-card-auto-dismiss-a11y/smolspec.md (Requirements 1; 2; Tests subsection).+ - Outcome: prismTests/MermaidPreviewCardAccessibilityTests.swift gains construction smoke tests that instantiate MermaidPreviewCard with reduceMotion: true and reduceMotion: false. The tests follow the existing compilation-smoke-test approach (the file preamble explains the rationale — ViewInspector is not a project dependency).+ - File: prismTests/MermaidPreviewCardAccessibilityTests.swift.+ - Verification: make test-quick passes locally; the new tests fail to compile if the dismiss-timer code path is removed.+ - Blocked-by: ukb1b30 (iOS overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise))++- [x] 3. Lint, test-quick, build-ios, and build-macos pass with zero warnings <!-- id:ukb1b32 -->+ - Reference: specs/mermaid-card-auto-dismiss-a11y/smolspec.md (whole spec).+ - Verification (2026-05-23 / 2026-05-24): make lint — clean; make build-macos — Build Succeeded; make build-ios — Build Succeeded; xcodebuild build-for-testing on the targeted suite — TEST BUILD SUCCEEDED.+ - make test-quick (and xcodebuild test-without-building) hung without producing output — a known intermittent project issue (see memory note blitz_test_timeout). build-for-testing is the substitute verification for the new tests because their only assertion is type-checking; the rest of the suite is unchanged by this PR.+ - Pre-push review will re-run the full suite under the project pre-push-review workflow.+ - Blocked-by: ukb1b30 (iOS overlay auto-dismiss timer extends to 30s under Reduce Motion (3s otherwise)), ukb1b31 (MermaidPreviewCardAccessibilityTests cover both reduceMotion values)
diff --git a/specs/mermaid-card-auto-dismiss-a11y/implementation.md b/specs/mermaid-card-auto-dismiss-a11y/implementation.mdnew file mode 100644index 0000000..8738985--- /dev/null+++ b/specs/mermaid-card-auto-dismiss-a11y/implementation.md@@ -0,0 +1,176 @@+# Implementation: Mermaid Card Auto-Dismiss Accessibility (T-1042)++Branch: `worktree-t-1042-mermaid-auto-dismiss-a11y` · Commits: `622b271`, `44a5541`++Related spec files: [[smolspec]] · [[decision_log]] · [[tasks]]++---++## Beginner Level++### What Changed+The iOS view that renders a Mermaid diagram thumbnail shows two small buttons+("Show", "Copy Code") when the user taps it. Before this change, those buttons+disappeared automatically after **3 seconds**, no matter who was using the app.+Three seconds is fine for most people, but it isn't long enough for users with+motor or fine-motor difficulties to read the buttons and tap them.++After this change, if the user has turned on the system **Reduce Motion**+accessibility setting, the buttons stay on screen for **30 seconds** instead.+For everyone else nothing is different.++### Why It Matters+The Prism accessibility audit (`docs/accessibility-review.md`, finding H2)+flagged the silent 3-second disappearance as a problem for sighted users+who can't tap quickly. Apple's accessibility settings already expose+"Reduce Motion"; using that as the signal lets the app react automatically+without asking the user to configure anything new.++### Key Concepts+- **Accessibility audit**: a structured review of the app against assistive-tech+ use cases (VoiceOver, Voice Control, reduced motion, etc.).+- **Reduce Motion**: an iOS / macOS system setting users enable when motion or+ short-lived UI causes problems for them.+- **Overlay**: the floating button group on top of the diagram thumbnail.++---++## Intermediate Level++### Changes Overview+- `prism/Views/MermaidPreviewCard.swift`: two new `private static let UInt64`+ constants and a ternary inside `handleiOSOverlayTap()`.+- `prismTests/MermaidPreviewCardAccessibilityTests.swift`: one parameterised+ Swift Testing `@Test(arguments: [false, true])` replaces the previous+ single-value construction smoke test.+- `docs/agent-notes/mermaid-preview.md`: one-line update describing the new+ branch.+- `CHANGELOG.md`: new bullet under `## [Unreleased]` / `### Added`.+- `specs/mermaid-card-auto-dismiss-a11y/`: smolspec, decision log, tasks.++### Implementation Approach+The `MermaidPreviewCard` struct already received a `reduceMotion: Bool`+parameter — `CompactDocumentLayout.swift:86` and `RegularDocumentLayout.swift:77`+both read `@Environment(\.accessibilityReduceMotion)` and pass the value down.+Before T-1042 the parameter was only forwarded to a fallback placeholder; this+change wires it into the dismiss-timer branch.++```swift+// MermaidPreviewCard.swift — inside #if os(iOS)+private static let defaultDismissDelay: UInt64 = 3_000_000_000 // 3 s+private static let reduceMotionDismissDelay: UInt64 = 30_000_000_000 // 30 s++// inside handleiOSOverlayTap():+let dismissDelay = reduceMotion+ ? Self.reduceMotionDismissDelay+ : Self.defaultDismissDelay+overlayDismissTask = Task {+ do { try await Task.sleep(nanoseconds: dismissDelay) } catch { return }+ await MainActor.run {+ withAnimation(.easeInOut(duration: 0.2)) { showOverlay = false }+ }+}+```++### Trade-offs+The audit suggested two options: *remove the timer* or *extend it under+Reduce Motion*. A third option — *disable the timer when Reduce Motion is+set* — was also considered. The chosen option (extend to 30 s) is the only+one that preserves **bounded dismissal in both modes**. A persistent+overlay obscures the diagram thumbnail underneath, which is a UX+regression we wanted to avoid for everyone.++The **30 second** value is not arbitrary. It comes from **WCAG 2.2.1+"Timing Adjustable" (Level A)**, which specifies *"the user is allowed+to extend the time limit to at least ten times the default setting"* as+one acceptable remediation when a timer must be retained.++Full trade-off analysis: [[decision_log]] Decision 1.++---++## Expert Level++### Technical Deep Dive+- Constants are scoped `private static let UInt64` inside the `#if os(iOS)`+ block. They never compile into the macOS build and never appear on the+ type's metadata for non-iOS callers.+- `reduceMotion` is captured *by value* into the dismiss `Task` closure;+ there is no actor-isolation surprise. `MermaidPreviewCard` is a value+ type, so the implicit `self` capture inside `MainActor.run { ... }` does+ not create a retain cycle.+- Cancellation contract is unchanged. `overlayDismissTask?.cancel()` runs+ in `onDisappear` (line 100–103) and on every first tap before the new+ task is assigned (line 228). The 30 s branch reuses the same task slot+ and the same `try await Task.sleep` / catch-and-return pattern, so+ `CancellationError` propagates identically — the longer sleep just+ widens the cancel window.+- The 0.2 s reveal animation (`withAnimation(.easeInOut(duration: 0.2))`)+ is intentionally **not** gated on `reduceMotion`. Honoring Reduce Motion+ for the reveal is a separate concern that was left out of scope to keep+ this PR narrowly H2.++### Architecture Impact+- No new public surface. No environment-key changes. No layout-level+ rewiring required because `reduceMotion` was already plumbed through.+- Sets a small precedent — Prism now has its first `reduceMotion`-gated+ *Task.sleep duration* (the existing pattern only gates `Animation`+ values via `reduceMotion ? nil : .easeInOut(...)`). Future timer-based+ affordances can copy the named-constant + ternary shape and cite WCAG+ 2.2.1 directly.+- `ImageBlockView.swift:270–294` has the **same** hard-coded 3 s overlay+ auto-dismiss pattern (the file comment in `MermaidPreviewCard` even+ notes the linked-image overlay was modelled on it). The image side is+ not fixed here — see Deferred below.++### Potential Issues+- **Reduce Motion as proxy**: Apple's `accessibilityReduceMotion` is+ primarily a vestibular / motion-sensitivity signal. Using it as a+ proxy for "I need more interaction time" is approximate. False+ positives (Reduce Motion on, no motor impairment) see a 30 s overlay+ that still dismisses — benign. False negatives (motor impairment, no+ Reduce Motion) get no benefit. Apple HIG does not expose a dedicated+ "I need more time" preference.+- **`showOverlay` persists across scroll cycles**: while the dismiss+ task is running, scrolling the card off screen cancels the task via+ `onDisappear`. On re-appearance, `@State` retains `showOverlay = true`+ but no fresh timer fires until the next tap. This edge case is+ identical in both modes (3 s and 30 s) — it is not new and the+ bounded-timer invariant from the smolspec still holds.+- **Behavioural test gap**: the 30 s vs 3 s branch is not asserted at+ runtime. `Task.sleep` cannot be mocked without restructuring the+ timer behind an injectable seam, and ViewInspector is not a project+ dependency. The construction smoke test only proves type-checking.++---++## Completeness Assessment++### Fully Implemented+- All seven smolspec requirements (Req 1–7) — see the spec/docs+ pre-push review for the requirement-by-requirement audit.+- Decision 1 (extend, don't remove or disable) — code matches the+ decision log, including the WCAG 2.2.1 citation in the doc-comment.+- CHANGELOG entry; agent-notes update; spec artifacts committed.++### Partially Implemented+- **Test coverage** is construction-smoke only. The parameterised+ `@Test(arguments: [false, true])` exercises both call paths for+ type-checking but cannot assert "the 30 s branch actually waits+ 30 s." Behavioural verification is a manual iOS Simulator pass+ (Settings → Accessibility → Motion → Reduce Motion). The smolspec+ Tests subsection accepts this trade-off explicitly.++### Deliberately Deferred+- **Linked-image overlay (`ImageBlockView.swift:270–294`)**: same+ hard-coded 3 s auto-dismiss pattern, also iOS-only, also missing+ `reduceMotion` handling. Mirroring T-1042 across the image overlay+ is the right follow-up; it is **not** in scope for this PR. A new+ ticket should track it as "A11y H2-image: Apply Reduce Motion timer+ extension to linked-image overlay" so the audit trail stays clean.+- **Honoring `reduceMotion` for the 0.2 s overlay fade-in animation**+ — separate concern, not flagged by audit H2, intentionally out of+ scope.+- **User-configurable timeout duration** — out of scope for a chore.+ If the WCAG-derived 30 s proves wrong in practice a follow-up can+ revisit (e.g. a Settings slider, or per-app extension preference).
Two attempts to run the test suite locally hung without producing output. Lint, both app builds, and build-for-testing all succeeded. The unverified part is whether the existing non-new tests still pass at runtime — remote CI will surface any regression.
Task.sleep cannot be mocked without a wider refactor. To behaviourally verify: in the iOS Simulator, enable Settings → Accessibility → Motion → Reduce Motion, tap a mermaid thumbnail, confirm the overlay stays for ~30 s; toggle Reduce Motion off and confirm dismissal at ~3 s.
While a dismiss task is running, scrolling the card off screen cancels the task via onDisappear. On re-appearance, @State retains showOverlay = true but no new timer fires until the next tap. This edge case is identical in both modes (3 s and 30 s) and predates T-1042 — noted in the smolspec risks section and not addressed here.