peach_icon_injection_spec.md

10.1 KB · raw

rendered · source

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:

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