Three fixes in one commit:
1. **CI test mock** — Added addEventListener/removeEventListener to the
Node.js makeElement mock so viewport.addEventListener() calls in the
touch handler registration don't crash with 'not a function'.
2. **Two-finger pan during pinch** — Replaced _setScale() call with
direct transform math. The new formula computes scale and position
simultaneously:
ratio = boundedScale / pinchStartScale
x = currMidX - (startMidX - startPosX) * ratio
This keeps the diagram under both fingers regardless of midpoint
drift, enabling simultaneous zoom + pan.
3. **Post-pinch click suppression** — _onTouchEnd now sets dragged=true
when exiting pinch mode so any synthesized click event is caught
by the existing dragged guard in _openViewerOnClick instead of
opening the lightbox.
Adds touch event handlers (touchstart/touchmove/touchend/touchcancel) to
the Mermaid viewer viewport for two-finger pinch zoom, using the existing
_setScale() function with midpoint anchoring.
- Guards pointer handlers (down/move/click) during active pinch to prevent
interference between touch and pointer event systems
- Uses {passive: false} on touchstart/touchmove to allow preventDefault()
- Falls back gracefully on non-touch devices — touch listeners are inert
without touch hardware
Fixes: Mermaid diagrams in Hermes WebUI were not zoomable via multi-touch
pinch on mobile/tablet devices, frustrating users with complex diagrams.
Greptile re-review (3/5) flagged two issues:
1. parserFor fallback ('__default__') string key → WeakMap.set throws
TypeError (WeakMap accepts only object keys). Fixed by introducing a
module-level sentinel object __SMD_PARSER_FALLBACK = {} and using it
in both parserFor definitions instead of the string literal.
2. _smdMediaTailClear was defined but never called despite the comment
claiming it was. Fixed by adding calls in:
- _smdEndParser → clears the ongoing live-stream parser's tail
- _clearAnchorProseIncrementalNode → clears each anchor-scene
parser's tail before the cache is dropped
All 222 tests remain green.
Code-level fixes for the two issues Greptile + owner review surfaced:
1. SAFETY (Greptile #1 / owner security regression): the original
_smdMediaAwareAddText concatenated prose slices + MEDIA HTML into
one string and handed it to DOMParser. Prose containing HTML
metacharacters (e.g. <img onerror=...>) would be parsed as live
elements. Fixed by routing each prose run through baseAddText
(which calls createTextNode) and only feeding each single MEDIA
token's trusted _inlineMediaHtmlForRef output through DOMParser.
2. CROSS-CHUNK SPLITS (Greptile #2): smd may deliver a MEDIA: token
across two flushes (e.g. "MEDIA:C:\...\" then "foo.png"). The
original implementation would treat each half as separate text
because neither half terminated cleanly. Fixed with a per-parser
tail buffer (WeakMap keyed by parser instance) that holds trailing
bytes ending in "MEDIA:" or MEDIA:<partial-ref> until the next call.
The tail-buffer boundary detection was also improved: the previous
`/[\s\)\]]/.test(rest)` incorrectly rejected "before MEDIA:" (the
whitespace is before MEDIA, not after a completed ref). Replaced
with `rest.endsWith('MEDIA:')` and `/MEDIA:...$/.exec(rest)`.
Architecture: _smdAppendMediaNode is a new single-token DOM insertion
helper that creates a DocumentFragment from one _inlineMediaHtmlForRef
output. _smdMediaTailSet/_smdMediaTailClear manage the per-parser
buffer. Both _safeSmdRenderer and _streamFadeRenderer now route through
the updated interceptor with tail map and parser identity.
Coverage: test_smd_media_in_stream.py tests the architectural invariants
(helper exists, both renderers delegate, recursion guard, entity-safety,
cross-chunk tail). 222 tests pass across all renderer/behaviour suites.
The local agent loop drops:
- a `.playwright-mcp/` directory full of intermediate screenshots while
debugging browser sessions
- a `final-check.png` of the page state at the end of a task
Both are local-only debugging artifacts that should never end up in a
commit. Add them next to the existing `screenshot-*.png` / `full-UI.png`
rules so the same dev-loop garbage collection pattern continues to hold
without anyone having to think about it.
The full renderMd() pipeline maps MEDIA:<path-or-url> tokens to inline
images / video / audio / SVG / PDF / etc. via stash + restore passes.
The streaming smd renderer used by the live assistant body never went
through that pass, so any MEDIA token emitted mid-turn showed up as the
raw path string in the chat window and only became a real <img> once
the turn settled and the full render fired.
Fix:
- Extract the URL/media → HTML mapping in renderMd() into a shared
helper _inlineMediaHtmlForRef(ref, sessionId) so the streaming path
can reuse the same DOM the settled message will eventually render.
- Add _smdMediaAwareAddText in messages.js that wraps smd's add_text
to detect MEDIA tokens in any incoming text chunk and splice the
resulting <img>/<video>/<a>/<div data-path=...> node directly into
the smd parent via a DOMParser-created fragment. Plain prose still
delegates to the renderer it intercepted (so the fade renderer keeps
its word-by-word animation, the safe renderer keeps its O(n) plain
text-node insertion).
- Route _safeSmdRenderer.add_text and _streamFadeRenderer.add_text
through the interceptor. Each fast-paths the no-MEDIA case to the
original baseAddText so we can't recurse into our own wrapper.
Coverage:
- New test_smd_media_in_stream.py locks in the architectural invariants
(helper exists, both renderers delegate to it, recursion guard).
- test_renderer_js_behaviour.py driver now provides a minimal
_inlineMediaHtmlForRef stand-in so renderMd can be invoked standalone
under node without the rest of ui.js in scope (matches the patterns
renderMd already relied on for _IMAGE_EXTS / _SVG_EXTS / _AUDIO_EXTS).
- test_svg_audio_video_rendering.py static-count assertions relax from
">=2" to ">=1" since audio/video/SVG markup now lives in a single
shared function rather than being copy-pasted inside renderMd's
stash-restore closure.