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 + +
); 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, }) => {