Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions apps/ios/CovenCave/CovenCave/Views/ContentZoom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import WebKit
struct ZoomTarget: Identifiable {
let id = UUID()
enum Content {
case image(UIImage)
/// A decoded image, with the description its message gave it (an
/// inline image's alt text) when there is one.
case image(UIImage, description: String? = nil)
case html(String)
/// A code block: the highlighted `<pre>` HTML for display plus the raw
/// text so the zoom surface can copy it natively.
Expand All @@ -22,6 +24,13 @@ struct ZoomTarget: Identifiable {
if case .code(_, let text) = content, !text.isEmpty { return text }
return nil
}

/// The VoiceOver name for an enlarged image: the description its message
/// gave it, or a generic name so VoiceOver never lands on a bare image.
static func imageAccessibilityLabel(description: String?) -> String {
let trimmed = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "Enlarged image" : trimmed
}
}

extension Notification.Name {
Expand All @@ -40,7 +49,9 @@ enum ContentZoom {
static func present(_ target: ZoomTarget) {
NotificationCenter.default.post(name: .caveZoomContent, object: target)
}
static func image(_ image: UIImage) { present(ZoomTarget(content: .image(image))) }
static func image(_ image: UIImage, description: String? = nil) {
present(ZoomTarget(content: .image(image, description: description)))
}
static func html(_ html: String) { present(ZoomTarget(content: .html(html))) }
static func code(html: String, text: String) {
present(ZoomTarget(content: .code(html: html, text: text)))
Expand All @@ -60,7 +71,8 @@ struct ZoomableContentView: View {
Color.black.ignoresSafeArea()
Group {
switch target.content {
case .image(let image): ZoomableImageView(image: image)
case .image(let image, let description):
ZoomableImageView(image: image, description: description)
case .html(let html): ZoomableHTMLView(html: html)
case .code(let html, _): ZoomableCodeView(html: html)
}
Expand Down Expand Up @@ -129,6 +141,7 @@ struct ZoomableContentView: View {
/// Pinch-to-zoom + drag-to-pan + double-tap-to-toggle for a native image.
private struct ZoomableImageView: View {
let image: UIImage
let description: String?

@State private var scale: CGFloat = 1
@GestureState private var pinch: CGFloat = 1
Expand All @@ -143,6 +156,8 @@ private struct ZoomableImageView: View {
Image(uiImage: image)
.resizable()
.scaledToFit()
.accessibilityLabel(ZoomTarget.imageAccessibilityLabel(description: description))
.accessibilityIdentifier("Zoomed image")
.scaleEffect(effectiveScale)
.offset(x: offset.width + drag.width, y: offset.height + drag.height)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Expand Down
7 changes: 4 additions & 3 deletions apps/ios/CovenCave/CovenCave/Views/MarkdownWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,8 @@ struct MarkdownWebView: UIViewRepresentable {
}
case "image":
Haptics.tap()
presentImage(src: body["src"] as? String, fallbackHTML: body["html"] as? String ?? "")
presentImage(src: body["src"] as? String, alt: body["alt"] as? String,
fallbackHTML: body["html"] as? String ?? "")
default:
if let html = body["html"] as? String, !html.isEmpty {
Haptics.tap()
Expand All @@ -557,7 +558,7 @@ struct MarkdownWebView: UIViewRepresentable {
/// Inline zoom shares the bounded loader and off-main ImageIO path used
/// by attachments. Never render the original HTML as a failure fallback:
/// its image could bypass the encoded-byte and decoded-pixel limits.
func presentImage(src: String?, fallbackHTML _: String) {
func presentImage(src: String?, alt: String? = nil, fallbackHTML _: String) {
guard !isInvalidated, !failed else { return }
imageTask?.cancel()
imageTask = nil
Expand Down Expand Up @@ -590,7 +591,7 @@ struct MarkdownWebView: UIViewRepresentable {
self.imageGeneration == requestGeneration else { return }
self.imageTask = nil
if let image {
ContentZoom.image(image)
ContentZoom.image(image, description: alt)
} else {
ContentZoom.html("<p>Couldn't open this image. It may be unavailable, unsupported, or too large.</p>")
}
Expand Down
21 changes: 21 additions & 0 deletions apps/ios/CovenCave/CovenCaveTests/ContentZoomTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,25 @@ final class ContentZoomTests: XCTestCase {
XCTAssertEqual(ContentZoom.restrictedHTMLBaseURL.scheme, "about")
XCTAssertFalse(ContentZoom.restrictedHTMLBaseURL.isFileURL)
}

func testZoomedImageIsNamedByItsDescription() {
XCTAssertEqual(ZoomTarget.imageAccessibilityLabel(description: "Quarterly revenue chart"),
"Quarterly revenue chart")
XCTAssertEqual(ZoomTarget.imageAccessibilityLabel(description: " Padded alt\n"), "Padded alt")
}

func testZoomedImageWithoutDescriptionStillHasAName() {
XCTAssertEqual(ZoomTarget.imageAccessibilityLabel(description: nil), "Enlarged image")
XCTAssertEqual(ZoomTarget.imageAccessibilityLabel(description: " \n "), "Enlarged image")
}

func testImageZoomEntryPointKeepsTheDescription() {
let posted = expectation(forNotification: .caveZoomContent, object: nil) { note in
guard let target = note.object as? ZoomTarget,
case .image(_, let description) = target.content else { return false }
return description == "Fixture diagram"
}
ContentZoom.image(UIImage(), description: "Fixture diagram")
wait(for: [posted], timeout: 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ final class MarkdownWebViewLifecycleTests: XCTestCase {
defer { coordinator.invalidate() }
let presented = expectation(description: "Inline zoom presents a bounded native image")
let token = NotificationCenter.default.addObserver(forName: .caveZoomContent, object: nil, queue: .main) { note in
guard let target = note.object as? ZoomTarget, case .image(let image) = target.content else {
guard let target = note.object as? ZoomTarget, case .image(let image, _) = target.content else {
XCTFail("Expected a native image")
presented.fulfill()
return
Expand All @@ -40,6 +40,34 @@ final class MarkdownWebViewLifecycleTests: XCTestCase {
await fulfillment(of: [presented], timeout: 5)
}

@MainActor
func testInlineImageZoomKeepsItsAltTextForVoiceOver() async throws {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let image = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8), format: format).image { context in
UIColor.black.setFill()
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
}
let source = "data:image/png;base64," + (try XCTUnwrap(image.pngData())).base64EncodedString()
let coordinator = MarkdownWebView.Coordinator()
defer { coordinator.invalidate() }
let presented = expectation(description: "Inline zoom carries the image's alt text")
let token = NotificationCenter.default.addObserver(forName: .caveZoomContent, object: nil, queue: .main) { note in
guard let target = note.object as? ZoomTarget, case .image(_, let description) = target.content else {
XCTFail("Expected a native image")
presented.fulfill()
return
}
XCTAssertEqual(description, "Quarterly revenue chart")
presented.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
coordinator.enqueueScriptBody([
"type": "enlarge", "kind": "image", "src": source, "alt": "Quarterly revenue chart",
])
await fulfillment(of: [presented], timeout: 5)
}

@MainActor
func testInlineImageFailureDoesNotRenderUnboundedFallback() async {
let coordinator = MarkdownWebView.Coordinator()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,29 @@ final class PerformanceBaselineUITests: XCTestCase {
}
}

/// VoiceOver names the enlarged image by its markdown alt text (#5314)
/// instead of landing on a bare, nameless image.
@MainActor
func testZoomedImageIsNamedByItsAltText() {
let app = XCUIApplication()
app.launchArguments = ["--performance-instrumentation", "--performance-fixture", "--image-zoom-fixture"]
app.launch()
defer { app.terminate() }
XCTAssertTrue(app.buttons["Open navigation"].waitForExistence(timeout: 60))
openDrawer(in: app)
let thread = app.buttons["Fixture chat 2"]
XCTAssertTrue(thread.waitForExistence(timeout: 10))
thread.tap()
let inline = app.webViews.images["Zoom fixture image A"].firstMatch
let reachable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == true AND hittable == true"), object: inline)
XCTAssertEqual(XCTWaiter.wait(for: [reachable], timeout: 60), .completed, "The fixture image must render in WebKit")
inline.tap()
XCTAssertTrue(app.buttons["Close"].waitForExistence(timeout: 15), "Zoom must present")
let zoomed = app.images["Zoomed image"]
XCTAssertTrue(zoomed.waitForExistence(timeout: 10), "The enlarged image must be in the accessibility tree")
XCTAssertEqual(zoomed.label, "Zoom fixture image A")
}

@MainActor
private func launchRecoveryFixture() -> XCUIApplication {
let app = XCUIApplication()
Expand Down
5 changes: 4 additions & 1 deletion apps/ios/markdown/entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,16 @@ document.addEventListener("click", (e) => {
const tag = target.tagName?.toLowerCase();
const kind = tag === "table" ? "table" : tag === "img" ? "image" : "diagram";
// For an <img>, also pass its src so native can decode it into a UIImage and
// present the smooth native zoom (pinch/pan/double-tap) instead of a WebView.
// present the smooth native zoom (pinch/pan/double-tap) instead of a WebView,
// and its alt text so VoiceOver can name the enlarged image.
const src = kind === "image" ? (target.getAttribute("src") || "") : "";
const alt = kind === "image" ? (target.getAttribute("alt") || "") : "";
window.webkit?.messageHandlers?.cave?.postMessage({
type: "enlarge",
kind,
html: target.outerHTML,
src,
alt,
});
});

Expand Down
21 changes: 21 additions & 0 deletions scripts/ios-accessible-controls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,27 @@ test("the ⌘1–3 destination shortcuts stay out of the accessibility tree", ()
);
});

test("the enlarged image is named by the alt text the bridge sends", () => {
// An inline image's alt text exists only in the WebView, so the enlarge
// message must carry it for the native zoom view to name the image (#5314).
// The UI test that proves the whole path compiles in CI but does not run.
const entry = readFileSync(join(root, "apps/ios/markdown/entry.mjs"), "utf8");
assert.ok(
/postMessage\(\{\s*type: "enlarge",[^}]*\balt\b/.test(entry),
"apps/ios/markdown/entry.mjs must send the tapped image's alt text with the enlarge message",
);

const zoom = readFileSync(join(appRoot, "Views/ContentZoom.swift"), "utf8");
assert.ok(
/\.accessibilityLabel\(ZoomTarget\.imageAccessibilityLabel\(description: description\)\)/.test(zoom),
"ZoomableImageView must name the image with ZoomTarget.imageAccessibilityLabel",
);
assert.ok(
/\.accessibilityIdentifier\("Zoomed image"\)/.test(zoom),
"PerformanceBaselineUITests finds the enlarged image by its \"Zoomed image\" identifier",
);
});

test("controls the session-switch UI tests drive keep their identifiers", () => {
// SessionSwitchUITests queries these by identifier. Both are Buttons with
// composed labels, which surface no queryable name on their own, so dropping
Expand Down
Loading