Skip to content

Open every picture and file in one Slack-style viewer, with rotate, zoom, Previous and Next, and Download - #881

Merged
lavaman131 merged 2 commits into
mainfrom
image-viewer-slack
Nov 12, 2026
Merged

lavaman131 merged 2 commits into
mainfrom
image-viewer-slack

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

What the maintainer asked for

the image click view should look something like this [Slack's viewer], but remove everything to the right of the download button on the bottom right hand corner

And, while this was in progress:

The gallery for multiple images or documents in one message should have 'Next' and 'Previous' arrows.

What was there

Two viewers did the same job:

  • Chat opened assistant-ui's copied gallery <Dialog>, on flat ink. It had the sender and the time at the top, Copy, Download and Close at the top end, and the arrows at the sides. It had no zoom and no rotate.
  • Document images and decision-card visuals opened the editor's own lightbox, also on flat ink. Zoom and Close were at the top end. A footer held the arrows, the alt text and the count. Zoom was a 2× toggle.

Pressing a file sent with a message downloaded it at once. A message's files could not be stepped through.

Before: Chat Before: document image
Chat's viewer before The document lightbox before

What changed

Every picture now opens in one viewer: Chat images, a message's files, document images and decision-card visuals. It is the editor's ImageLightbox, rebuilt in place (packages/editor/src/widgets/image-lightbox.tsx), with its own stylesheet. It loads only when a picture opens.

The layout follows the reference:

  • Ground. The viewer fills the window. Behind the picture is the picture itself, blurred 64px, dimmed to 55%, with 40% of --color-gray-900 over it.
  • Top left. The sender's face and handle, exactly as written (chopin, with its mark, for a picture the Planner made). Under them, a muted line: 3 hours ago in Weekly review – chart.png. For a thread's messages it reads in a thread in Weekly review. A document's picture has no sender, so the header is the document's title, then the picture's name.
  • Top right. Close.
  • Middle. The picture, centred and fitted with a margin, never enlarged past its own pixels.
  • Bottom left. Rotate, then Zoom out, the zoom slider and Zoom in.
  • Bottom right. Download, and nothing to its right. Slack's +ALT, side panel, pop-out and ⋯ are left out.
Beside the reference layout (a schematic, not the screenshot) After: Chat
The reference layout beside the new viewer The new viewer
After: document image After: phone
A document image The viewer on a phone
Zoomed Rotated Tooltip
Zoomed in Turned a quarter Zoom in says its name
Over a white picture Over a dark picture
Over a white picture Over a dark picture

How it works

  • Several pictures and files. A message's pictures and the files sent with it share one viewer, in the order they are drawn (pictures first, then files). Round Previous and Next buttons sit halfway down the sides, and ArrowLeft and ArrowRight step too. At either end the arrow is not drawn, and it stays named and disabled. The position shows at the foot as 2 of 5, as a status.
  • Files. Pressing a file card opens the viewer on it. A text file (every file Chat accepts is stored as text) shows as its text on a page-coloured sheet. Any other file, or one that cannot be read, shows as a card with its type's icon, name, type and size. Rotate and zoom are not drawn for a file. Download stays where it is. The card's own hover toolbar still downloads the file directly.
  • Zoom. Use the slider (logarithmic, from fitted to 8× fitted), − and +, the + = - keys, and 0 to fit. The wheel and a trackpad pinch zoom about the pointer, and so does a two-finger pinch on a phone. A zoomed picture pans under a drag, only as far as its edges. A double-click switches between fitted and the picture's own pixels.
  • Rotate. Turns the picture a quarter clockwise each press, and fits it again.
  • Download. Reads the original as bytes and saves it under its name. That works for a signed-in upload, a blob or data address, and a same-origin path. Chat saves the stored file, never the 1024px copy it draws, under the same name the message's own Download uses. If a picture's host on another site refuses the read, Download opens it in a new tab without opener or referrer, and says so to a screen reader.
  • Keyboard and focus. Focus starts on the viewer, is trapped, and wraps. Escape, Close or pressing the ground closes the viewer, and focus returns to what opened it. Arrow keys on the slider move the slider.
  • Names and tooltips. Every control has an accessible name and shows the shared tooltip. The tooltip now draws inside an open modal <dialog> (icon-tooltip.tsx). Before this, it was drawn under the dialog's top layer, where nobody could see it.
  • Phone. The viewer fills the screen, and safe areas pad every edge. The picture takes the full width. Every control is 44px on a coarse pointer.
  • Motion. The viewer fades in over --duration-base (200ms) while the picture settles from 96%. It fades out over --duration-fast (120ms). Zoom and rotate ease over --duration-base. A drag, a pinch or the wheel follow the hand with no easing. Under reduced motion there is no animation and no transition.
  • Broken images. An image that cannot load keeps its sentence in the middle, and Download is disabled.

Smaller changes

  • The document's title reaches the viewer through a small ImagePlace context (@chopin/editor/image-place). It is provided where decision contexts are, and the thread panel turns it into "a thread in …".
  • The viewer's CSS leaves the editor's stylesheet for its own lazy file. The old Chat viewer rules in chat/image.css are gone.
  • A RotateIcon joins Chopin's line icons. The design contract's exceptions for line.tsx and question-markdown.tsx are renewed: a new hash, and one new case for the icon's prop forwarding.
  • DESIGN.md: Chat image viewer and Image lightbox now describe the one viewer (Image viewer), and Chat files says that pressing a card opens it.

Contrast (measured)

I measured contrast from screenshots with a script, ./contrast.md. For each element it takes the worst ground pixel around it and compares it with the element's computed colour.

Element Over a light chart Over a white picture Over a dark picture Phone
Name (white) 7.36:1 7.10:1 19.9:1 7.44:1
Muted line (--color-gray-300) 5.21:1 5.04:1 14.37:1 5.23:1
Count 2 of 5 7.95:1 5.12:1 14.18:1 7.17:1
Close icon 7.32:1 7.10:1 19.9:1 8.05:1
Previous/Next icon (on their own dark circle) 14.82:1 14.35:1 19.59:1 5.57:1 (over the picture)
Rotate, Zoom in, slider thumb 6.61:1 5.24:1 14.68:1 7.40:1
Download icon 8.02:1 7.10:1 19.65:1 10.85:1

The focus ring is a 2px white outline, so it has the icons' contrast. A disabled control (Zoom out when fitted) is exempt. On a phone, Previous and Next sit over the picture itself. With the controls' 12% white fill they measured 1.03:1 over a white picture, so they now carry a 62% dark circle of their own.

Impeccable passes

  • Context: I ran impeccable context on the lightbox. The incumbent system is the authority; this is an Operate surface. As a subagent, I worked from the brief instead of an interview.
  • Critique: Two viewers with two looks and two keyboards. Flat ink unrelated to the picture. Download crowded at the top. No rotate or slider. Files could not be stepped through.
  • Layout and polish:
    • Slack's foot groups: rotate alone, then − slider +. Download alone at the end.
    • 36px controls with 18px glyphs, 44px on touch.
    • Icons take the viewer's ink, not the page's quiet icon grey.
    • A text sheet sized to its content.
    • The arrows' own dark ground.
  • Motion: One authored moment, the fade with the picture settling. The dialog itself is never transformed, so the fixed tooltip can live inside it.
  • Audit: I checked keyboard order and the focus trap, names and tooltips, contrast (above), touch targets, safe areas and reduced motion. The bundle is unchanged.
  • Detector: bun run ci (Impeccable check-design) reports no new findings.

Full notes: critique.md.

Checks

  • bun run types: passes.
  • bun run ci: passes. dprint, oxlint, tokens, the design contract, the design record, and Impeccable report no new findings.
  • bun test packages/editor/src/widgets packages/icons apps/web/src/chat apps/web/src/icon-tooltip.test.ts: 1103 pass. The lightbox state tests cover fit, rotation, slider scale, steps, double-click, pan limits, zoom about a point, pinch, relative time, the header line, file names and download names.
  • Browser specs, after rebasing onto aaa1d6c (--workers=2): chat-media, chat-images, chat-images-gallery, chat-attachments, images and decision-option-previews: 95 passed. The harness spec "images the Planner makes reach Chat" also passed.
  • The new and changed viewer tests fail on main (38716a9) when run with this branch's specs: 10 failed. They are chat-media's viewer, names and tooltips, zoom and rotate, download, mixed images and files, 20 MB photo and phone tests, and images' lightbox, own-upload download and narrow-document tests.
  • bun run design:behavior --workers=2 (with CHOKIDAR_USEPOLLING=1, because this shared machine is out of inotify instances): 104 passed and 2 failed. The two failures are cursors.e2e.ts "shadcn's menu… emoji picker's tabs" and emoji.e2e.ts "every emoji is text…". In both, the emoji picker did not open in time under load, and neither touches the viewer. Rerun on their own, both files passed (20 passed). An earlier full run also found a real bug: in development, React's double effect closed the viewer as it opened. That is fixed, and design/chat-media.e2e.ts covers it.
  • bun run build: initial JavaScript is 259,958 B raw and 82,482 B gzip; on main it was 259,958 B and 82,478 B. The budget is 260,500 and 83,450. The initial stylesheet is 184,551 B raw and 32,672 B gzip, the same as main. The budget is 185,000 and 33,000. The viewer is its own lazy chunk (image-lightbox-*.js and image-lightbox-*.css).

Judgement calls

  • One viewer. Chat's viewer used to be the copied ImageGallery <Dialog>. Now Chat opens Chopin's own viewer, and the copied element keeps its bytes. The tiles still come from it, and its own dialog is still shown on the design-audit plate. The copy's dialog could not take a slider, rotate or a blurred ground without patching it, and the request was one design for every caller.
  • Copy image is not in the viewer. Slack's viewer has none, and the maintainer asked for Download alone at the bottom end. Copy stays on the image's toolbar in the message.
  • A picture on another site whose host refuses reads. The old viewer offered Copy link and an Open image link in the viewer. Now Download tries to read the picture, and if the host refuses, it opens the picture in a new tab. That keeps a single control.
  • PDFs. Chat accepts only raster images and text-like files, which are stored as plain text, so a PDF never reaches the viewer. Anything that is not text shows as the file card. No same-origin PDF embedding was added.
  • The document's own images. The editor does not group a document's images, so a document image opens alone, with no arrows.
  • Where Previous and Next sit, and their names. They are at the window's sides, halfway down, on desktop and phone. They are named "Previous" and "Next", because they step through files as well as pictures.
  • Motion tokens. I used the existing --duration-base, --duration-fast and --ease-out rather than adding a token.
  • The zoom slider's track. It is one colour, with the thumb showing the position. A filled portion would need a custom property inside a colour value, which the design contract refuses.

Left open

  • None known.

after-chat-desktop-closed

after-files-closed

after-files-csv

after-files-text

before-chat-desktop-closed

before-chat-phone

before-files-closed

before-files-text

reference-layout

…h a message's files with it

Chat's pictures opened in assistant-ui's copied gallery dialog, and document and decision-card
pictures in the editor's own lightbox: two looks and two keyboards for one job, neither with
rotate or a zoom slider, both on a flat ink ground, with Download at the top beside Copy and Close.
Pressing a file sent with a message downloaded it at once.

Every caller now opens the editor's viewer (`ImageLightbox`), loaded only when a picture opens,
with its own stylesheet. It is laid out as Slack's: the whole window over the picture itself,
blurred and dimmed; along the top who sent it (their face and handle, chopin for the Planner's
pictures), then "3 hours ago in <document> – <name>" ("in a thread in <document>" for a thread's
messages), or for a document's picture the document's title and the picture's name, with Close at
the top end; the picture fitted with a margin; Rotate, then Zoom out, the zoom slider and Zoom in
at the bottom start; Download alone at the bottom end, with nothing to its right. Several pictures
step with Previous and Next at the sides and the arrow keys, and say "2 of 5". The keys, the wheel,
a pinch and a double-click zoom; a zoomed picture pans under a drag. Download reads the original
as bytes, so a signed-in upload saves as a file under its name; a picture whose host refuses the
read opens in a new tab without opener or referrer. Focus is trapped and returned; it fades in and
out, and not at all under reduced motion.

A message's files join its viewer after its pictures: a text file shows as its text, and Rotate
and zoom are not drawn for it. The shared tooltip draws itself inside an open modal dialog, so the
viewer's controls say their names. The document's title reaches the viewer through a small
`ImagePlace` context, and the viewer's styles leave the editor's stylesheet.

The new and changed viewer specs fail on 38716a9 (10 of them) and pass here.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun run types, bun run ci
Assistant-verification: bun test packages/editor/src/widgets packages/icons apps/web/src/chat apps/web/src/icon-tooltip.test.ts (1103 pass)
Assistant-verification: chat-media, chat-images, chat-images-gallery, chat-attachments, images, decision-option-previews on aaa1d6c (95 passed), and harness "images the Planner makes" (1 passed)
Assistant-verification: the changed chat-media and images specs on 38716a9 (10 failed)
Assistant-verification: bun run design:behavior (104 passed; cursors and emoji picker specs flaked under load and passed on their own, 20 passed)
Assistant-verification: bun run build (initial JS 259,958 B raw / 82,482 B gzip, initial CSS 184,551 B raw / 32,672 B gzip)
User-preference: the image click view should look something like Slack's, but remove everything to the right of the download button on the bottom right hand corner; the gallery for multiple images or documents in one message should have Next and Previous arrows
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…round

The viewer's stylesheet drew its own focus outline and suppressed the dialog's, which
apps/web/src/focus.test.ts refuses: focus geometry belongs to the theme. The viewer now uses the
theme's rule like everything else. On its dark ground petrol is under 3:1, so the theme gives the
viewer's controls the page's colour for `--focus-ring-color`, beside the boundary offset it already
sets. The dialog itself no longer takes focus; Close does on opening, as the old lightbox did, and
Tab and Shift+Tab still wrap inside the viewer.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline (subagent)
Assistant-verification: bun test apps/web/src (1662 pass, 2 skip), bun test packages/editor (1004 pass), bun test scripts packages/icons packages/visuals (284 pass)
Assistant-verification: bun run types, bun run ci
Assistant-verification: chat-media, chat-images, chat-images-gallery, chat-attachments, images, decision-option-previews on 8634eef (95 passed after the images spec's colour serialization fix)
Assistant-verification: bun run design:behavior e2e/design/chat-media.e2e.ts e2e/design/cursors.e2e.ts (22 passed)
Assistant-verification: bun run build (initial JS 259,958 B raw / 82,484 B gzip, initial CSS 184,636 B raw / 32,692 B gzip)
User-preference: the image click view should look something like Slack's, but remove everything to the right of the download button on the bottom right hand corner
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 merged commit 8cb7e62 into main Nov 12, 2026
24 checks passed
@lavaman131
lavaman131 deleted the image-viewer-slack branch November 12, 2026 23:46
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.

1 participant