From 56aa6160e871e423dafac4ff05daee70501c98f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Wed, 8 Jul 2026 16:54:11 +0200 Subject: [PATCH] Stabilize own-message hover toolbar position and align emphasis to hover row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The own-message tint hugs its content (`w-fit`), and the hover toolbar was anchored inside that box — so a short own message pulled its action menu far from the row edge, and the emphasis bottom floated a few px above the hover-row highlight. - Move the toolbar into a full-width positioning column that wraps the hugging tint box, so it pins to the row's right edge for every message regardless of bubble width (matching incoming rows). - Extend `.message-own-tint-end` down through the row's group-end padding so the emphasis bottom meets the hover-row highlight. padding-bottom paints the wash lower; an equal negative margin-bottom cancels it so row heights (and the virtualizer's measurements) are unchanged. Mirrors `.message-group-end`, including the compact-density override. Adds regression tests: toolbar anchoring (DOM structure) and the CSS mirror invariant between `.message-own-tint-end` and `.message-group-end`. --- .../__snapshots__/ChatView.test.tsx.snap | 116 ++++++++++-------- .../conversation/MessageBubble.test.tsx | 45 +++++++ .../components/conversation/MessageBubble.tsx | 63 +++++----- .../messageOwnTintAlignment.test.ts | 46 +++++++ apps/fluux/src/index.css | 12 ++ 5 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 apps/fluux/src/components/conversation/messageOwnTintAlignment.test.ts diff --git a/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap b/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap index 2d06eff09..70bd17506 100644 --- a/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap +++ b/apps/fluux/src/components/__snapshots__/ChatView.test.tsx.snap @@ -127,8 +127,7 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
Snapshots > should match snapshot with messages 1`] = `
+
+ + Alice Smith + +
- Alice Smith + 14:30
- - 14:30 - -
-
- Hello there! -
-
- Attachments +
+ Hello there! +
+
+ Attachments +
@@ -289,8 +293,7 @@ exports[`ChatView > Snapshots > should match snapshot with messages 1`] = `
Snapshots > should match snapshot with messages 1`] = `
+
+ + Me + +
- Me + 14:30
- - 14:30 - -
-
- Hi! How are you? -
-
- Attachments +
+ Hi! How are you? +
+
+ Attachments +
diff --git a/apps/fluux/src/components/conversation/MessageBubble.test.tsx b/apps/fluux/src/components/conversation/MessageBubble.test.tsx index 5ac2c6e26..d8496987f 100644 --- a/apps/fluux/src/components/conversation/MessageBubble.test.tsx +++ b/apps/fluux/src/components/conversation/MessageBubble.test.tsx @@ -907,6 +907,51 @@ describe('Own-message tint', () => { }) }) +describe('Hover toolbar anchoring', () => { + // Regression: the own-message tint hugs its content (`w-fit`), so anchoring the + // hover toolbar inside that box made the toolbar drift left/right with the + // bubble width — a short own message pulled its menu far from the row edge. + // The toolbar now lives in a full-width positioning column that also wraps the + // hugging tint box, so it pins to the row's right edge for every message + // regardless of how narrow the tint is. These guard that structure. + it('renders the toolbar OUTSIDE the hugging own-tint box (not a descendant of it)', () => { + const props = createDefaultProps({ message: createTestMessage({ isOutgoing: true }) }) + const { container } = render() + + const toolbar = container.querySelector('[data-message-toolbar]') + expect(toolbar).not.toBeNull() + // If someone re-nests the toolbar inside the w-fit tint, this fails. + expect(toolbar!.closest('.message-own-tint')).toBeNull() + }) + + it('anchors the toolbar in a full-width (flex-1) column that also holds the tint box', () => { + const props = createDefaultProps({ message: createTestMessage({ isOutgoing: true }) }) + const { container } = render() + + const column = container.querySelector('[data-message-toolbar]')!.parentElement! + // The positioning column spans the full available width and is the offset + // parent for the absolutely-positioned toolbar. + expect(column.className).toContain('flex-1') + expect(column.className).toContain('relative') + // The content-hugging tint box is a sibling of the toolbar inside that column. + const tint = column.querySelector('.message-own-tint') + expect(tint).not.toBeNull() + expect(tint!.className).toContain('w-fit') + }) + + it('keeps incoming content full-width (w-full) with the toolbar as a sibling', () => { + const props = createDefaultProps({ message: createTestMessage({ isOutgoing: false }) }) + const { container } = render() + + const column = container.querySelector('[data-message-toolbar]')!.parentElement! + expect(column.className).toContain('flex-1') + // Incoming rows have no tint; their content fills the column. + expect(container.querySelector('.message-own-tint')).toBeNull() + const content = column.querySelector('[data-msg-chrome]')! + expect(content.className).toContain('w-full') + }) +}) + describe('Density spacing', () => { it('marks group-start rows with the density spacing class', () => { const groupStartProps = createDefaultProps({ showAvatar: true }) diff --git a/apps/fluux/src/components/conversation/MessageBubble.tsx b/apps/fluux/src/components/conversation/MessageBubble.tsx index 21f440722..235997244 100644 --- a/apps/fluux/src/components/conversation/MessageBubble.tsx +++ b/apps/fluux/src/components/conversation/MessageBubble.tsx @@ -462,9 +462,10 @@ export const MessageBubble = memo(function MessageBubble({ : '' // Own-message tint hugs its content instead of spanning the row: `w-fit` sizes // the filled surface to the widest line (body or the name+time header) and - // `max-w-full` still wraps long messages at the available width. Incoming rows - // and whisper cards keep `flex-1` so their layout is unchanged. - const contentWidthClass = ownTint ? 'w-fit max-w-full' : 'flex-1' + // `max-w-full` still wraps long messages at the available width. The tint box + // sits inside a full-width positioning column (see below), so incoming rows and + // whisper cards fill that column with `w-full`; their layout is unchanged. + const contentWidthClass = ownTint ? 'w-fit max-w-full' : 'w-full' // A run of consecutive own messages shares its widest line's width so the tint // reads as one clean rectangle rather than a ragged per-row hug. `ownGroupKey` // is already undefined unless this is a MULTI-row own run; gate on `ownTint` @@ -554,6 +555,36 @@ export const MessageBubble = memo(function MessageBubble({ mismatch the fill and knock the side borders 8px out of alignment with the incoming rows — shattering the single "private with X" card. The name header already carries the own-vs-counterpart distinction. */} + {/* Full-width positioning column. The hover toolbar is anchored here (not + inside the tint box) so it pins to the row's right edge and keeps a + stable position regardless of how narrow the own-message tint hugs its + content. Incoming rows already spanned the full width, so their toolbar + position is unchanged. */} +
+ {/* Floating hover toolbar - hidden when user is composing or message is retracted */} + {!message.isRetracted && ( + + )}
))} - {/* Floating hover toolbar - hidden when user is composing or message is retracted */} - {!message.isRetracted && ( - - )} - {/* Nick header - hidden for /me action messages (nick is shown inline) */} {showAvatar && !isActionMessage(message.body) && (
@@ -782,6 +788,7 @@ export const MessageBubble = memo(function MessageBubble({
)}
+
{/* Avatar lightbox overlay */} {showAvatarLightbox && ( diff --git a/apps/fluux/src/components/conversation/messageOwnTintAlignment.test.ts b/apps/fluux/src/components/conversation/messageOwnTintAlignment.test.ts new file mode 100644 index 000000000..49f33118a --- /dev/null +++ b/apps/fluux/src/components/conversation/messageOwnTintAlignment.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +// Pure file-parsing test — the app's default happy-dom env rewrites +// `import.meta.url` to a non-file URL, which breaks fileURLToPath; node env +// keeps it a real file:// URL. +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// The emphasis-bottom alignment fix is pure CSS (jsdom can't compute it), so we +// guard the invariant by parsing index.css directly. Comments are stripped so a +// prose mention of a property name can never be mistaken for a declaration. +const css = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + +/** Read a single declaration value out of a named rule block. */ +function decl(selector: string, prop: string): string | null { + const sel = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const block = new RegExp(`${sel}\\s*\\{([^}]*)\\}`).exec(css) + if (!block) return null + const m = new RegExp(`(?:^|;|\\s)${prop}\\s*:\\s*([^;]+?)\\s*(?:;|$)`).exec(block[1]) + return m ? m[1].trim() : null +} + +describe('Own-message emphasis bottom alignment', () => { + // Regression: a group-end own message must extend its tint down through the + // row's group-end padding so the emphasis bottom meets the hover-row highlight + // instead of floating a few px above it. It's done with padding-bottom (paints + // the wash lower) plus an EQUAL negative margin-bottom (so the row height — and + // the virtualizer's measurements — stay unchanged). Those values must MIRROR + // .message-group-end exactly; otherwise the emphasis over/under-shoots the + // highlight, or the negative margin no longer cancels the padding and row + // heights drift (breaking the scroll invariants). + it('mirrors .message-group-end padding on .message-own-tint-end (comfortable)', () => { + const gap = decl('.message-group-end', 'padding-bottom') + expect(gap).not.toBeNull() + expect(decl('.message-own-tint-end', 'padding-bottom')).toBe(gap) + expect(decl('.message-own-tint-end', 'margin-bottom')).toBe(`-${gap}`) + }) + + it('mirrors the compact-density group-end padding on .message-own-tint-end', () => { + const gap = decl('[data-density="compact"] .message-group-end', 'padding-bottom') + expect(gap).not.toBeNull() + expect(decl('[data-density="compact"] .message-own-tint-end', 'padding-bottom')).toBe(gap) + expect(decl('[data-density="compact"] .message-own-tint-end', 'margin-bottom')).toBe(`-${gap}`) + }) +}) diff --git a/apps/fluux/src/index.css b/apps/fluux/src/index.css index f5dbeb740..f1b05e407 100644 --- a/apps/fluux/src/index.css +++ b/apps/fluux/src/index.css @@ -1212,6 +1212,18 @@ input[type="range"]::-moz-range-thumb { .message-own-tint-end { border-bottom-left-radius: var(--fluux-radius-m); border-bottom-right-radius: var(--fluux-radius-m); + /* Extend the group's outer bottom edge down through the row's group-end + padding so the emphasis meets the bottom of the hover-row highlight instead + of floating a few px above it. padding-bottom paints the wash lower; the + matching negative margin-bottom cancels it so the row height (and the + virtualizer's measurements) are untouched. Mirror .message-group-end's + values exactly, including the compact-density override below. */ + padding-bottom: 6px; + margin-bottom: -6px; +} +[data-density="compact"] .message-own-tint-end { + padding-bottom: 3px; + margin-bottom: -3px; } /* Persistent highlight for search context view */