fix(desktop): polish inline chip states (#6718)

**Category:** fix
**User Impact:** Inline chips now read more consistently, fit cleanly in
the composer, and show deleted message links with the same calm muted
treatment as unresolved links.

**Problem:** Deleted message links looked like destructive actions even
though they are informational, while mention chips had slightly uneven
vertical spacing, a high-set human icon, and could clip inside the
composer. **Solution:** Unify unavailable-state styling, tighten chip
spacing, optically align the human icon, and give composer chips enough
line height to paint without changing caret behavior.

<details>
<summary>File changes</summary>

**desktop/src/shared/styles/globals/markdown.css**
Makes chip padding vertically symmetric, moves only the human `@` icon
down by 1px, and shares muted colors between deleted and unresolved
message links while preserving their separate semantic classes.

**desktop/src/shared/styles/globals/composer.css**
Adds a composer-only line height derived from the text size and chip
padding so inline chips no longer clip while retaining inline caret
behavior.

**desktop/tests/e2e/entity-link-recipient-cards.spec.ts**
Verifies deleted and unresolved chips have matching computed colors
while deleted links retain their tooltip, semantics, and navigation
behavior.

**desktop/tests/e2e/mentions.spec.ts**
Covers composer chip height, clipping, the human icon's 1px optical
offset, and visual capture.

</details>

## Reproduction steps

1. Open a channel containing a link to a definitively deleted message
and compare it with a transient or unresolved message link; both should
use the muted unavailable treatment, while the deleted link still says
“Message deleted” and navigates to its fallback destination.
2. Insert a person mention in the composer; the chip should have
balanced vertical spacing and paint fully without clipping.
3. Compare a person mention with a channel chip; only the human `@` icon
should sit 1px lower.

## Screenshots

### Deleted message link

| Before | After |
| --- | --- |
| ![Deleted message link
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/deleted-chip-before.png)
| ![Deleted message link
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/deleted-chip-after.png)
|

### Composer chip polish

| Before | After |
| --- | --- |
| ![Composer mention chip
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/composer-chip-before.png)
| ![Composer mention chip
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/composer-chip-after-07411aba3.png)
|

## Validation

- `desktop/tests/e2e/entity-link-recipient-cards.spec.ts` +
`desktop/tests/e2e/mentions.spec.ts`: 82/82 passed
- Desktop unit suite: 5,397 passed
- Formatting and lint checks passed (existing informational warnings
only)
- Pre-push desktop check, typecheck, and unit tests passed at
`0255c3fd49f9244cd727d0dd20c514b3a1812152`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-24 20:45:42 -07:00
committed by GitHub
parent 8d2d0ff5ad
commit a8e1c66c4a
4 changed files with 157 additions and 9 deletions

View File

@@ -96,6 +96,7 @@
@apply text-message font-normal tracking-normal;
outline: none;
min-height: 1lh;
padding-block: 0.0625rem;
}
.rich-text-composer .tiptap p {
@@ -260,8 +261,14 @@
caret into their decorated range and swallows the trailing space after an
@mention. Composer Buzz links are atom nodes, but their labels may still
fragment between characters; only the icon and leading label fragment stay
together. */
together. The line-height keeps each inline decoration box tall enough to
paint both block-padding edges without clipping. */
.rich-text-composer .tiptap .mention-chip {
display: inline;
min-height: 0;
line-height: calc(
1em +
var(--inline-chip-padding-block-start) +
var(--inline-chip-padding-block-end)
);
}

View File

@@ -1,5 +1,5 @@
.message-markdown {
--inline-chip-padding-block-start: 0.1875rem;
--inline-chip-padding-block-start: 0.125rem;
--inline-chip-padding-block-end: 0.125rem;
--inline-chip-padding-inline: 0.25rem;
--inline-chip-min-height: calc(
@@ -101,15 +101,11 @@
}
.message-markdown .mention-chip.buzz-link-unavailable,
.message-markdown .mention-chip.buzz-link-unavailable:hover {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground) / 0.7);
}
.message-markdown .mention-chip.buzz-link-unavailable:hover,
.message-markdown .mention-chip.buzz-link-deleted,
.message-markdown .mention-chip.buzz-link-deleted:hover {
background: hsl(var(--destructive) / 0.12);
color: hsl(var(--destructive));
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground) / 0.7);
}
.message-markdown .mention-chip.inbox-channel-chip {
@@ -336,6 +332,7 @@
}
.message-markdown .inline-chip-icon-human::before {
transform: translateY(calc(-50% + 0.0625rem));
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E")
center / contain no-repeat;

View File

@@ -662,6 +662,10 @@ test("deleted reply links identify deletion and fall back to their thread root",
test("deleted top-level message links identify deletion and fall back to channel navigation", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
window.localStorage.setItem("buzz-accent-color", "#c0a2f1");
});
const missingMessageId = "d".repeat(64);
const channelId = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
const link = `buzz://message?channel=${channelId}&id=${missingMessageId}`;
@@ -685,6 +689,33 @@ test("deleted top-level message links identify deletion and fall back to channel
);
await expect(deletedLink).toHaveClass(/buzz-link-deleted/);
await expect(deletedLink).not.toHaveClass(/buzz-link-unavailable/);
const unavailableColors = await linkMessage.evaluate((message) => {
const probe = document.createElement("span");
probe.className = "mention-chip buzz-link-unavailable";
message.querySelector(".message-markdown")?.append(probe);
const styles = getComputedStyle(probe);
const colors = {
backgroundColor: styles.backgroundColor,
color: styles.color,
};
probe.remove();
return colors;
});
await expect
.poll(() =>
deletedLink.evaluate((element) => {
const styles = getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
color: styles.color,
};
}),
)
.toEqual(unavailableColors);
await waitForAnimations(page);
await linkMessage.screenshot({
path: `${SHOTS}/deleted-message-link-after.png`,
});
await deletedLink.hover();
await expect(page.getByRole("tooltip")).toHaveText("Message deleted");

View File

@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import {
installMockBridge,
openChannelBrowser,
@@ -844,6 +846,9 @@ test("mention autocomplete caps global people search at 50 results", async ({
test("selecting a person mention inserts @Name into input", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "buzz-dark");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -872,6 +877,114 @@ test("selecting a person mention inserts @Name into input", async ({
),
);
expect(iconMask).toContain("data:image/svg+xml");
await expect(mentionChip).toHaveCSS("line-height", "18px");
const scrollViewport = page.getByTestId("message-input-scroll");
const paintedBounds = await mentionChip.evaluate((element) => {
const chip = element.getBoundingClientRect();
const viewport = element
.closest("[data-testid='message-input-scroll']")
?.getBoundingClientRect();
if (!viewport)
throw new Error("Mention chip is missing its scroll viewport");
return {
chipTop: chip.top,
chipBottom: chip.bottom,
viewportTop: viewport.top,
viewportBottom: viewport.bottom,
};
});
expect(paintedBounds.chipTop).toBeGreaterThanOrEqual(
paintedBounds.viewportTop,
);
expect(paintedBounds.chipBottom).toBeLessThanOrEqual(
paintedBounds.viewportBottom,
);
const humanIconTranslateY = await mentionChip.evaluate(
(element) =>
new DOMMatrix(getComputedStyle(element, "::before").transform).m42,
);
const channelIconTranslateY = await input.evaluate((composer) => {
const probe = document.createElement("span");
probe.className =
"mention-chip inline-chip-with-icon inline-chip-icon-channel";
composer.append(probe);
const translateY = new DOMMatrix(
getComputedStyle(probe, "::before").transform,
).m42;
probe.remove();
return translateY;
});
expect(humanIconTranslateY - channelIconTranslateY).toBeCloseTo(1);
await input.fill("@bo");
await dropdown.getByText("bob").click();
await input.press("Shift+Enter");
await page.keyboard.type("@bo");
await dropdown.getByText("bob").click();
await input.press("Shift+Enter");
await page.keyboard.type("@bo");
await dropdown.getByText("bob").click();
const multilineChips = input.locator(".human-mention-highlight");
await expect(multilineChips).toHaveCount(3);
const multilinePaintBounds = await multilineChips.evaluateAll((elements) => {
const viewport = elements[0]
?.closest("[data-testid='message-input-scroll']")
?.getBoundingClientRect();
if (!viewport) {
throw new Error("Mention chips are missing their scroll viewport");
}
return elements.map((element) => {
const chip = element.getBoundingClientRect();
return {
chipTop: chip.top,
chipBottom: chip.bottom,
viewportTop: viewport.top,
viewportBottom: viewport.bottom,
};
});
});
for (const bounds of multilinePaintBounds) {
expect(bounds.chipTop).toBeGreaterThanOrEqual(bounds.viewportTop);
expect(bounds.chipBottom).toBeLessThanOrEqual(bounds.viewportBottom);
}
await scrollViewport.evaluate((element) => {
element.style.width = "8rem";
});
await input.fill("A deliberately long prefix that forces @bo");
await dropdown.getByText("bob").click();
const wrappedChip = input.locator(".human-mention-highlight", {
hasText: "bob",
});
const wrappedPaintBounds = await wrappedChip.evaluate((element) => {
const chip = element.getBoundingClientRect();
const viewport = element
.closest("[data-testid='message-input-scroll']")
?.getBoundingClientRect();
if (!viewport)
throw new Error("Mention chip is missing its scroll viewport");
return {
chipTop: chip.top,
chipBottom: chip.bottom,
viewportTop: viewport.top,
viewportBottom: viewport.bottom,
};
});
expect(wrappedPaintBounds.chipTop).toBeGreaterThanOrEqual(
wrappedPaintBounds.viewportTop,
);
expect(wrappedPaintBounds.chipBottom).toBeLessThanOrEqual(
wrappedPaintBounds.viewportBottom,
);
await expect(input).toHaveCSS("height", /^(?!20px$)/);
await scrollViewport.evaluate((element) => {
element.style.removeProperty("width");
});
await waitForAnimations(page);
await page.getByTestId("message-composer").screenshot({
path: "test-results/inline-chip-polish/composer-after.png",
});
});
test("immediate ArrowLeft after a person mention is not bounced past the trailing space", async ({