peach_icon_injection_spec.md
Peach Icon Injection — Reliability Fix Spec
Peach v1.4.0 target
Diagnosis
After reading src/content/inject.ts, src/content/detect.ts, and src/content/index.ts in full, there are five distinct failure modes that together explain why the icon misses on some sites.
Failure 1 — Icon is positioned relative to the parent element (the main culprit)
injectFieldIcon does this:
const parent = field.parentElement as HTMLElement;
parent.style.position = "relative";
parent.appendChild(svgEl);
The SVG then uses position: absolute; right: 6px; top: 50%. This works when the parent is a simple wrapper that visually hugs the input. It breaks in all of these common cases:
- Parent has
overflow: hidden→ icon is clipped - Parent is a flex row or grid cell whose bounds don't match the visible input → icon appears in the wrong position or outside the field
- Parent already has
position: fixedorposition: sticky(sticky login headers) → addingposition: relativeremoves the sticky behavior and the icon disappears with the sticky element - Parent is a shadow host or a custom element —
parentElementreturns null or an element the code can't safely mutate - Site JS watches for mutations on the parent and removes unknown children (common in React-rendered forms), causing the icon to vanish shortly after injection
The fix: use position: fixed anchored to getBoundingClientRect() and track the field position with requestAnimationFrame, exactly like showAutofillSelector already does. The icon becomes a direct child of document.body and is completely immune to parent layout and overflow: hidden.
export function injectFieldIcon(
field: HTMLInputElement,
onClick: () => void
): () => void {
const icon = document.createElement("div");
icon.innerHTML = peachIconSvg();
const svgEl = icon.firstElementChild as HTMLElement;
svgEl.style.cssText = `
position: fixed;
z-index: 2147483646;
width: 18px;
height: 18px;
cursor: pointer;
pointer-events: auto;
border-radius: 50%;
transition: opacity 120ms ease;
opacity: 0;
`;
svgEl.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
onClick();
});
document.body.appendChild(svgEl);
// Position the icon at the right edge of the field, vertically centered.
function updatePosition() {
const rect = field.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
// Field collapsed or off-screen — hide icon
svgEl.style.opacity = "0";
svgEl.style.pointerEvents = "none";
} else {
svgEl.style.top = `${rect.top + rect.height / 2 - 9}px`;
svgEl.style.left = `${rect.right - 26}px`;
svgEl.style.opacity = "1";
svgEl.style.pointerEvents = "auto";
}
}
// Fade in after first position is set
updatePosition();
requestAnimationFrame(() => { svgEl.style.opacity = "1"; });
let rafId = requestAnimationFrame(function loop() {
updatePosition();
rafId = requestAnimationFrame(loop);
});
// Add padding so the icon doesn't overlap text the user is typing
const originalPaddingRight = field.style.paddingRight;
const currentPad = parseInt(getComputedStyle(field).paddingRight) || 0;
field.style.paddingRight = `${currentPad + 26}px`;
return () => {
cancelAnimationFrame(rafId);
svgEl.remove();
field.style.paddingRight = originalPaddingRight;
};
}
The requestAnimationFrame loop is cheap — it only reads getBoundingClientRect() and sets two style properties. showAutofillSelector already runs the same pattern continuously with no performance issues.
Failure 2 — No initial scan at startup
The content script sets up focusin listening and MutationObserver but never scans the existing DOM. If the page renders its login form before document_idle fires (most static sites, server-rendered pages) and the DOM doesn't change again, the MutationObserver never fires and the icon only appears on the user's first focus.
This is acceptable for the selector popup (it triggers on focus anyway) but means the icon never appears until after the first interaction, which makes the feature feel broken — users see the popup on focus but there's no icon hinting that Peach is available before they touch the field.
The fix: run an initial scan after a short delay to allow SPA hydration, and again at progressively longer intervals for slow-loading SPAs.
// At the bottom of index.ts, after setupMutationObserver():
// Initial scan — runs once at idle, then retries for SPAs that hydrate late
function initialScan() {
const newFields = detectLoginFields().filter(
(f) => f.type === "password" && !processedInputs.has(f.el)
);
for (const f of newFields) {
reattachListeners(f.el);
}
}
// Run at 0ms, 500ms, 1500ms — catches static pages, React hydration, lazy-loaded widgets
setTimeout(initialScan, 0);
setTimeout(initialScan, 500);
setTimeout(initialScan, 1500);
Failure 3 — processedInputs WeakSet marks a field as done even if the icon was removed
Once processedInputs.add(passwordEl) fires, that field is never re-processed — even if site JS removes the icon from the DOM. On React/Vue sites that re-render form components on validation errors or transitions, the icon silently disappears and never comes back.
The fix: switch from WeakSet<HTMLInputElement> to WeakMap<HTMLInputElement, () => void> that stores the cleanup function. On each debouncedReattach pass, check whether the stored cleanup element is still in the DOM — if not, run cleanup and re-inject.
// Replace:
const processedInputs = new WeakSet<HTMLInputElement>();
// With:
const processedInputs = new WeakMap<HTMLInputElement, () => void>();
In reattachListeners:
function reattachListeners(el: HTMLInputElement): void {
// If already processed, check if the icon is still attached
if (processedInputs.has(el)) {
// Nothing to do — the rAF loop in injectFieldIcon handles repositioning
return;
}
// ... rest of injection
const cleanup = injectFieldIcon(el, () => { ... });
processedInputs.set(el, cleanup);
}
In debouncedReattach, before running reattachListeners, check for stale entries:
const debouncedReattach = debounce(() => {
const fields = detectLoginFields();
for (const f of fields) {
if (f.type !== "password") continue;
reattachListeners(f.el);
}
}, 200); // increased from 120ms
And in onFocusIn, replace processedInputs.has(passwordEl) / processedInputs.add(passwordEl) with the WeakMap equivalent.
Failure 4 — isLoginForm gate is too strict for multi-step flows
onFocusIn returns early if !isLoginForm(fields) && !isSignupForm(fields). Many modern sites show password-only forms (step 2 of a username-first flow), or email-only forms (magic link). These never pass isLoginForm because there's no username+password pair on screen simultaneously, so the icon never appears.
isLoginForm requires either (username + password) or (single password with autocomplete="current-password"). The first step of a username→password split flow never satisfies this.
The fix: for the icon injection specifically, relax to: any visible input[type="password"] gets the icon. The isLoginForm check can still gate the autofill selector popup (which requires knowing which entries to show), but the icon itself should appear on any password field the user focuses.
// In onFocusIn, split the logic:
// 1. Show icon for any password field, regardless of form shape
if (fieldType === "password" && !processedInputs.has(field)) {
const cleanup = injectFieldIcon(field, () => {
currentDismiss?.();
onFocusIn({ target: field } as unknown as Event);
});
processedInputs.set(field, cleanup);
}
// 2. Only show the selector popup if it looks like a login/signup form AND there are matching entries
if (!isLoginForm(fields) && !isSignupForm(fields)) return;
// ... rest of selector logic
Failure 5 — ML prediction gates username field injection, 150ms timeout is often hit
The ML model is loaded lazily and the first prediction takes longer than 150ms on a cold start (ONNX WASM instantiation). The code resolves null after timeout, then checks:
if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {
return;
}
When mlPrediction is null (timeout), this condition is false (because mlPrediction && ... is false), so the code proceeds. That's actually fine. But the real problem is that on the username step of a split login (no password field visible), the code hits the if (!pwField) return; guard shortly after and bails. The icon injection for step-1 (username-only) fields never happens.
The fix: in the relaxed model from Failure 4 above, inject the icon for the focused field as soon as it looks like a credential field (password, or ML-predicted username/email) — before checking for a complete login form. The form completeness check only gates the selector popup.
Summary of changes
| File | Change |
|---|---|
src/content/inject.ts |
injectFieldIcon rewritten to use position: fixed + getBoundingClientRect() + requestAnimationFrame position loop; returns cleanup function that cancels the rAF loop and removes the SVG from body |
src/content/index.ts |
processedInputs → WeakMap<HTMLInputElement, () => void>; add initialScan() with 0/500/1500ms retries; relax icon injection to any password field (decouple from isLoginForm check); debounce raised from 120ms to 200ms; reattachListeners updated for WeakMap |
No changes to detect.ts, fill.ts, save.ts, or any background files.
What this does NOT change
- The autofill selector popup logic is unchanged — it still only appears when there are matching vault entries and the form passes the login/signup detection
- The canary ping on autofill is unchanged
- The TOTP autofill is unchanged
- The identity helper for signup forms is unchanged
- The ML detector is unchanged — it still gates the username field selector popup, just not the icon itself