peach_icon_injection_spec.md

10.1 KB · raw

rendered · source

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  // Position the icon at the right edge of the field, vertically centered.62  function updatePosition() {63    const rect = field.getBoundingClientRect();64    if (rect.width === 0 || rect.height === 0) {65      // Field collapsed or off-screen — hide icon66      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  // Fade in after first position is set77  updatePosition();78  requestAnimationFrame(() => { svgEl.style.opacity = "1"; });7980  let rafId = requestAnimationFrame(function loop() {81    updatePosition();82    rafId = requestAnimationFrame(loop);83  });8485  // Add padding so the icon doesn't overlap text the user is typing86  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```ts111// At the bottom of index.ts, after setupMutationObserver():112113// Initial scan — runs once at idle, then retries for SPAs that hydrate late114function 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}122123// Run at 0ms, 500ms, 1500ms — catches static pages, React hydration, lazy-loaded widgets124setTimeout(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```ts138// Replace:139const processedInputs = new WeakSet<HTMLInputElement>();140141// With:142const processedInputs = new WeakMap<HTMLInputElement, () => void>();143```144145In `reattachListeners`:146```ts147function reattachListeners(el: HTMLInputElement): void {148  // If already processed, check if the icon is still attached149  if (processedInputs.has(el)) {150    // Nothing to do — the rAF loop in injectFieldIcon handles repositioning151    return;152  }153154  // ... rest of injection155  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); // increased from 120ms169```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```ts184// In onFocusIn, split the logic:185186// 1. Show icon for any password field, regardless of form shape187if (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}194195// 2. Only show the selector popup if it looks like a login/signup form AND there are matching entries196if (!isLoginForm(fields) && !isSignupForm(fields)) return;197// ... rest of selector logic198```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