feat(composer): click pasted/attached image thumbnails to lightbox-zoom them
When pasting screenshots into the composer (especially multiple in
sequence, now possible end-to-end with hermes-webui/hermes-swift-mac
PR #74) the user has no way to verify the right image attached. The
56x56 thumbnail in the chip is fine as a UI affordance but offers no
detail at all. Quote from the request:
When I hit Cmd+C and save an image to the clipboard and then paste
the clipboard out, I want to be able to click on any one of those
uploaded images that's inside the composer bar and have it zoom up
like a lightbox so I can see the image in full once it's been
pasted in to the composer input.
The lightbox infrastructure already exists for message-attached
images (static/ui.js:269 _openImgLightbox + the doc-level click
delegate at :298 for .msg-media-img). This PR extends the same
delegate to also fire on .attach-thumb composer chips:
- Clicking the thumbnail opens the existing image lightbox with the
blob URL as src and the file name as alt text.
- Audio/video chips are excluded (they have their own native
<audio> / <video> controls and don't render an .attach-thumb
img).
- SVG thumbnails (.attach-thumb attach-thumb--svg) qualify — they
are images visually.
- The chip's x remove button is a sibling, not an ancestor, of the
thumb — closest('.attach-thumb') from the button returns null,
so removing still works without lightbox interference.
Also updates static/style.css:
- cursor: zoom-in on .attach-thumb (was cursor: default — actively
misleading).
- Subtle :hover emphasis (brightness 1.05 + scale 1.04, 120ms ease)
so users discover the affordance before clicking.
5 regression tests in tests/test_composer_chip_lightbox.py pinning:
- delegate handles .attach-thumb on IMG elements
- delegate still handles .msg-media-img (no regression)
- audio/video chips do NOT render an .attach-thumb img
- cursor:zoom-in declared on the .attach-thumb selector
- hover emphasis rule present
Browser-verified live on port 8789:
- addFiles three distinct screenshot files (mimicking three Mac
sequential pastes) -> 3 chips, 3 thumbs, all distinct.
- Click thumb #2 -> lightbox opens with the right image, alt text
matches filename.
- Click x on chip #2 -> removes that chip, no lightbox.
- Escape key closes lightbox.
Companion PR on the Mac side:
hermes-webui/hermes-swift-mac#74 (unique filename per paste so
sequential pastes actually appear as distinct chips).
Refs nesquena/hermes-webui#1733.
2026-05-06 19:54:04 +00:00
|
|
|
|
"""Regression tests for composer attach-thumb lightbox click behaviour.
|
|
|
|
|
|
|
|
|
|
|
|
User pasted/dropped/picked an image and wants to verify the right one
|
|
|
|
|
|
attached before sending. Clicking the thumbnail in the composer's
|
|
|
|
|
|
attach-tray should open the existing image lightbox (the same one
|
|
|
|
|
|
that's wired to message-attached images).
|
|
|
|
|
|
|
|
|
|
|
|
This file pins the wiring at the source level — the document-level
|
|
|
|
|
|
delegated click handler must:
|
|
|
|
|
|
- Continue handling .msg-media-img (existing v0.50.x behaviour).
|
|
|
|
|
|
- Also handle .attach-thumb on IMG elements (new in this PR).
|
|
|
|
|
|
- NOT trigger on the chip's × remove button (sibling element).
|
|
|
|
|
|
- NOT trigger on audio/video chips (those have native controls).
|
|
|
|
|
|
|
|
|
|
|
|
It also pins the CSS cursor affordance so users discover the feature.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
UI = ROOT / "static" / "ui.js"
|
|
|
|
|
|
STYLE = ROOT / "static" / "style.css"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComposerChipLightboxDelegate:
|
|
|
|
|
|
def test_delegate_handles_attach_thumb_clicks(self):
|
|
|
|
|
|
"""The document click handler must pick up clicks on .attach-thumb
|
|
|
|
|
|
(composer image chips) and route them to _openImgLightbox().
|
|
|
|
|
|
|
|
|
|
|
|
Previously the handler only looked for .msg-media-img.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = UI.read_text(encoding="utf-8")
|
|
|
|
|
|
assert "e.target.closest('.attach-thumb')" in src, (
|
|
|
|
|
|
"Document click delegate must also match .attach-thumb"
|
|
|
|
|
|
)
|
|
|
|
|
|
# And it must call _openImgLightbox in that path.
|
|
|
|
|
|
# Use a tighter anchor block to ensure both branches are wired.
|
|
|
|
|
|
anchor = (
|
|
|
|
|
|
"img = e.target.closest('.attach-thumb');\n"
|
|
|
|
|
|
" if(img && img.tagName === 'IMG'){\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert anchor in src
|
|
|
|
|
|
|
|
|
|
|
|
def test_delegate_still_handles_message_attached_images(self):
|
|
|
|
|
|
"""Existing .msg-media-img wiring must not regress."""
|
|
|
|
|
|
src = UI.read_text(encoding="utf-8")
|
|
|
|
|
|
# The message-image branch must come first (so _openImgLightbox
|
|
|
|
|
|
# fires for them without falling through to the .attach-thumb check).
|
2026-05-26 07:16:03 +00:00
|
|
|
|
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img); return; }"
|
feat(composer): click pasted/attached image thumbnails to lightbox-zoom them
When pasting screenshots into the composer (especially multiple in
sequence, now possible end-to-end with hermes-webui/hermes-swift-mac
PR #74) the user has no way to verify the right image attached. The
56x56 thumbnail in the chip is fine as a UI affordance but offers no
detail at all. Quote from the request:
When I hit Cmd+C and save an image to the clipboard and then paste
the clipboard out, I want to be able to click on any one of those
uploaded images that's inside the composer bar and have it zoom up
like a lightbox so I can see the image in full once it's been
pasted in to the composer input.
The lightbox infrastructure already exists for message-attached
images (static/ui.js:269 _openImgLightbox + the doc-level click
delegate at :298 for .msg-media-img). This PR extends the same
delegate to also fire on .attach-thumb composer chips:
- Clicking the thumbnail opens the existing image lightbox with the
blob URL as src and the file name as alt text.
- Audio/video chips are excluded (they have their own native
<audio> / <video> controls and don't render an .attach-thumb
img).
- SVG thumbnails (.attach-thumb attach-thumb--svg) qualify — they
are images visually.
- The chip's x remove button is a sibling, not an ancestor, of the
thumb — closest('.attach-thumb') from the button returns null,
so removing still works without lightbox interference.
Also updates static/style.css:
- cursor: zoom-in on .attach-thumb (was cursor: default — actively
misleading).
- Subtle :hover emphasis (brightness 1.05 + scale 1.04, 120ms ease)
so users discover the affordance before clicking.
5 regression tests in tests/test_composer_chip_lightbox.py pinning:
- delegate handles .attach-thumb on IMG elements
- delegate still handles .msg-media-img (no regression)
- audio/video chips do NOT render an .attach-thumb img
- cursor:zoom-in declared on the .attach-thumb selector
- hover emphasis rule present
Browser-verified live on port 8789:
- addFiles three distinct screenshot files (mimicking three Mac
sequential pastes) -> 3 chips, 3 thumbs, all distinct.
- Click thumb #2 -> lightbox opens with the right image, alt text
matches filename.
- Click x on chip #2 -> removes that chip, no lightbox.
- Escape key closes lightbox.
Companion PR on the Mac side:
hermes-webui/hermes-swift-mac#74 (unique filename per paste so
sequential pastes actually appear as distinct chips).
Refs nesquena/hermes-webui#1733.
2026-05-06 19:54:04 +00:00
|
|
|
|
assert msg_branch in src
|
|
|
|
|
|
|
|
|
|
|
|
def test_delegate_excludes_audio_video_chips(self):
|
|
|
|
|
|
"""Audio/video chips have their own inline controls (native <audio>
|
|
|
|
|
|
/ <video>) — they don't get a thumbnail .attach-thumb at all, so
|
|
|
|
|
|
the handler can't possibly trigger on them. Pin that the chip
|
|
|
|
|
|
renderer uses .attach-chip--audio / .attach-chip--video sibling
|
|
|
|
|
|
classes (no IMG with class attach-thumb in those branches).
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = UI.read_text(encoding="utf-8")
|
|
|
|
|
|
# Audio chip block — uses <audio>, no .attach-thumb img
|
|
|
|
|
|
assert "<audio controls preload=\"metadata\"" in src
|
|
|
|
|
|
# Video chip block — uses <video>, no .attach-thumb img
|
|
|
|
|
|
assert "<video controls preload=\"metadata\"" in src
|
|
|
|
|
|
# The .attach-thumb img tag is only generated in the image / svg branches.
|
|
|
|
|
|
# Quick structural check: every chip-rendering line that emits
|
|
|
|
|
|
# `class="attach-thumb"` has either `<img class="attach-thumb"` or
|
|
|
|
|
|
# `attach-thumb attach-thumb--svg`. Both are images.
|
|
|
|
|
|
for line in src.splitlines():
|
|
|
|
|
|
if 'class="attach-thumb' in line:
|
|
|
|
|
|
assert "<img " in line, (
|
|
|
|
|
|
"Every .attach-thumb emission should be an <img> tag, "
|
|
|
|
|
|
f"got: {line.strip()[:120]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComposerChipCursorAffordance:
|
|
|
|
|
|
def test_attach_thumb_cursor_is_zoom_in(self):
|
|
|
|
|
|
"""`cursor: zoom-in` signals to the user that the thumbnail is
|
|
|
|
|
|
clickable for zoom — the most discoverable affordance for this UX.
|
|
|
|
|
|
Previously it was `cursor: default` which silently advertised
|
|
|
|
|
|
non-interactivity.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = STYLE.read_text(encoding="utf-8")
|
|
|
|
|
|
# The .attach-thumb rule must declare cursor:zoom-in
|
|
|
|
|
|
# Use a substring search resilient to other property additions.
|
|
|
|
|
|
for line in src.splitlines():
|
|
|
|
|
|
if line.strip().startswith(".attach-thumb{"):
|
|
|
|
|
|
assert "cursor:zoom-in" in line, (
|
|
|
|
|
|
f".attach-thumb cursor must be 'zoom-in', got: {line.strip()[:120]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise AssertionError(".attach-thumb selector not found in style.css")
|
|
|
|
|
|
|
|
|
|
|
|
def test_attach_thumb_has_hover_emphasis(self):
|
|
|
|
|
|
"""Subtle hover emphasis (brightness + scale) reinforces the
|
|
|
|
|
|
zoom-in cursor by giving instant visual feedback before click.
|
|
|
|
|
|
"""
|
|
|
|
|
|
src = STYLE.read_text(encoding="utf-8")
|
|
|
|
|
|
assert ".attach-thumb:hover{" in src or ".attach-thumb:hover {" in src
|