A full-viewport, one-photo-at-a-time lightbox with prev/next and a vote button per slide — the sibling photo_grid does not cover: reviewing options ONE AT A TIME with full attention, immersively, rather than scanning a grid. Neither replaces the other; pick per use case, or offer both. Renders closed as a compact trigger card (first photo + label); clicking it opens the full takeover. The takeover itself is a pure-CSS checkbox toggle — the SAME `#checkbox:checked ~ .overlay{display:...}` pattern the `css_modal` atom already uses for its own position:fixed;inset:0 overlay, not a new mechanism. Paging within the open overlay is the SAME pure-CSS radio-input trick the existing `carousel` atom already uses (sibling :checked selectors drive the slide transform) — no JS-tracked index to go stale, and critically, nothing for a live data refresh to disturb: `carousel` never had a live-update wire to worry about, but this atom does, and the radio's own browser-owned :checked state surviving a vote untouched is what stops "vote on photo 12, get silently bounced back to photo 1" from ever being possible in the first place — not a bug that was fixed, a failure mode the design does not have. Same reasoning protects open/closed state: the checkbox is the browser's own state too, so a live update cannot close the overlay out from under you either. That is also why this atom's live-update path (_a2uiUpdatePhotoStepper) is NOT a rebuild like photo_grid's own _a2uiUpdatePhotoGrid — it patches each slide's badge/vote-button/voter-line IN PLACE, matched by candidate id (not array position), and touches nothing else. Matching by id, specifically, because the typical consumer of this atom (a live vote board) re-sorts its candidate list by vote count on every read while voting is open — so a candidate's position in the incoming array can move the instant ANY vote lands, including the one just cast on the slide currently open; patching by position against a reordered array would silently swap a different candidate's photo/name into the slide the radio's :checked state still has open. A consequence worth knowing before using this atom: because the update path patches EXISTING slide elements rather than creating them, the caller should populate the initial `images` prop with real data at render time (server-side), not rely purely on the `rows` wire to populate an empty first paint — an update arriving before any slide exists to patch has nothing to act on. Reuses photo_grid's click contract exactly for the vote button (same data-row-json attribute, same generalised [data-row-json] binder in A2UIState.html) — the third consumer of a mechanism built once for data_table's own onRowClick.
| Surface | Note |
|---|---|
| web | Renders as a static, non-paging stack of slides; no Python renderer exists for this atom yet, and CSS-only paging still needs the radio inputs' sibling-selector behaviour a static article render does not exercise interactively either way. |
| Field | Type | |
|---|---|---|
| images | array (required). Objects of {id, url, alt?, name?, price_display?, badge?, active?, starred_by_display?, source_url?}. Same shape family as photo_grid (id/url/badge/active carry the identical meaning), plus fields specific to a one-at-a-time review: `name`/`price_display` render as the slide's caption title/subtitle, `starred_by_display` is a plain pre-joined string (e.g. "Curtis, Marianne") shown under the caption, `source_url` renders a plain "Open source listing" link in the caption when present — the atom does not aggregate voter names or validate the link itself, the caller derives both server- side, same reasoning photo_grid gives for `badge` and `active` being caller-derived rather than computed by the atom. | optional |
| accent | string (optional, hex, default #0f766e). Vote-button and active- badge colour. | optional |
| trigger_label | string (optional, default "Browse full screen"). Label shown on the closed trigger card, next to the photo count. | optional |
{
"type": "photo_stepper"
}
{
"identifier": "urn:air:a2uicatalog.ai:atom:photo_stepper",
"displayName": "Photo Stepper",
"type": "application/vnd.a2ui.atom+json",
"url": "https://a2uicatalog.ai/atoms/photo_stepper",
"capabilities": [
"google-apps-script-web",
"mcp-apps"
],
"description": "A full-viewport, one-photo-at-a-time lightbox with prev/next and a vote button per slide \u2014 the sibling photo_grid does not cover: reviewing options ONE AT A TIME with full attention, immersively, rather than scanning a grid. Neither replaces the other; pick per use case, or offer both. Renders closed as a compact trigger card (first photo + label); clicking it opens the full takeover.\nThe takeover itself is a pure-CSS checkbox toggle \u2014 the SAME `#checkbox:checked ~ .overlay{display:...}` pattern the `css_modal` atom already uses for its own position:fixed;inset:0 overlay, not a new mechanism. Paging within the open overlay is the SAME pure-CSS radio-input trick the existing `carousel` atom already uses (sibling :checked selectors drive the slide transform) \u2014 no JS-tracked index to go stale, and critically, nothing for a live data refresh to disturb: `carousel` never had a live-update wire to worry about, but this atom does, and the radio's own browser-owned :checked state surviving a vote untouched is what stops \"vote on photo 12, get silently bounced back to photo 1\" from ever being possible in the first place \u2014 not a bug that was fixed, a failure mode the design does not have. Same reasoning protects open/closed state: the checkbox is the browser's own state too, so a live update cannot close the overlay out from under you either.\nThat is also why this atom's live-update path (_a2uiUpdatePhotoStepper) is NOT a rebuild like photo_grid's own _a2uiUpdatePhotoGrid \u2014 it patches each slide's badge/vote-button/voter-line IN PLACE, matched by candidate id (not array position), and touches nothing else. Matching by id, specifically, because the typical consumer of this atom (a live vote board) re-sorts its candidate list by vote count on every read while voting is open \u2014 so a candidate's position in the incoming array can move the instant ANY vote lands, including the one just cast on the slide currently open; patching by position against a reordered array would silently swap a different candidate's photo/name into the slide the radio's :checked state still has open. A consequence worth knowing before using this atom: because the update path patches EXISTING slide elements rather than creating them, the caller should populate the initial `images` prop with real data at render time (server-side), not rely purely on the `rows` wire to populate an empty first paint \u2014 an update arriving before any slide exists to patch has nothing to act on.\nReuses photo_grid's click contract exactly for the vote button (same data-row-json attribute, same generalised [data-row-json] binder in A2UIState.html) \u2014 the third consumer of a mechanism built once for data_table's own onRowClick.",
"representativeQueries": [
"show a full-viewport one-at-a-time photo lightbox \u2014 opens from a compact trigger card, carousel-style CSS paging, per-slide vote button reusing photo_grid''s click contract, live badge/voter updates patched in place by candidate id so voting never resets position or swaps photos",
"a full-viewport, one-photo-at-a-time lightbox with prev/next and a vote button per slide \u2014 the sibli",
"render a photo stepper"
]
}
The renderer is an open-source Google Apps Script web app. Deploy your own instance in 4 commands — you own the URL, no dependency on the demo endpoint.
git clone https://github.com/a2uicatalog/a2ui
cd a2ui/apps-script-surface/gas-wired-renderer
clasp push && clasp deploy
Full deploy guide →