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>
2 files changed,
+19
−7
web/static/chat.js+5 −3web/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; |