Jimmy
2026-08-25 15:19:54 +10:00
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 ({