1# Peach Icon Injection — Reliability Fix Spec2_Peach v1.4.0 target_34---56## Diagnosis78After 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.910---1112### Failure 1 — Icon is positioned relative to the parent element (the main culprit)1314`injectFieldIcon` does this:1516```ts17const parent = field.parentElement as HTMLElement;18parent.style.position = "relative";19parent.appendChild(svgEl);20```2122The 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:2324- Parent has `overflow: hidden` → icon is clipped25- 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 field26- 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 element27- Parent is a shadow host or a custom element — `parentElement` returns null or an element the code can't safely mutate28- Site JS watches for mutations on the parent and removes unknown children (common in React-rendered forms), causing the icon to vanish shortly after injection2930**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`.3132```ts33export function injectFieldIcon(34 field: HTMLInputElement,35 onClick: () => void36): () => void {37 const icon = document.createElement("div");38 icon.innerHTML = peachIconSvg();39 const svgEl = icon.firstElementChild as HTMLElement;4041 svgEl.style.cssText = `42 position: fixed;43 z-index: 2147483646;44 width: 18px;45 height: 18px;46 cursor: pointer;47 pointer-events: auto;48 border-radius: 50%;49 transition: opacity 120ms ease;50 opacity: 0;51 `;5253 svgEl.addEventListener("click", (e) => {54 e.stopPropagation();55 e.preventDefault();56 onClick();57 });5859 document.body.appendChild(svgEl);6061 62 function updatePosition() {63 const rect = field.getBoundingClientRect();64 if (rect.width === 0 || rect.height === 0) {65 66 svgEl.style.opacity = "0";67 svgEl.style.pointerEvents = "none";68 } else {69 svgEl.style.top = `${rect.top + rect.height / 2 - 9}px`;70 svgEl.style.left = `${rect.right - 26}px`;71 svgEl.style.opacity = "1";72 svgEl.style.pointerEvents = "auto";73 }74 }7576 77 updatePosition();78 requestAnimationFrame(() => { svgEl.style.opacity = "1"; });7980 let rafId = requestAnimationFrame(function loop() {81 updatePosition();82 rafId = requestAnimationFrame(loop);83 });8485 86 const originalPaddingRight = field.style.paddingRight;87 const currentPad = parseInt(getComputedStyle(field).paddingRight) || 0;88 field.style.paddingRight = `${currentPad + 26}px`;8990 return () => {91 cancelAnimationFrame(rafId);92 svgEl.remove();93 field.style.paddingRight = originalPaddingRight;94 };95}96```9798The `requestAnimationFrame` loop is cheap — it only reads `getBoundingClientRect()` and sets two style properties. `showAutofillSelector` already runs the same pattern continuously with no performance issues.99100---101102### Failure 2 — No initial scan at startup103104The 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.105106This 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.107108**The fix:** run an initial scan after a short delay to allow SPA hydration, and again at progressively longer intervals for slow-loading SPAs.109110```ts111112113114function initialScan() {115 const newFields = detectLoginFields().filter(116 (f) => f.type === "password" && !processedInputs.has(f.el)117 );118 for (const f of newFields) {119 reattachListeners(f.el);120 }121}122123124setTimeout(initialScan, 0);125setTimeout(initialScan, 500);126setTimeout(initialScan, 1500);127```128129---130131### Failure 3 — `processedInputs` WeakSet marks a field as done even if the icon was removed132133Once `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.134135**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.136137```ts138139const processedInputs = new WeakSet<HTMLInputElement>();140141142const processedInputs = new WeakMap<HTMLInputElement, () => void>();143```144145In `reattachListeners`:146```ts147function reattachListeners(el: HTMLInputElement): void {148 149 if (processedInputs.has(el)) {150 151 return;152 }153154 155 const cleanup = injectFieldIcon(el, () => { ... });156 processedInputs.set(el, cleanup);157}158```159160In `debouncedReattach`, before running `reattachListeners`, check for stale entries:161```ts162const debouncedReattach = debounce(() => {163 const fields = detectLoginFields();164 for (const f of fields) {165 if (f.type !== "password") continue;166 reattachListeners(f.el);167 }168}, 200); 169```170171And in `onFocusIn`, replace `processedInputs.has(passwordEl)` / `processedInputs.add(passwordEl)` with the WeakMap equivalent.172173---174175### Failure 4 — `isLoginForm` gate is too strict for multi-step flows176177`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.178179`isLoginForm` requires either (username + password) or (single password with `autocomplete="current-password"`). The first step of a username→password split flow never satisfies this.180181**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.182183```ts184185186187if (fieldType === "password" && !processedInputs.has(field)) {188 const cleanup = injectFieldIcon(field, () => {189 currentDismiss?.();190 onFocusIn({ target: field } as unknown as Event);191 });192 processedInputs.set(field, cleanup);193}194195196if (!isLoginForm(fields) && !isSignupForm(fields)) return;197198```199200---201202### Failure 5 — ML prediction gates username field injection, 150ms timeout is often hit203204The 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:205206```ts207if (fieldType !== "password" && mlPrediction && mlPrediction.type !== "username" && mlPrediction.type !== "email") {208 return;209}210```211212When `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.213214**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.215216---217218## Summary of changes219220| File | Change |221|------|--------|222| `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 |223| `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 |224225**No changes to `detect.ts`, `fill.ts`, `save.ts`, or any background files.**226227---228229## What this does NOT change230231- The autofill selector popup logic is unchanged — it still only appears when there are matching vault entries and the form passes the login/signup detection232- The canary ping on autofill is unchanged233- The TOTP autofill is unchanged234- The identity helper for signup forms is unchanged235- The ML detector is unchanged — it still gates the username field selector popup, just not the icon itself