Files
hermes-webui/tests/test_composer_chip_lightbox.py
T
nesquena-hermes 759c25655d 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

101 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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).
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img.src, img.alt); return; }"
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