# 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: ```ts 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: fixed` or `position: sticky` (sticky login headers) → adding `position: relative` removes the sticky behavior and the icon disappears with the sticky element - Parent is a shadow host or a custom element — `parentElement` returns 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`. ```ts 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. ```ts // 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` to `WeakMap 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. ```ts // Replace: const processedInputs = new WeakSet(); // With: const processedInputs = new WeakMap void>(); ``` In `reattachListeners`: ```ts 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: ```ts 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. ```ts // 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: ```ts 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 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