Skip to content

Replace the magnify-and-new-tab figure with an inline lightbox - #3313

Open
enf0rc3 wants to merge 5 commits into
mainfrom
willlaugesen/nes-284-component-lightbox
Open

Replace the magnify-and-new-tab figure with an inline lightbox#3313
enf0rc3 wants to merge 5 commits into
mainfrom
willlaugesen/nes-284-component-lightbox

Conversation

@enf0rc3

@enf0rc3 enf0rc3 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes NES-284.

Clicking the magnifier called window.open, dropping the reader on a bare image file. Figure images now click to zoom in place.

Modelled on Linear's docs, which the designer referenced, and matching the Figma frames under Lightbox (1848:17946, 1848:18049), plus a close button.

Two files

src/scripts/modules/figures.js | 194 +++++++++++++++++++++++++++++-----
src/styles/main.css            | 156 +++++++++++-------------------

No markup changes — the emitted HTML is byte-identical to main. Nothing about how images are served changes: no new files, no image-pipeline changes, public/ untouched.

No new dependency

I started on PhotoSwipe, then read Linear's shipped CSS — their whole lightbox is 7 rules and no vendor library.

I did go back and build the PhotoSwipe version properly once review raised scroll-locking and the close button — it's on willlaugesen/nes-284-photoswipe-spike and it works. We're not taking it yet, because this version animates left/top/width/height rather than transform: scale(), and that difference matters: a transform scales its whole subtree, so the 8px radius drew at 43px mid-zoom and the 1px border at 5px, both snapping back at the end. Avoiding that in PhotoSwipe needs counter-scaling the radius per frame. Here it's free.

The spike stays available if we later want pinch-zoom on touch, which this doesn't do.

How it works

figures.js reads the image's rect, clones it into a fixed frame at that rect, then animates to a fit-viewport rect over an opaque var(--color-base-primary) backdrop — already theme-aware, so dark mode is free. prefers-reduced-motion skips the animation.

The image carries the interaction directly. Linear uses a wrapping <div aria-label="Zoom image">, which isn't focusable and gives keyboard users nothing; img.tabIndex = 0 plus an Enter/Space handler is what a wrapper would otherwise provide, without touching the markup.

main.css loses the .image frame, the .image__img shadow, the legacy figure:has(p > img) p duplicate (which carried a TODO to remove it), and all six .magnify-* rules. What replaces it is one figure img rule covering both the markdown and <Image> paths — the duplication that TODO was waiting on is gone without migrating 1,452 blocks to a component.

Review feedback, all addressed

fix
page scrolled behind the lightbox overflow: hidden plus permanent scrollbar-gutter: stable, so hiding the scrollbar reflows nothing
no visible way out close button top right, takes focus on open, and is removed the moment closing starts
low-res image blown up the clone names the widest srcset file outright, and hovering prefetches it
white images have no edge border: var(--borderWidth1) solid var(--colorBorderPrimary), inline and zoomed

Verified

Full build (2,673 pages, clean), then driven with Playwright against the built site, including under 300KB/s with 300ms latency:

resting cursor zoom-in
overlay rgb(255,255,255) light, rgb(17,26,35) dark
lightbox cursor zoom-out
border #DEE1E6 light, #2E475D dark, no overflow (799.97px in an 800px column)
scroll while open moved 0px; body width 1425px before, during and after
image served 1432px file behind a 1340px box, correct from the first frame
close button, click and Escape; button gone within 30ms of closing
keyboard Enter opens, focus lands on close, returns to the image after
both paths markdown :::figure and the <Image> MDX component
console errors none

Worth knowing

Mobile pinch-zoom. The Figma frames are 1920×1080 only. This fits to viewport and stops there, so a dense screenshot is still hard to read on a phone. Linear has the same limitation. The PhotoSwipe spike solves it without changing this markup.

naturalWidth lies on srcset images. It returns a density-adjusted size — the badges thumbnail reports 842 where the file is genuinely 1432. It nearly read as an upscaling bug. The pages with srcset also carry a real width attribute, which takes priority in the upscale cap, so behaviour is correct.

Upscale cap. A narrow screenshot opens at its true pixel width rather than stretching, which keeps it sharp.

🤖 Generated with Claude Code

@enf0rc3
enf0rc3 marked this pull request as ready for review August 6, 2026 23:14
@team-marketing-branch-protections

Copy link
Copy Markdown

Pull request environment is available at https://stoctodocspr3313.z22.web.core.windows.net.

You can view the ephemeral environment status in Octopus Deploy.

This environment will be automatically deprovisioned when the pull request is closed, or after 7 days of inactivity.

@ellen-octopus

Copy link
Copy Markdown
Contributor

So exciting! This looks fantastic 🤩

@enf0rc3 I've realised we probably need a border because some images are white 😅
Could you please add border: var(--borderWidth1) solid var(--colorBorderPrimary); to images?

@enf0rc3
enf0rc3 force-pushed the willlaugesen/nes-284-component-lightbox branch 2 times, most recently from 5fe96fc to 4fde6b1 Compare August 7, 2026 00:59
Clicking the magnifier called window.open, which dropped the reader on a
bare image file and lost the page they were reading. The frame around each
figure - 2rem of padding, a navy border and a grey panel - was duplicated in
three places, because markdown figures and the Image component were styled
separately.

The image now carries the interaction itself: click it, and it animates from
where it sits to a fit-viewport rect over an opaque backdrop, matching the
design in NES-284. Click or Escape closes it. tabindex is what a wrapping
button would otherwise have given for free, and the zoom would be mouse-only
without it.

Modelled on Linear's docs, which the designer referenced. Their whole
lightbox is seven CSS rules and no library, and PhotoSwipe would have been
50KB of which most gets switched off again: the design has no close button,
no counter, no arrows and no zoom controls.

No markup changes, so the emitted HTML is unchanged and nothing about how
images are served moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 force-pushed the willlaugesen/nes-284-component-lightbox branch from 4fde6b1 to 8dfe780 Compare August 7, 2026 01:10
@borland

borland commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Looking good!

Some UI feedback

  • When the image is zoomed, it always fills the screen, even if the original image is small. This looks kind of weird; should we put a max zoom level in or something?

    • Alternatively, maybe we shouldn't offer the lightbox for images below a certain size. The project badges page linked below is an example
  • Despite the fact that the cursor swaps to a "minus" magnifying class, and the escape key works to close the lightbox, I found myself hunting for some sort of "Close" button and was a bit lost without one.

  • The lightbox is just taking the existing inline image and moving it around. For responsive images, like on the project badges page (https://stoctodocspr3313.z22.web.core.windows.net/docs/projects/badges) we shouldn't just take the low-res version and blow it up, we should swap out for the full-size version.

  • When the image is zoomed, you can scroll the page underneath it with the scroll wheel. This is a common bug with lightboxes.

Taking the image-swapping, close and scroll-bugs into consideration, I feel like photoswipe is probably worthwhile.

enf0rc3 and others added 2 commits August 7, 2026 13:37
Light screenshots have no visible edge against the page, and the lightbox
makes that worse by putting them on an opaque background of the same colour.

Applied to the zoomed image as well, for the same reason.

Only html is border-box, so the images also need it explicitly. Without it
the border sits outside the max-width: 100% they inherit from the global img
rule and overflows the column by 2px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things reviewers hit on the first version.

The page scrolled behind the lightbox. Locking overflow on the document
normally reflows everything by the width of the scrollbar, so the gutter is
now reserved permanently and hiding it costs nothing.

There was no way out except the cursor or Escape, and both are easy to miss.
A close button sits in the top right and takes focus when the lightbox
opens, which also gives Tab somewhere to land.

The zoom took whichever candidate the browser had picked for a column and
stretched it. The clone now names the widest file in the srcset outright,
and hovering an image fetches it, so by the time a click lands it is already
decoded. On the badges page that is a 1432px file behind a 1340px box
instead of a 1000px one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3

enf0rc3 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the border, did a spike to implement the library but required way more customisation, was easier just to do the fixes recommended.

enf0rc3 and others added 2 commits August 7, 2026 14:59
It lived until the element was removed at the end of the animation, so it
hung in the corner for the whole 400ms shrink. Reads as the lightbox
ignoring the click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@borland

borland commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@enf0rc3 I had a play with the latest version.

The "X" button is cool, and the under-scrolling bug is fixed, and the subtle border looks nice

The image swapping doesn't seem to be there though. I went to the main page on /docs. The responsive image for the dashboard gets loaded into the page via docs/i/2000/getting-started/dashboard.webp, but when I click the lightbox I expect it to get swapped out for the original image at docs/i/x/getting-started/dashboard.png, but it just takes the 2000 webp and stretches it

// Fetching the full size on the way to the click means the zoom does not
// spend its first frames showing the column-sized version stretched
const warm = () => {
const full = largest(img);

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.

This is the wrong way to go about it.

Our responsive image thing renders image tags like this:

<img src="/docs/i/x/getting-started/dashboard.png" alt="Octopus Dashboard" srcset="/docs/i/600/getting-started/dashboard.webp 600w, /docs/i/1000/getting-started/dashboard.webp 1000w, /docs/i/2000/getting-started/dashboard.webp 1500w" sizes="(min-width: 1680px) 1000px, (min-width: 940px) calc(71.81vw - 192px), calc(100vw - 32px)" class="resp-img" width="1500" height="736" style="--shown: 1;" tabindex="0">

The original/largest image is just in the src attribute. We should ignore the srcset when looking to swap in a full-sized replacement

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants