From 65a4ad217515134d95330730f766495f0b37d17f Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Thu, 12 Mar 2026 17:08:13 +0100 Subject: [PATCH 1/3] Fix manual attach icon jitter in complex editors --- .../suggestions/ManualAttachUiManager.ts | 27 +++++++++++++- tests/SuggestionManagerRuntime.test.ts | 36 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts index e2069b8a..b7d2dda4 100644 --- a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts +++ b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts @@ -4,6 +4,17 @@ const BUTTON_SIZE_PX = 18; const FIELD_INSET_PX = 8; const PADDING_RESERVE_PX = BUTTON_SIZE_PX + FIELD_INSET_PX * 2; const SUCCESS_STATE_MS = 650; +const INLINE_OBSTACLE_SELECTOR = [ + "button", + "a[href]", + "input", + "textarea", + "select", + "[contenteditable='true']", + "[role='button']", + "[role='combobox']", + "[role='textbox']", +].join(", "); export const MANUAL_ATTACH_BUTTON_CLASS = "ft-manual-attach-button"; export const MANUAL_ATTACH_TOOLTIP = "Click to enable FluentTyper for this field."; @@ -351,11 +362,15 @@ export class ManualAttachUiManager { } const elementRect = element.getBoundingClientRect(); const elementMidpoint = elementRect.left + elementRect.width / 2; - const sameWrapperCandidates = Array.from(positioningParent.querySelectorAll("*")).filter( + // Only consider peer layout boxes around the editor. Deep descendants inside + // complex editors (like Slack) can appear/disappear while typing and should + // not yank the manual-attach control around. + const sameWrapperCandidates = Array.from(positioningParent.children).filter( (candidate): candidate is HTMLElement => candidate instanceof HTMLElement && candidate !== element && candidate !== handle.container && + this.isInlineObstacleCandidate(candidate) && !element.contains(candidate) && !candidate.contains(element), ); @@ -385,6 +400,16 @@ export class ManualAttachUiManager { return candidateRect.bottom > elementRect.top && candidateRect.top < elementRect.bottom; } + private isInlineObstacleCandidate(candidate: HTMLElement): boolean { + if (candidate.getAttribute("aria-hidden") === "true") { + return false; + } + if (candidate.matches(INLINE_OBSTACLE_SELECTOR)) { + return true; + } + return candidate.querySelector(INLINE_OBSTACLE_SELECTOR) instanceof HTMLElement; + } + private resolveOffsetTop( height: number, options: { diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 328c1ccb..e98db8fc 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -1134,6 +1134,42 @@ describe("SuggestionManagerRuntime", () => { expect(getManualAttachButton(editorShell)).not.toBeNull(); }); + test("ignores nested decorative descendants when positioning contenteditable manual attach icon", () => { + const runtime = makeRuntime(); + const shell = document.createElement("div"); + const editorShell = document.createElement("div"); + const editable = document.createElement("div"); + const decorationLayer = document.createElement("div"); + const decorationIcon = document.createElement("span"); + const list = document.createElement("div"); + list.id = "editable-list"; + list.setAttribute("role", "listbox"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { + configurable: true, + value: true, + }); + editable.tabIndex = 0; + editable.setAttribute("role", "combobox"); + editable.setAttribute("aria-expanded", "true"); + editable.setAttribute("aria-controls", "editable-list"); + decorationLayer.appendChild(decorationIcon); + editorShell.append(editable, decorationLayer); + shell.appendChild(editorShell); + document.body.append(shell, list); + mockRect(shell, { left: 10, top: 20, width: 260, height: 52 }); + mockRect(editorShell, { left: 86, top: 24, width: 150, height: 40 }); + mockRect(editable, { left: 94, top: 30, width: 150, height: 28 }); + mockRect(decorationLayer, { left: 214, top: 26, width: 18, height: 28 }); + mockRect(decorationIcon, { left: 214, top: 30, width: 14, height: 14 }); + + runtime.queryAndAttachHelper(); + + const container = getManualAttachContainer(editorShell); + expect(container).not.toBeNull(); + expect(container?.style.left).toBe("132px"); + }); + test("ignores non-overlapping rows when resolving contenteditable manual attach obstacles", () => { const runtime = makeRuntime(); const shell = document.createElement("div"); From 73bd2d5130b45a7579cc3d699dc5884d2a3a159d Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Thu, 12 Mar 2026 17:12:47 +0100 Subject: [PATCH 2/3] Improve manual attach icon contrast on dark themes --- .../suggestions/ManualAttachUiManager.ts | 119 ++++++++++++++++-- tests/SuggestionManagerRuntime.test.ts | 36 ++++++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts index b7d2dda4..cc21de77 100644 --- a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts +++ b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts @@ -20,6 +20,14 @@ export const MANUAL_ATTACH_BUTTON_CLASS = "ft-manual-attach-button"; export const MANUAL_ATTACH_TOOLTIP = "Click to enable FluentTyper for this field."; export type ManualAttachTarget = HTMLInputElement | HTMLTextAreaElement | HTMLElement; +type ManualAttachSurfaceTone = "light" | "dark"; + +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} interface ParentPositionState { count: number; @@ -39,6 +47,7 @@ interface ManualAttachUiHandle { button: HTMLButtonElement; icon: HTMLImageElement; checkmark: HTMLSpanElement; + surfaceTone: ManualAttachSurfaceTone; originalPaddingInlineEnd: string; successTimer: ReturnType | null; successPending: boolean; @@ -64,6 +73,7 @@ export class ManualAttachUiManager { const existing = this.handles.get(element); if (existing) { this.updatePlacement(element, existing); + this.updateSurfaceTone(element, existing); if (!existing.successPending) { this.applyIdleState(existing); } @@ -148,6 +158,7 @@ export class ManualAttachUiManager { transition: "transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease", outline: "none", + backdropFilter: "blur(8px) saturate(1.12)", }); const icon = element.ownerDocument.createElement("img"); @@ -187,6 +198,7 @@ export class ManualAttachUiManager { button, icon, checkmark, + surfaceTone: this.resolveSurfaceTone(element), originalPaddingInlineEnd: this.getInlineEndPaddingStyleValue(element), successTimer: null, successPending: false, @@ -234,15 +246,20 @@ export class ManualAttachUiManager { } private applyIdleState(handle: ManualAttachUiHandle): void { + const isDarkSurface = handle.surfaceTone === "dark"; Object.assign(handle.button.style, { - backgroundColor: "rgba(255, 255, 255, 0.82)", - borderColor: "transparent", - boxShadow: "none", + backgroundColor: isDarkSurface ? "rgba(15, 23, 42, 0.92)" : "rgba(255, 255, 255, 0.9)", + borderColor: isDarkSurface ? "rgba(148, 163, 184, 0.34)" : "rgba(148, 163, 184, 0.2)", + boxShadow: isDarkSurface + ? "0 10px 18px -14px rgba(2, 6, 23, 0.96)" + : "0 8px 14px -14px rgba(15, 23, 42, 0.4)", transform: "scale(1)", }); Object.assign(handle.icon.style, { - opacity: "0.55", - filter: "grayscale(1) saturate(0) contrast(0.98)", + opacity: isDarkSurface ? "0.96" : "0.72", + filter: isDarkSurface + ? "drop-shadow(0 1px 2px rgba(2, 6, 23, 0.45))" + : "grayscale(0.24) saturate(0.78) contrast(1.02)", }); Object.assign(handle.checkmark.style, { opacity: "0", @@ -251,15 +268,18 @@ export class ManualAttachUiManager { } private applyHoverState(handle: ManualAttachUiHandle): void { + const isDarkSurface = handle.surfaceTone === "dark"; Object.assign(handle.button.style, { - backgroundColor: "rgba(255, 255, 255, 0.96)", - borderColor: "rgba(14, 165, 233, 0.24)", - boxShadow: "0 6px 14px -10px rgba(14, 165, 233, 0.9)", + backgroundColor: isDarkSurface ? "rgba(30, 41, 59, 0.98)" : "rgba(255, 255, 255, 0.98)", + borderColor: isDarkSurface ? "rgba(96, 165, 250, 0.48)" : "rgba(14, 165, 233, 0.32)", + boxShadow: isDarkSurface + ? "0 10px 20px -12px rgba(96, 165, 250, 0.62)" + : "0 8px 18px -12px rgba(14, 165, 233, 0.58)", transform: "scale(1.05)", }); Object.assign(handle.icon.style, { opacity: "1", - filter: "none", + filter: isDarkSurface ? "drop-shadow(0 1px 2px rgba(2, 6, 23, 0.42))" : "none", }); Object.assign(handle.checkmark.style, { opacity: "0", @@ -268,10 +288,13 @@ export class ManualAttachUiManager { } private applySuccessState(handle: ManualAttachUiHandle): void { + const isDarkSurface = handle.surfaceTone === "dark"; Object.assign(handle.button.style, { - backgroundColor: "rgba(236, 253, 245, 0.98)", - borderColor: "rgba(16, 185, 129, 0.32)", - boxShadow: "0 8px 18px -12px rgba(4, 120, 87, 0.95)", + backgroundColor: isDarkSurface ? "rgba(6, 78, 59, 0.94)" : "rgba(236, 253, 245, 0.98)", + borderColor: isDarkSurface ? "rgba(52, 211, 153, 0.48)" : "rgba(16, 185, 129, 0.32)", + boxShadow: isDarkSurface + ? "0 10px 20px -12px rgba(4, 120, 87, 0.92)" + : "0 8px 18px -12px rgba(4, 120, 87, 0.95)", transform: "scale(1.08)", }); Object.assign(handle.icon.style, { @@ -279,11 +302,16 @@ export class ManualAttachUiManager { filter: "none", }); Object.assign(handle.checkmark.style, { + color: isDarkSurface ? "#d1fae5" : "#047857", opacity: "1", transform: "scale(1)", }); } + private updateSurfaceTone(element: ManualAttachTarget, handle: ManualAttachUiHandle): void { + handle.surfaceTone = this.resolveSurfaceTone(element); + } + private updatePlacement(element: ManualAttachTarget, handle: ManualAttachUiHandle): void { const elementRect = element.getBoundingClientRect(); const isTextarea = element.tagName.toLowerCase() === "textarea"; @@ -410,6 +438,73 @@ export class ManualAttachUiManager { return candidate.querySelector(INLINE_OBSTACLE_SELECTOR) instanceof HTMLElement; } + private resolveSurfaceTone(element: ManualAttachTarget): ManualAttachSurfaceTone { + const colorSources = [element, ...this.collectAncestorElements(element)]; + for (const candidate of colorSources) { + const backgroundColor = + candidate.ownerDocument.defaultView?.getComputedStyle(candidate).backgroundColor; + const parsed = this.parseCssColor(backgroundColor); + if (parsed && parsed.a > 0.05) { + return this.relativeLuminance(parsed) < 0.36 ? "dark" : "light"; + } + } + return element.ownerDocument.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + + private collectAncestorElements(element: ManualAttachTarget): HTMLElement[] { + const ancestors: HTMLElement[] = []; + let current = element.parentElement; + while (current) { + ancestors.push(current); + current = current.parentElement; + } + const body = element.ownerDocument.body; + if (body && !ancestors.includes(body)) { + ancestors.push(body); + } + return ancestors; + } + + private parseCssColor(rawValue: string | undefined): RgbaColor | null { + if (!rawValue) { + return null; + } + const value = rawValue.trim().toLowerCase(); + const rgbMatch = value.match( + /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d*\.?\d+))?\s*\)$/, + ); + if (!rgbMatch) { + return null; + } + const r = Number.parseInt(rgbMatch[1], 10); + const g = Number.parseInt(rgbMatch[2], 10); + const b = Number.parseInt(rgbMatch[3], 10); + const a = rgbMatch[4] === undefined ? 1 : Number.parseFloat(rgbMatch[4]); + if ([r, g, b, a].some((part) => Number.isNaN(part))) { + return null; + } + return { + r: this.clampChannel(r), + g: this.clampChannel(g), + b: this.clampChannel(b), + a: Math.min(Math.max(a, 0), 1), + }; + } + + private relativeLuminance(color: RgbaColor): number { + const channels = [color.r, color.g, color.b].map((channel) => { + const normalized = this.clampChannel(channel) / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + private clampChannel(channel: number): number { + return Math.min(Math.max(Math.round(channel), 0), 255); + } + private resolveOffsetTop( height: number, options: { diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index e98db8fc..7c8f319b 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -1170,6 +1170,42 @@ describe("SuggestionManagerRuntime", () => { expect(container?.style.left).toBe("132px"); }); + test("uses a high-contrast dark surface treatment for manual attach icon", () => { + const runtime = makeRuntime(); + const shell = document.createElement("div"); + const editorShell = document.createElement("div"); + const editable = document.createElement("div"); + const list = document.createElement("div"); + list.id = "editable-list"; + list.setAttribute("role", "listbox"); + shell.style.backgroundColor = "rgb(29, 28, 29)"; + editorShell.style.backgroundColor = "rgb(29, 28, 29)"; + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { + configurable: true, + value: true, + }); + editable.tabIndex = 0; + editable.setAttribute("role", "combobox"); + editable.setAttribute("aria-expanded", "true"); + editable.setAttribute("aria-controls", "editable-list"); + editorShell.appendChild(editable); + shell.appendChild(editorShell); + document.body.append(shell, list); + mockRect(shell, { left: 10, top: 20, width: 260, height: 52 }); + mockRect(editorShell, { left: 86, top: 24, width: 150, height: 40 }); + mockRect(editable, { left: 94, top: 30, width: 150, height: 28 }); + + runtime.queryAndAttachHelper(); + + const button = getManualAttachButton(editorShell); + const icon = button?.querySelector("img"); + expect(button).not.toBeNull(); + expect(button?.style.backgroundColor).toBe("rgba(15, 23, 42, 0.92)"); + expect(button?.style.borderColor).toBe("rgba(148, 163, 184, 0.34)"); + expect(icon?.style.filter).not.toContain("grayscale"); + }); + test("ignores non-overlapping rows when resolving contenteditable manual attach obstacles", () => { const runtime = makeRuntime(); const shell = document.createElement("div"); From 9f1e81e2f2c15f67bc54f4f548e59d37a2159acc Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Thu, 12 Mar 2026 17:54:47 +0100 Subject: [PATCH 3/3] Handle shadow-hosted dark surfaces for manual attach icon --- .../suggestions/ManualAttachUiManager.ts | 20 ++++++++++++++++--- tests/SuggestionManagerRuntime.test.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts index cc21de77..2fd905fd 100644 --- a/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts +++ b/src/adapters/chrome/content-script/suggestions/ManualAttachUiManager.ts @@ -455,10 +455,24 @@ export class ManualAttachUiManager { private collectAncestorElements(element: ManualAttachTarget): HTMLElement[] { const ancestors: HTMLElement[] = []; - let current = element.parentElement; + let current: HTMLElement | null = element; while (current) { - ancestors.push(current); - current = current.parentElement; + const parentElement = current.parentElement; + if (this.isHtmlElement(parentElement, current.ownerDocument)) { + ancestors.push(parentElement); + current = parentElement; + continue; + } + const rootNode = current.getRootNode(); + if ( + this.isShadowRoot(rootNode, current.ownerDocument) && + this.isHtmlElement(rootNode.host, current.ownerDocument) + ) { + ancestors.push(rootNode.host); + current = rootNode.host; + continue; + } + current = null; } const body = element.ownerDocument.body; if (body && !ancestors.includes(body)) { diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 7c8f319b..85973fac 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -1610,6 +1610,26 @@ describe("SuggestionManagerRuntime", () => { expect(getManualAttachButton(host)).toBeNull(); }); + test("uses dark surface styling for a shadow-hosted conflicting field on a dark host", () => { + const runtime = makeRuntime(); + const host = document.createElement("div"); + host.style.backgroundColor = "rgb(29, 28, 29)"; + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: "open" }); + const list = document.createElement("datalist"); + list.id = "cities"; + const shadowInput = document.createElement("input"); + shadowInput.type = "text"; + shadowInput.setAttribute("list", "cities"); + shadow.append(list, shadowInput); + + runtime.queryAndAttachHelper(); + + const button = getManualAttachButton(shadow); + expect(button).not.toBeNull(); + expect(button?.style.backgroundColor).toBe("rgba(15, 23, 42, 0.92)"); + }); + test("removes the manual attach icon when a shadow-hosted conflicting field is removed", () => { const runtime = makeRuntime(); const host = document.createElement("div");