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({
             </div>
           )}
           {canDropInMainColumn && mainComposerMedia.isDragOver ? (
-            <DropZoneOverlay className="z-30 rounded-none" />
+            <DropZoneOverlay className="z-50 rounded-2xl bg-primary/20 backdrop-blur-sm" />
           ) : null}
         </section>
       ) : 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 (
     <div
+      data-testid="drop-zone-overlay"
       className={cn(
         "pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10",
         className,
       )}
     >
-      <span className="text-sm font-medium text-primary">
-        Drop files to upload
+      <span
+        className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-sm font-semibold text-background shadow-sm ring-1 ring-background/15"
+        data-testid="drop-zone-label"
+      >
+        <UploadCloud aria-hidden="true" className="size-4" />
+        <span>Drop files to upload</span>
       </span>
     </div>
   );
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<FormattingSelectionRange | null>(
+    null,
+  );
   const [activeStates, setActiveStates] = React.useState<ActiveStates | null>(
     () => (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<HTMLElement>(
+        '[data-testid="drop-zone-overlay"]',
+      );
+      const contentSurface = document.querySelector<HTMLElement>(
+        "[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<HTMLElement>(
+        '[data-testid="drop-zone-overlay"]',
+      );
+      const composerOverlayElement = document.querySelector<HTMLElement>(
+        '[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,
 }) => {
