Skip to content

[Component Audit] Lightbox: screen readers hear the open viewer, and 0 resets the zoom - #10775

Merged
cixzhang merged 4 commits into
mainfrom
night-watch-component-audit/Lightbox/2026-11-09
Nov 9, 2026
Merged

cixzhang merged 4 commits into
mainfrom
night-watch-component-audit/Lightbox/2026-11-09

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A screen-reader user now hears the viewer while it is open:

  • Moving to another photo announces it with its position ("Mountain lake, 2 of 4"), and zooming announces "Zoomed in" or "Zoomed out". Before, the viewer sent these messages to the page behind its modal dialog, where a screen reader does not read them while the viewer is open. In real Chromium the messages now land in the viewer's own live region and no longer reach the page.
  • The 0 key returns a zoomed photo to its fitted size, as the viewer's keyboard contract promises. Before, it did nothing. Ctrl+0 and Cmd+0 still reset the browser's page zoom.

Nothing changes on screen: frames before and after match, except the frame taken after pressing 0, which now shows the fitted photo instead of the zoomed one (and the browser's own loading spinner in the empty video player, which moves between runs).

Builders get docs that match the component: hasZoom lists the 0 key, and the guidance no longer says the lightbox is only for images (it shows images and videos).

Audit result

  • Package/component: core/Lightbox (released in @astryxdesign/core under ./Lightbox)
  • Mode: Night Watch whole-component audit, rubric 1.16.6
  • Base: fe174dd1213 (rebased after the LayoutPanel audit landed; the change set is unchanged)
  • Exact head: ec3f47f3206e0af20dbfb26eeb18704b9b44f907
  • Component contract: existing Lightbox.spec.md (authority: current), unchanged. AR3 requires 0 to return to the fitted size, and AR4 with spec:AST-086 FR14 requires the position and zoom messages to be delivered inside the open viewer; the record lists both as implementation gaps (Current-state impact). This pull request closes both in code. No observational backfill was prepared.
  • Runtime change: the viewer's own messages go through the shared in-modal writer (announceInModal) with its own dialog, as other modal owners do, and 0 without Ctrl, Cmd, or Alt zooms out like -. No prop, default, target, or visual change.
Before After
Score 83.2, capped at C (3 open BLOCKs) 90.4, capped at C (1 open BLOCK)
Distinct defects 3 BLOCK, 10 FIX, 0 NIT 1 BLOCK, 9 FIX, 0 NIT

Sensitivity: fix the open BLOCK (Needs Review 1) and change nothing else → projected 92.0 / A.

Section W Before After State
§1 Accessibility 16 2.5 4.5 scored; BLOCKs closed: position and zoom messages were delivered outside the open modal viewer (AR4; spec:AST-086 FR14), and 0 did nothing (AR3). Open FIX: the zoom message does not name the zoom level (AR4; Needs Review 2). Modal dialog named by the active item, focus inside, Tab contained (FR4), Escape closes and focus returns to the opener; controls 32px; the audit's axe pass over the open viewer reports no violations (axe does not measure the caption over the translucent backdrop; see Needs Review 1)
§2 Theming 14 5 5 scored; one lightbox target on the dialog; controls delegate to Button's button target; colours, spacing, radius, and type from tokens; the zoomable controls sit on Lightbox's own on-light backing with on-dark icons (AR9); Media, Caption, and Counter have no target, as the record states
§3 Public API 14 5 5 scored; isOpen/onOpenChange, index/defaultIndex/onIndexChange, hasZoom, hasAutoPlay; rest and ref reach the dialog
§4 Behavior 12 3 3 scored; six open FIXes the record lists as implementation gaps: pinch to close (FR9), tap to hide the controls (FR10), the experimental thumbnail transition (FR12), the touch exit (FR16), the drag-to-close report timing (FR8), and the page settle's motion roles (FR5). Needs Review 4
§5a Design, objective 6 5 5 scored; tokens throughout; local stacking only; springs run through the motion modules (the roles gap is scored in §4)
§5b Design, rendered 4 3 3 scored from 18 frames before and 18 after; open BLOCK: caption text on the light-mode backdrop is under 4.5:1 (Needs Review 1)
§6 Testing 8 5 5 scored; extensive unit and browser coverage. The checks for 0 and for in-viewer delivery were missing downstream of the §1 defects; they now exist as unit cases (position and zoom routing, 0, Ctrl/Cmd+0) and in the keyboard browser spec that runs on every pull request
§7 Code health 8 4 4 scored; two open FIXes the record lists (FR14): the stage's own dblclick listener and the viewer's pointerdown press tracker sit outside the shared touch modules. The position message's Effect fires once per index the viewer actually shows, which a controlled index decides, so it is not handler work
§8 Docs 8 4.5 5 scored; FIX closed: guidance said the lightbox is "specialized for images", against its video support; hasZoom now lists 0
§9 i18n and RTL 5 5 5 scored; every string through the translator with ICU positions; arrow keys follow the page direction; RTL frames mirror the buttons
§10 Responsive and touch 5 5 5 scored; the viewer fills a 320px viewport with every control reachable; the touch-presentation gaps are scored in §4

§5b is scored before from frames of the fixture commit (component unchanged from base), so the open contrast BLOCK counts on both sides. §11 is not run for an existing core component.

Why the score moved: the two §1 BLOCKs close, and the docs FIX closes. The grade stays capped at C by the open contrast BLOCK, which needs an owner decision.

Findings fixed

ID Finding Authority Fix and proof
A6 / AR4 (BLOCK) Lightbox called useAnnounce above its own dialog's announcement provider, so its position and zoom messages went to the page's shared channel, which a screen reader does not read while the modal viewer is open component:Lightbox AR4; spec:AST-086 FR14 The viewer writes its messages into its own dialog through the shared announceInModal. Red, then green: the new unit case fails at the fixture commit (no message in the dialog) and passes after; in real Chromium the audit receipts at the fixture commit find "Mountain lake, 2 of 4" and "Zoomed in, use arrow keys to pan" in the page's region and none in the viewer's, and after the fix find both in the viewer's region and neither on the page
A4 / AR3 (BLOCK) 0 did not return a zoomed photo to its fitted size component:Lightbox AR3 0 without a modifier zooms out like -; Ctrl/Cmd+0 stays the browser's page-zoom reset. Red, then green: the new unit case and the browser receipts (zoom still pressed after 0) fail at the fixture commit and pass after; the keyboard browser spec now checks +, the in-viewer message, and 0
X3 / X21 (FIX) A "don't" said the lightbox is specialized for images, against its documented video support FR1, AV1 English, Chinese, and dense guidance
X5b (downstream of AR3) hasZoom docs and JSDoc did not list 0 AR3 English, Chinese, and dense docs and JSDoc
Audit routing (procedure) Every Lightbox story opens the viewer from a button, so the axe pass never reached an open viewer rubric procedure New Open Viewer story, open on load; .github/a11y-audit-axe.json points the audit's axe pass at it and waits for dialog[open]

Needs Review

  1. Caption contrast on the light-mode backdrop (BLOCK, pre-existing, not changed here). The caption uses --color-on-dark directly on the --color-overlay backdrop. Over a white page in light mode, the measured composite is 4.00:1 in Neutral and 3.33:1 in Butter for 17px caption text, under WCAG 2.2 1.4.3's 4.5:1 (D7); the core default overlay with no theme computes to 2.63:1. Dark mode measures 20.5:1. A gallery without hasZoom puts its Counter on the same pair (not measured here). The remedy is a visual decision for the owner: for example a darker viewer backdrop, or the Counter's backing behind the caption.
  2. Zoom level in the zoom message (FIX). AR4 asks every zoom change, by any input, to announce the new level once it settles. The viewer announces only "Zoomed in" or "Zoomed out" when it crosses the fitted size. This needs a new catalog message with a locale-formatted level and an announcement when a pinch or wheel zoom settles.
  3. Record follow-up. Lightbox.spec.md Current-state impact still lists the 0 key and the delivery outside the viewer, which this pull request fixes, and Tab containment (FR4), which useFocusRegionContainment already provides and the keyboard browser spec checks. A later owner-approved record change can remove them.
  4. Touch-presentation gaps (FIX ×6, unchanged). FR5, FR8, FR9, FR10, FR12, and FR16 are feature work the record already lists; none is an audit fix.
  5. Shared touch modules (FIX ×2, unchanged). FR14's dblclick listener and press tracker, as the record lists.
  6. Keys during a drag (observation, pre-existing). -, and now 0, pressed while a drag pans a zoomed photo zoom out, and the drag's next move can restore the zoomed scale. Not scored here.
  7. Ctrl/Cmd with + or - (observation, pre-existing). While a zoomable photo is open these zoom the photo instead of the page. No current record decides this; Ctrl/Cmd+0 is left to the browser.
  8. Overlapping open pull request. feat(Lightbox): a gallery's caption sits in a two-line band #9810 changes how a gallery's caption sits; it does not touch the announcer or the keys.

Evidence

  • Fixture: the new Open Viewer story for the axe pass. The audit spec drives the existing Gallery with Zoom, Default, and Video stories with real keys, replaces the remote photos with one local picture so frames repeat exactly, and aborts the remote video.
  • Red before: the audit spec, run locally in Chromium against a Storybook built from the fixture commit 5d39f03 (component unchanged from base), fails only the five Gallery with Zoom cases (Neutral light and dark, Butter, RTL, 320px), each on the same four checks (spec as of the fixture run): the position message is on the page and not in the viewer, the zoom message is not in the viewer, and the zoom stays pressed after 0. The Default and Video cases pass.
  • After, exact head: CI run 42631047655 (attempt 2, every job; the push run deferred heavy jobs by design) with ci:run-a11y and ci:run-rtl: all 8 audit cases pass in artifact pr-a11y-evidence-ec3f47f-1 (sha256:7192aebf7720ae6adc67a613f8790620ac7a24e259cb3c9cc74f7b683096ccfa), each receipt bound to build ec3f47f; the audit's axe pass over the open viewer reports no violations; pr-rtl, Stable visual regression, and the keyboard browser spec green. Locally all 8 cases pass with receipts bound to the head. Against the red-before build, the frames match except the five fitted frames (now the fitted photo, as 0 intends), the Video frame, whose only changed pixels are the browser's own loading spinner over the empty player, and the first frame in Butter and at 320px, where the photo's edge lands one CSS pixel apart; every geometry and state check passes in all of them.
    • every case: one modal dialog carrying only the astryx-lightbox target, in the page direction, named by the active item, with focus inside and a Close button; Escape closes it and focus returns to the opener;
    • Gallery with Zoom: counter "1 / 4"; Previous disabled at the start; Next at the inline end (the right in LTR, the left in RTL); Close, Previous, and Next at 32 × 32px; paging announces "Mountain lake, 2 of 4" inside the viewer and nothing on the page; + zooms, keeps focus inside, and announces inside the viewer and not on the page; 0 returns to the fitted size;
    • Default: no gallery buttons or counter, caption shown, no zoom target; Video: native controls, no zoom target.
  • Visual acceptance: N/A. No rendered output changes except the state 0 now reaches, shown in the fitted frames.

Closed evidence inventory

Surface Source Tests Consumer docs Rendered evidence Authority Result
Exports Lightbox, LightboxProps, useLightbox Lightbox/index.ts export checks in CI Lightbox.doc.mjs, useLightbox.doc.mjs — architecture:public-component-api reachable
Viewer, lightbox target, Media, Close Lightbox.tsx render suites anatomy every case FR1, FR2, FR3 pass
Gallery: Previous, Next, Counter, boundaries Lightbox.tsx gallery and index suites media, index Gallery with Zoom cases FR1, FR17, AV2 pass
Caption Lightbox.tsx caption suite media.caption Default, gallery first frame FR1, AV3 pass; contrast open (Needs Review 1)
Video Lightbox.tsx video suites media.type Video case FR7, FR18, AV1 pass
Zoom: keys, pointer, pan Lightbox.tsx, useZoomCore.ts, useZoomStage.ts zoom suites; 0 case (new); zoom browser specs hasZoom zoomed and fitted frames FR6, FR11, AR2, AR3 pass after this fix
Announcements Lightbox.tsx announcement suite; in-viewer case (new) — receipts in every gallery case AR4; spec:AST-086 FR14 pass after this fix; level open (Needs Review 2)
Focus, Tab, Escape, focus return Lightbox.tsx keyboard browser spec; unmount suite — every case FR4, AR3; family:overlay-dismissal pass
Touch presentation Lightbox.tsx, touch modules touch interaction specs hasZoom — FR5–FR10, FR12, FR16 partial (Needs Review 4)
Controls over a zoomed photo Lightbox.tsx AR9 contrast test — zoomed frames AR9 pass
RTL Lightbox.tsx keyboard browser spec (LTR and RTL) — RTL case AR3; design:bidirectionality pass
Narrow viewport Lightbox.tsx viewport browser spec — 320px case — pass
FR10 eligibility report
{
  "schema": "astryx.audit-eligibility/v1",
  "component": "Lightbox",
  "package": "core",
  "mode": "night-watch",
  "rubricVersion": "1.16.6",
  "repository": {
    "base": "fe174dd1213",
    "head": "ec3f47f3206e0af20dbfb26eeb18704b9b44f907"
  },
  "contract": {
    "id": "component:Lightbox",
    "authority": "current",
    "changed": false
  },
  "inventory": {
    "closed": true,
    "rows": 12,
    "open": ["touch presentation partial"]
  },
  "unresolved": {
    "objective": [
      "zoom message does not name the zoom level (AR4)"
    ],
    "manual": [
      "D7: caption text on the light-mode backdrop under 4.5:1; remedy is a visual decision",
      "record follow-up: Current-state impact lists gaps now closed"
    ],
    "ownedElsewhere": [
      "touch presentation FR5, FR8, FR9, FR10, FR12, FR16 under spec:AST-094",
      "FR14 listeners outside the shared touch modules"
    ]
  },
  "remediations": [
    {"id": "A6/AR4", "kind": "bug-fix", "evidence": "in-viewer unit case and Chromium receipts red at the fixture commit, green at head"},
    {"id": "A4/AR3", "kind": "bug-fix", "evidence": "0-key unit case and Chromium receipts red at the fixture commit, green at head"},
    {"id": "X3/X21", "kind": "doc-drift", "evidence": "guidance names images and videos; hasZoom lists 0"}
  ],
  "approvals": {
    "specOwner": "success (no record change)"
  },
  "checks": "green at ec3f47f (CI run 42631047655, attempt 2)",
  "eligible": false,
  "reasons": [
    "spec:AST-029 is phase accepted; FR11 activation has not shipped",
    "an open BLOCK remains (D7)"
  ]
}

@vercel

vercel Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 9, 2026 2:11pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 9, 2026
@astracat-bot

astracat-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 9, 2026, 2:26 PM UTC

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · ec3f47f

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Lightbox · Storybook 122.6 kB (+66 B) · 10.6 kB CSS (0) 3,412 (+35) ✓ · ✓ · ✓
lab/CommentThread 344.5 kB (+40 B) · 2.4 kB CSS (0) — — · — · —
lab/Comments 351.3 kB (+35 B) · 2.5 kB CSS (0) — — · — · —

Package size: @astryxdesign/core 7.21 MB packed (+388 B); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 29 rows, core 36 rows, theme-butter 1 row, theme-y2k 1 row; run pnpm api:report.

Touched components checked; add ci:run-visual to run the full suite.


Generated by PR Enrichment workflow | View full report

@cixzhang cixzhang added ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) ci:run-rtl Run the RTL suite on this PR; write access, next push (AST-030 FR12) labels Nov 9, 2026

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. Both fixes work: the position and zoom messages now land in the open viewer's live region instead of the page's, and 0 returns a zoomed photo to its fitted size. The new tests fail on the base and pass on this head. One thing to change first:

  • ViewerAnnouncer and its ref wrapper (Lightbox.tsx:479-482, :617-620) repeat the announcer handoff PowerSearchBottomSheet.tsx already has (SheetAnnouncer, :589-604, :993-999), so please call the shared announceInModal(dialogRef.current, message) from hooks/useAnnounce.ts, as ToastViewport and useBusyAnnouncement do, or move one shared announcer into hooks/ and use it from both components.

[Automated review]

Comment thread packages/core/src/Lightbox/Lightbox.tsx Resolved
@cixzhang
cixzhang force-pushed the night-watch-component-audit/Lightbox/2026-11-09 branch from 3e0666b to b565892 Compare November 9, 2026 13:03
@cixzhang
cixzhang marked this pull request as ready for review November 9, 2026 13:19

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The fix itself works: the position and zoom messages now land in the open viewer's live region instead of the page's, and 0 returns a zoomed photo to its fitted size; the new tests for both fixes fail on the base and pass on this head. One change is still needed:

  • ViewerAnnouncer and its ref wrapper (Lightbox.tsx:479-482, :617-620) repeat the announcer handoff PowerSearchBottomSheet.tsx already has (SheetAnnouncer, :589-604, :993-999), so please call the shared announceInModal(dialogRef.current, message) from hooks/useAnnounce.ts, as ToastViewport and useBusyAnnouncement do, or move one shared announcer into hooks/ and use it from both components.

[Automated review]

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI: on this push the required test, build and docsite-test checks are deferred, and they run in the pre-merge re-run.

Both fixes now do what the Lightbox contract asks: paging and zoom messages land in the open viewer's own live region instead of the page behind the modal, and 0 returns a zoomed photo to its fitted size. The new unit tests for both fail before the change and pass after it. Routing the messages through the shared announceInModal resolves the earlier request to avoid a second private announcer.

[Automated review]

@cixzhang
cixzhang merged commit 117bc6e into main Nov 9, 2026
124 of 127 checks passed

This branch was successfully deployed

1 active deployment
Preview — ec3f47f3 Deployed Nov 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) ci:run-rtl Run the RTL suite on this PR; write access, next push (AST-030 FR12) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant