eleven / messenger Public

web: don't shift the bubble when the hover reply icon appears

The reply icon was a flex child, so revealing it widened the row and pushed the bubble. It's now absolutely positioned inside .content, just outside the bubble's far edge — revealing it shifts nothing. It abuts the bubble (no gap) and only becomes interactive on hover, so the pointer can travel bubble→icon without losing the hover and stray clicks don't trigger a reply.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 8128cef6e70cf3b5d367b5869de7dc1133cd7a47 parent c6eed65
2 files changed, +19 −7
  • web/static/chat.js +5 −3
  • web/static/style.css +14 −4
diff --git a/web/static/chat.js b/web/static/chat.js
index 272dbd8..25c3c5e 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -511,11 +511,13 @@ async function start(session) {
for (const att of payload.attachments) content.appendChild(renderAttachment(att));
}
if (payload.text) addLinkPreview(content, payload.text);
- bubbleRow.appendChild(content);
const replyMeta = { id: msg.id, name: payload.name, snippet: replySnippet(payload) };
- // Desktop: a hover-revealed reply icon beside the bubble (touch uses double-tap).
- if (!isCoarse) bubbleRow.appendChild(buildHoverReply(replyMeta));
+ // Desktop: a reply icon that reveals beside the bubble on hover. It's absolutely
+ // positioned inside .content, so revealing it never shifts the bubble (touch uses
+ // double-tap instead).
+ if (!isCoarse) content.appendChild(buildHoverReply(replyMeta));
+ bubbleRow.appendChild(content);
el.appendChild(bubbleRow);
// React or reply: double-tap on touch, press-and-hold on desktop.
diff --git a/web/static/style.css b/web/static/style.css
index 0473d5c..8fe3ba1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -181,25 +181,35 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; }
-/* desktop hover reply icon, revealed beside the bubble on hover */
+/* desktop hover reply icon — absolutely positioned just outside the bubble so it
+ reveals on hover without shifting any content. It abuts the bubble (no gap) so
+ the pointer can travel from bubble to icon without losing the hover. */
.reply-hover {
- flex: none;
+ position: absolute;
+ top: 50%;
+ right: -30px;
+ transform: translateY(-50%);
background: transparent;
color: var(--muted);
padding: 4px 6px;
border-radius: 50%;
- display: none;
+ display: inline-flex;
align-items: center;
line-height: 1;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s;
}
+.msg.me .reply-hover { right: auto; left: -30px; }
.reply-hover svg { width: 18px; height: 18px; }
.reply-hover:hover { background: var(--bubble-them); color: var(--accent); }
@media (hover: hover) {
- .msg:hover .reply-hover { display: inline-flex; }
+ .msg:hover .reply-hover { opacity: 1; pointer-events: auto; }
}
/* message content (text bubble + any attachments) */
.content {
+ position: relative; /* anchors the absolute hover reply icon */
display: flex;
flex-direction: column;
gap: 4px;