diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 6acfa25224..81c65d7910 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -59,6 +59,7 @@ export default defineConfig({
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-link-shortcut.spec.ts",
+ "**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index dbce0b6f5e..fea06e1b8e 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -820,7 +820,7 @@ export const ChannelPane = React.memo(function ChannelPane({
)}
{canDropInMainColumn && mainComposerMedia.isDragOver ? (
-
+
) : null}
) : null}
diff --git a/desktop/src/features/messages/lib/selectionBlockFormatting.ts b/desktop/src/features/messages/lib/selectionBlockFormatting.ts
new file mode 100644
index 0000000000..ef38ed7e0c
--- /dev/null
+++ b/desktop/src/features/messages/lib/selectionBlockFormatting.ts
@@ -0,0 +1,86 @@
+import { TextSelection, type Transaction } from "@tiptap/pm/state";
+import { canSplit } from "@tiptap/pm/transform";
+
+function canSplitInsideTextblock(
+ transaction: Transaction,
+ position: number,
+): boolean {
+ const $position = transaction.doc.resolve(position);
+ return (
+ $position.parent.inlineContent &&
+ $position.parentOffset > 0 &&
+ $position.parentOffset < $position.parent.content.size &&
+ canSplit(transaction.doc, position)
+ );
+}
+
+function mapRangeThroughLatestStep(
+ transaction: Transaction,
+ from: number,
+ to: number,
+): { from: number; to: number } {
+ const stepMap = transaction.steps.at(-1)?.getMap();
+ return stepMap
+ ? {
+ from: stepMap.map(from, 1),
+ to: stepMap.map(to, -1),
+ }
+ : { from, to };
+}
+
+/**
+ * Isolate a non-empty text selection at exact block boundaries.
+ *
+ * ProseMirror's block commands operate on whole textblocks. The composer can
+ * hold an entire draft in one paragraph, so toggling a list or code block for
+ * a substring otherwise formats the whole draft. Splitting at the selection
+ * end and start first gives the selected text its own block while preserving
+ * the surrounding content as sibling paragraphs.
+ *
+ * This mutates the transaction supplied by a Tiptap command chain so the
+ * isolation and the following block toggle remain one undoable edit.
+ */
+export function isolateSelectionForBlockFormatting(
+ transaction: Transaction,
+): boolean {
+ if (
+ !(transaction.selection instanceof TextSelection) ||
+ transaction.selection.empty
+ ) {
+ return false;
+ }
+
+ const isBackward = transaction.selection.anchor > transaction.selection.head;
+ let { from, to } = transaction.selection;
+
+ const nodeAfterSelection = transaction.doc.resolve(to).nodeAfter;
+ if (nodeAfterSelection?.type.name === "hardBreak") {
+ transaction.delete(to, to + nodeAfterSelection.nodeSize);
+ ({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
+ }
+
+ const nodeBeforeSelection = transaction.doc.resolve(from).nodeBefore;
+ if (nodeBeforeSelection?.type.name === "hardBreak") {
+ transaction.delete(from - nodeBeforeSelection.nodeSize, from);
+ ({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
+ }
+
+ if (canSplitInsideTextblock(transaction, to)) {
+ transaction.split(to);
+ ({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
+ }
+
+ if (canSplitInsideTextblock(transaction, from)) {
+ transaction.split(from);
+ ({ from, to } = mapRangeThroughLatestStep(transaction, from, to));
+ }
+
+ transaction.setSelection(
+ TextSelection.create(
+ transaction.doc,
+ isBackward ? to : from,
+ isBackward ? from : to,
+ ),
+ );
+ return true;
+}
diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx
index 6a14aa498a..bee38e321c 100644
--- a/desktop/src/features/messages/ui/ComposerAttachments.tsx
+++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx
@@ -7,6 +7,7 @@ import {
HatGlasses,
Pencil,
Play,
+ UploadCloud,
Users,
X,
} from "lucide-react";
@@ -39,13 +40,18 @@ import { ComposerImageEditor } from "./ComposerImageEditor";
export function DropZoneOverlay({ className }: { className?: string }) {
return (
-
- Drop files to upload
+
+
+ Drop files to upload
);
diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx
index a605b3dcd9..9dcc45b63c 100644
--- a/desktop/src/features/messages/ui/FormattingToolbar.tsx
+++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
import {
Bold,
@@ -15,6 +16,7 @@ import {
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import { isolateSelectionForBlockFormatting } from "@/features/messages/lib/selectionBlockFormatting";
import { getEditorSpoilerRangeState } from "@/features/messages/lib/spoilerFormatting";
import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark";
@@ -29,6 +31,11 @@ type FormattingToolbarProps = {
onLinkButton?: () => void;
};
+type FormattingSelectionRange = {
+ anchor: number;
+ head: number;
+};
+
type ActiveStates = {
bold: boolean;
italic: boolean;
@@ -117,6 +124,9 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
disabled = false,
onLinkButton,
}: FormattingToolbarProps) {
+ const pendingSelectionRef = React.useRef(
+ null,
+ );
const [activeStates, setActiveStates] = React.useState(
() => (editor ? getActiveStates(editor) : null),
);
@@ -137,36 +147,83 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
};
}, [editor]);
- const toggleBold = React.useCallback(() => {
- editor?.chain().focus().toggleBold().run();
+ const captureSelection = React.useCallback(() => {
+ if (!editor || editor.state.selection.empty) {
+ pendingSelectionRef.current = null;
+ return;
+ }
+
+ const { anchor, head } = editor.state.selection;
+ pendingSelectionRef.current = { anchor, head };
}, [editor]);
- const toggleItalic = React.useCallback(() => {
- editor?.chain().focus().toggleItalic().run();
+ const formattingChain = React.useCallback(() => {
+ if (!editor) return null;
+
+ const range = pendingSelectionRef.current;
+ pendingSelectionRef.current = null;
+ const chain = editor.chain();
+
+ if (
+ range &&
+ range.anchor !== range.head &&
+ range.anchor <= editor.state.doc.content.size &&
+ range.head <= editor.state.doc.content.size
+ ) {
+ chain.command(({ tr }) => {
+ tr.setSelection(TextSelection.create(tr.doc, range.anchor, range.head));
+ return true;
+ });
+ }
+
+ return chain.focus();
}, [editor]);
+ const toggleBold = React.useCallback(() => {
+ formattingChain()?.toggleBold().run();
+ }, [formattingChain]);
+
+ const toggleItalic = React.useCallback(() => {
+ formattingChain()?.toggleItalic().run();
+ }, [formattingChain]);
+
const toggleStrike = React.useCallback(() => {
- editor?.chain().focus().toggleStrike().run();
- }, [editor]);
+ formattingChain()?.toggleStrike().run();
+ }, [formattingChain]);
const toggleCode = React.useCallback(() => {
- editor?.chain().focus().toggleCode().run();
- }, [editor]);
+ formattingChain()?.toggleCode().run();
+ }, [formattingChain]);
const toggleCodeBlock = React.useCallback(() => {
- editor?.chain().focus().toggleCodeBlock().run();
- }, [editor]);
+ formattingChain()
+ ?.command(({ tr }) => {
+ isolateSelectionForBlockFormatting(tr);
+ return true;
+ })
+ .toggleCodeBlock()
+ .run();
+ }, [formattingChain]);
+
+ const restorePendingSelection = React.useCallback(() => {
+ formattingChain()?.run();
+ }, [formattingChain]);
const toggleLink = React.useCallback(() => {
if (!editor) return;
- // Preferred path: open the link-edit modal, which handles add, edit, and
- // remove with proper display-text + URL fields.
+ // Restore the range captured on pointer-down before opening the modal.
+ // WKWebView may otherwise collapse it as focus moves to the toolbar.
if (onLinkButton) {
+ restorePendingSelection();
onLinkButton();
return;
}
+ const chain = formattingChain();
+ if (!chain) return;
+ chain.run();
+
// Legacy fallback (no modal wired): the native prompts below are a no-op
// in the Tauri WebView, so this path effectively does nothing there.
if (editor.isActive("link")) {
@@ -189,24 +246,37 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor.chain().focus().insertContent(`[${label}](${url})`).run();
}
}
- }, [editor, onLinkButton]);
+ }, [editor, formattingChain, onLinkButton, restorePendingSelection]);
const toggleBulletList = React.useCallback(() => {
- editor?.chain().focus().toggleBulletList().run();
- }, [editor]);
+ formattingChain()
+ ?.command(({ tr }) => {
+ isolateSelectionForBlockFormatting(tr);
+ return true;
+ })
+ .toggleBulletList()
+ .run();
+ }, [formattingChain]);
const toggleOrderedList = React.useCallback(() => {
- editor?.chain().focus().toggleOrderedList().run();
- }, [editor]);
+ formattingChain()
+ ?.command(({ tr }) => {
+ isolateSelectionForBlockFormatting(tr);
+ return true;
+ })
+ .toggleOrderedList()
+ .run();
+ }, [formattingChain]);
const toggleBlockquote = React.useCallback(() => {
- editor?.chain().focus().toggleBlockquote().run();
- }, [editor]);
+ formattingChain()?.toggleBlockquote().run();
+ }, [formattingChain]);
const toggleSpoiler = React.useCallback(() => {
if (!editor) return;
+ restorePendingSelection();
toggleSpoilerFormatting(editor);
- }, [editor]);
+ }, [editor, restorePendingSelection]);
if (!editor || !activeStates) return null;
@@ -289,6 +359,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
aria-pressed={item.active}
disabled={disabled}
onClick={() => item.action()}
+ onMouseDown={captureSelection}
className={cn(
"inline-flex h-7 w-7 min-w-7 items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx
index e7e29cab35..9eb24a970c 100644
--- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx
+++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx
@@ -195,6 +195,7 @@ export function SelectionFormattingTray({
? "-translate-x-1/2 -translate-y-full"
: "-translate-x-1/2",
)}
+ data-buzz-selection-formatting-tray
data-testid="selection-formatting-tray"
onMouseDown={(event) => event.preventDefault()}
role="toolbar"
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index 634dc78ffa..0810a15f0a 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -245,6 +245,28 @@
--buzz-active-foreground: 0 0% 100%;
}
+/*
+ * Use the Buzz primary color for the floating selection-formatting tray.
+ * Other themes retain the standard popover treatment.
+ */
+:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] {
+ border-color: hsl(var(--primary-foreground) / 0.18);
+ background-color: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+}
+
+:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button {
+ color: hsl(var(--primary-foreground));
+}
+
+:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button:hover,
+:root[data-buzz-sidebar]
+ [data-buzz-selection-formatting-tray]
+ button[aria-pressed="true"] {
+ background-color: hsl(var(--primary-foreground) / 0.16);
+ color: hsl(var(--primary-foreground));
+}
+
/* Keep the subtle content-card lift in Buzz Light only. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
box-shadow:
diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts
new file mode 100644
index 0000000000..0da13afe3e
--- /dev/null
+++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts
@@ -0,0 +1,287 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+async function openGeneral(page: Page) {
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+}
+
+async function selectText(input: Locator, selectedText: string) {
+ await input.evaluate((element, text) => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ let offset = 0;
+
+ while (walker.nextNode()) {
+ const node = walker.currentNode;
+ const value = node.textContent ?? "";
+ const index = value.indexOf(text);
+ if (index >= 0) {
+ const range = document.createRange();
+ range.setStart(node, index);
+ range.setEnd(node, index + text.length);
+
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ (element as HTMLElement).focus();
+ document.dispatchEvent(new Event("selectionchange"));
+ return;
+ }
+ offset += value.length;
+ }
+
+ throw new Error(
+ `Could not select "${text}" in composer after ${offset} characters`,
+ );
+ }, selectedText);
+}
+
+async function dragSelectText(
+ page: Page,
+ input: Locator,
+ selectedText: string,
+ backward = false,
+) {
+ const points = await input.evaluate((element, text) => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+
+ while (walker.nextNode()) {
+ const node = walker.currentNode;
+ const value = node.textContent ?? "";
+ const index = value.indexOf(text);
+ if (index < 0) continue;
+
+ const startRange = document.createRange();
+ startRange.setStart(node, index);
+ startRange.setEnd(node, index + 1);
+ const startRect = startRange.getBoundingClientRect();
+
+ const endRange = document.createRange();
+ endRange.setStart(node, index + text.length - 1);
+ endRange.setEnd(node, index + text.length);
+ const endRect = endRange.getBoundingClientRect();
+
+ return {
+ start: {
+ x: startRect.left + 1,
+ y: startRect.top + startRect.height / 2,
+ },
+ end: {
+ x: endRect.right - 1,
+ y: endRect.top + endRect.height / 2,
+ },
+ };
+ }
+
+ throw new Error(`Could not locate "${text}" for mouse selection`);
+ }, selectedText);
+
+ const dragStart = backward ? points.end : points.start;
+ const dragEnd = backward ? points.start : points.end;
+ await page.mouse.move(dragStart.x, dragStart.y);
+ await page.mouse.down();
+ await page.mouse.move(dragEnd.x, dragEnd.y, { steps: 12 });
+ await page.mouse.up();
+
+ await expect
+ .poll(() => page.evaluate(() => window.getSelection()?.toString()))
+ .toBe(selectedText);
+}
+
+async function applySelectionFormat(
+ page: Page,
+ input: Locator,
+ label: "Bullet list" | "Code block" | "Ordered list",
+ collapseAfterMouseDown = false,
+ useMouseSelection = false,
+) {
+ if (useMouseSelection) {
+ await dragSelectText(page, input, "selected");
+ } else {
+ await selectText(input, "selected");
+ }
+ const tray = page.getByTestId("selection-formatting-tray");
+ await expect(tray).toBeVisible();
+ const button = tray.getByRole("button", { name: label });
+
+ if (collapseAfterMouseDown) {
+ await button.evaluate((element, inputTestId) => {
+ element.addEventListener(
+ "mouseup",
+ () => {
+ const input = document.querySelector(
+ `[data-testid="${inputTestId}"]`,
+ );
+ if (!input) throw new Error("Composer input not found");
+
+ const range = document.createRange();
+ range.selectNodeContents(input);
+ range.collapse(false);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+ },
+ { once: true },
+ );
+ }, "message-input");
+ }
+
+ await button.click();
+}
+
+test.beforeEach(async ({ page }) => {
+ await installMockBridge(page);
+});
+
+for (const format of [
+ { label: "Code block", selector: "pre" },
+ { label: "Bullet list", selector: "ul" },
+ { label: "Ordered list", selector: "ol" },
+] as const) {
+ test(`${format.label} applies only to the selected composer text`, async ({
+ page,
+ }) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.fill("before selected after");
+ await applySelectionFormat(page, input, format.label);
+
+ await expect(input.locator(":scope > p").first()).toHaveText("before ");
+ await expect(input.locator(`:scope > ${format.selector}`)).toHaveText(
+ "selected",
+ );
+ await expect(input.locator(":scope > p").last()).toHaveText(" after");
+ await expect(input).toHaveText("before selected after");
+ });
+}
+
+test("block formatting preserves the lines around a selected composer line", async ({
+ page,
+}) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.click();
+ await input.pressSequentially("before");
+ await input.press("Shift+Enter");
+ await input.pressSequentially("selected");
+ await input.press("Shift+Enter");
+ await input.pressSequentially("after");
+ await applySelectionFormat(page, input, "Bullet list");
+
+ await expect(input.locator(":scope > p").first()).toHaveText("before");
+ await expect(input.locator(":scope > ul")).toHaveText("selected");
+ await expect(input.locator(":scope > p").last()).toHaveText("after");
+
+ await page.getByTestId("send-message").click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
+ }
+ ).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
+ ),
+ )
+ .toBe("before\n\n- selected\n\nafter");
+});
+
+test("block formatting restores a selection collapsed by the toolbar interaction", async ({
+ page,
+}) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.fill("before selected after");
+ await applySelectionFormat(page, input, "Bullet list", true);
+
+ await expect(input.locator(":scope > p").first()).toHaveText("before ");
+ await expect(input.locator(":scope > ul")).toHaveText("selected");
+ await expect(input.locator(":scope > p").last()).toHaveText(" after");
+});
+
+test("block formatting only changes text selected with a native mouse drag", async ({
+ page,
+}) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.fill("before selected after");
+ await applySelectionFormat(page, input, "Bullet list", false, true);
+
+ await expect(input.locator(":scope > p").first()).toHaveText("before ");
+ await expect(input.locator(":scope > ul")).toHaveText("selected");
+ await expect(input.locator(":scope > p").last()).toHaveText(" after");
+});
+
+test("block formatting preserves a backward native selection", async ({
+ page,
+}) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.fill("before selected after");
+ await dragSelectText(page, input, "selected", true);
+
+ const tray = page.getByTestId("selection-formatting-tray");
+ await expect(tray).toBeVisible();
+ await tray.getByRole("button", { name: "Bullet list" }).click();
+
+ await expect(input.locator(":scope > ul")).toHaveText("selected");
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const selection = window.getSelection();
+ if (!(selection?.anchorNode && selection.focusNode)) return false;
+ const anchorRange = document.createRange();
+ anchorRange.setStart(selection.anchorNode, selection.anchorOffset);
+ anchorRange.collapse(true);
+ const focusRange = document.createRange();
+ focusRange.setStart(selection.focusNode, selection.focusOffset);
+ focusRange.collapse(true);
+ return (
+ anchorRange.compareBoundaryPoints(Range.START_TO_START, focusRange) >
+ 0
+ );
+ }),
+ )
+ .toBe(true);
+});
+
+test("Buzz theme uses the primary color for the selection formatter", async ({
+ page,
+}) => {
+ await openGeneral(page);
+
+ const input = page.getByTestId("message-input");
+ await input.fill("before selected after");
+ await selectText(input, "selected");
+ const tray = page.getByTestId("selection-formatting-tray");
+ await expect(tray).toBeVisible();
+
+ const colors = await tray.evaluate((element) => {
+ const probe = document.createElement("span");
+ probe.style.backgroundColor = "hsl(var(--primary))";
+ probe.style.color = "hsl(var(--primary-foreground))";
+ document.body.appendChild(probe);
+ const probeStyles = getComputedStyle(probe);
+ const trayStyles = getComputedStyle(element);
+ const result = {
+ primaryBackground: probeStyles.backgroundColor,
+ primaryForeground: probeStyles.color,
+ trayBackground: trayStyles.backgroundColor,
+ trayForeground: trayStyles.color,
+ };
+ probe.remove();
+ return result;
+ });
+
+ expect(colors.trayBackground).toBe(colors.primaryBackground);
+ expect(colors.trayForeground).toBe(colors.primaryForeground);
+});
diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts
index 41eb55e521..403d26c136 100644
--- a/desktop/tests/e2e/file-attachment.spec.ts
+++ b/desktop/tests/e2e/file-attachment.spec.ts
@@ -82,7 +82,53 @@ test("dropping a file on the channel column attaches it to the composer", async
const dropZone = page.getByTestId("channel-drop-zone");
await dropZone.dispatchEvent("dragenter", { dataTransfer });
- await expect(dropZone.getByText("Drop files to upload")).toBeVisible();
+ const overlay = dropZone.getByTestId("drop-zone-overlay");
+ const label = dropZone.getByTestId("drop-zone-label");
+ await expect(overlay).toBeVisible();
+ await expect(label).toContainText("Drop files to upload");
+
+ const [dropZoneBox, overlayBox, overlayStyles, stacking] = await Promise.all([
+ dropZone.boundingBox(),
+ overlay.boundingBox(),
+ page.evaluate(() => {
+ const overlayElement = document.querySelector(
+ '[data-testid="drop-zone-overlay"]',
+ );
+ const contentSurface = document.querySelector(
+ "[data-buzz-content-surface]",
+ );
+ if (!(overlayElement && contentSurface)) return null;
+ const overlayStyle = getComputedStyle(overlayElement);
+ return {
+ backdropFilter: overlayStyle.backdropFilter,
+ containerRadius: getComputedStyle(contentSurface).borderRadius,
+ overlayRadius: overlayStyle.borderRadius,
+ };
+ }),
+ page.evaluate(() => {
+ const overlayElement = document.querySelector(
+ '[data-testid="drop-zone-overlay"]',
+ );
+ const composerOverlayElement = document.querySelector(
+ '[data-testid="channel-composer-overlay"]',
+ );
+ if (!(overlayElement && composerOverlayElement)) return null;
+ return {
+ composer: Number.parseInt(
+ getComputedStyle(composerOverlayElement).zIndex,
+ 10,
+ ),
+ dropZone: Number.parseInt(getComputedStyle(overlayElement).zIndex, 10),
+ };
+ }),
+ ]);
+
+ expect(overlayBox).toEqual(dropZoneBox);
+ expect(overlayStyles).not.toBeNull();
+ expect(overlayStyles?.overlayRadius).toBe(overlayStyles?.containerRadius);
+ expect(overlayStyles?.backdropFilter).toContain("blur");
+ expect(stacking).not.toBeNull();
+ expect(stacking?.dropZone).toBeGreaterThan(stacking?.composer ?? 0);
await dropZone.dispatchEvent("drop", { dataTransfer });
await expect(page.getByTestId("message-composer")).toContainText(
@@ -90,6 +136,60 @@ test("dropping a file on the channel column attaches it to the composer", async
);
});
+for (const theme of ["buzz", "buzz-dark", "github-light", "github-dark"]) {
+ test(`drop prompt has accessible text contrast in ${theme}`, async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await page.evaluate((selectedTheme) => {
+ window.localStorage.setItem("buzz-theme", selectedTheme);
+ }, theme);
+ await page.reload();
+ await page.getByTestId("channel-general").click();
+
+ const dataTransfer = await page.evaluateHandle(() => {
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File(["contrast check"], "contrast-check.txt", {
+ type: "text/plain",
+ }),
+ );
+ return transfer;
+ });
+ const dropZone = page.getByTestId("channel-drop-zone");
+ await dropZone.dispatchEvent("dragenter", { dataTransfer });
+
+ const contrastRatio = await dropZone
+ .getByTestId("drop-zone-label")
+ .evaluate((element) => {
+ const parseRgb = (value: string) =>
+ (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
+ const luminance = (color: number[]) =>
+ color
+ .map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045
+ ? value / 12.92
+ : ((value + 0.055) / 1.055) ** 2.4;
+ })
+ .reduce(
+ (sum, channel, index) =>
+ sum + channel * [0.2126, 0.7152, 0.0722][index],
+ 0,
+ );
+ const style = getComputedStyle(element);
+ const foreground = luminance(parseRgb(style.color));
+ const background = luminance(parseRgb(style.backgroundColor));
+ return (
+ (Math.max(foreground, background) + 0.05) /
+ (Math.min(foreground, background) + 0.05)
+ );
+ });
+
+ expect(contrastRatio).toBeGreaterThanOrEqual(4.5);
+ });
+}
+
test("forum posts emit a FileCard for generic attachments, not a broken image", async ({
page,
}) => {