inject.ts

53.4 KB · raw

1/**2 * inject.ts — autofill icon + selector overlay, injected into pages.3 *4 * Runs as a content script. Injects the Peach icon next to detected login5 * fields, anchors it via event-driven positioning (no perpetual rAF — see6 * Projects.md §4.7), and renders the inline autofill selector in a shadow7 * DOM. Never holds decrypted vault data directly: all entry lookups go8 * through `chrome.runtime.sendMessage`, and only the minimum fields needed9 * to fill come back across the boundary.10 */1112import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";13import { generateSmartPassword } from "../shared/generate";14import { detectPasswordPolicy } from "./policy";1516let accentHex = "#E8956D";17let popupTheme: "dark" | "light" = "dark";18chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string; theme?: string } } | undefined) => {19  const data = resp?.data;20  const name = data?.accentColor ?? "peach";21  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";22  popupTheme = data?.theme === "light" ? "light" : "dark";23});2425// 8-digit hex alpha helpers for the accent-derived shadow tokens.26// `#E8956D` + alpha `28` → `#E8956D28`. The browser parses the trailing27// two hex digits as the alpha channel.28function withAlpha(hex: string, alpha: string): string {29  // Defensive: only 6-digit hex inputs are supported here. ACCENT_COLORS30  // always exposes 6-digit hex, so this is a guard against future changes31  // shipping shorthand colors that would silently break the var injection.32  return /^#[0-9a-f]{6}$/i.test(hex) ? hex + alpha : hex;33}3435export interface SelectorEntry {36  id: string;37  title: string;38  username?: string;39  url?: string;40  hasTotp?: boolean;41}4243interface SelectorOptions {44  domain?: string;45  searchableEntries?: SelectorEntry[];46  /** True when shown on a signup form — changes header label and entry-click behaviour */47  isSignup?: boolean;48  /**49   * Called after the user picks a saved identity (signup mode only) or50   * after successfully saving a new entry on a signup form.51   * Lets the caller fill the visible username + password fields.52   */53  onFillFields?: (username: string, password: string) => void;54  /**55   * When true, show an inline unlock form instead of the entries list.56   * Used when the vault is locked so the user can unlock without opening57   * the popup.58   */59  locked?: boolean;60  /**61   * Called after a successful inline unlock — the caller should re-query62   * entries and re-open the selector with real data.63   */64  onUnlocked?: () => void;65  /**66   * Called when the user explicitly dismisses the selector (Escape, Tab, or67   * click outside) — NOT when an entry is selected or on auto-close.68   * Used by the caller to suppress spurious reopens on the same field.69   */70  onDismiss?: () => void;71}7273function getDomainFromUrl(url: string): string {74  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }75}7677function keySvgStr(): string {78  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="rgba(240,232,216,0.35)" stroke-width="0.95" stroke-linecap="round"><rect x="2" y="5.5" width="8" height="6" rx="1.5"/><path d="M4 5.5V4.25A2 2 0 0 1 6 2.25A2 2 0 0 1 8 4.25V5.5"/></svg>`;79}8081function plusSvgStr(): string {82  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1.5v9M1.5 6h9"/></svg>`;83}8485function peachIconSvg(): string {86  // Peach body is always brand-peach (#D96830), not `accentHex` — a sage87  // or sky accent must not turn the autofill icon green or blue. Accent88  // color still tints surrounding glow/hover halos below.89  return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 64 64" fill="none" aria-hidden="true"><path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/><path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/></svg>`;90}9192function genSvgStr(): string {93  return `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"><path d="M9.5 2A4.5 4.5 0 1 0 10.5 5.5"/><path d="M8.5 2H9.5V1"/></svg>`;94}9596function checkSvgStr(): string {97  return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"><path d="M4 10.5L8 14.5L16 6.5"/></svg>`;98}99100function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {101  const { hostname, origin } = location;102  const parts = hostname.split(".");103  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",104    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",105    "oidc", "federation", "saml", "cas", "ldap"];106  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());107  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;108  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);109  return { origin, title, loginSubdomain };110}111112/**113 * Shows a bottom-of-page toast after filling credentials when the entry has114 * never matched the current site — offering to add the current origin to the115 * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s.116 */117export function showRememberSiteToast(118  pageDomain: string,119  entryId: string,120): void {121  const host = document.createElement("div");122  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";123  host.style.setProperty("--peach-accent", accentHex);124  const shadow = host.attachShadow({ mode: "closed" });125126  const style = document.createElement("style");127  style.textContent = `128    .bar {129      display:flex;align-items:center;gap:10px;padding:10px 14px;130      background:#131109;border:1px solid rgba(240,232,216,0.09);131      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);132      font-family:Inter,sans-serif;white-space:nowrap;133      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;134    }135    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }136    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }137    .yes {138      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;139      background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;140    }141    .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }142    .no {143      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;144      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;145    }146  `;147  shadow.appendChild(style);148149  const bar = document.createElement("div"); bar.className = "bar";150  const txt = document.createElement("span"); txt.className = "txt";151  txt.textContent = `Remember ${pageDomain} for this entry?`;152  const yesBtn = document.createElement("button"); yesBtn.className = "yes";153  yesBtn.textContent = "Remember";154  yesBtn.addEventListener("click", async () => {155    await chrome.runtime.sendMessage({156      type: "ADD_ADDITIONAL_URL",157      payload: { id: entryId, url: location.origin },158    });159    host.remove();160  });161  const noBtn = document.createElement("button"); noBtn.className = "no";162  noBtn.textContent = "Not now";163  noBtn.addEventListener("click", () => host.remove());164  bar.append(txt, yesBtn, noBtn);165  shadow.appendChild(bar);166  document.body.appendChild(host);167  setTimeout(() => host.remove(), 9000);168}169170function generatePasswordForSite(policy: PasswordPolicy = {}): string {171  return generateSmartPassword(policy);172}173174export function observeAnchorPosition(175  anchorEl: HTMLElement,176  onUpdate: () => void177): () => void {178  let rafId = 0;179  let burstUntil = 0;180181  const runUpdate = () => {182    rafId = 0;183    onUpdate();184    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);185  };186  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };187  const scheduleBurst = (ms = 220) => {188    burstUntil = Math.max(burstUntil, performance.now() + ms);189    scheduleUpdate();190  };191192  const resizeObserver = new ResizeObserver(() => scheduleBurst());193  resizeObserver.observe(anchorEl);194  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);195196  const mutationObserver = new MutationObserver(() => scheduleBurst());197  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });198  if (anchorEl.parentElement) {199    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });200  }201202  const onScroll = () => scheduleUpdate();203  window.addEventListener("scroll", onScroll, true);204  window.addEventListener("resize", onScroll);205  window.visualViewport?.addEventListener("resize", onScroll);206  window.visualViewport?.addEventListener("scroll", onScroll);207  anchorEl.addEventListener("focus", () => scheduleBurst());208  anchorEl.addEventListener("input", () => scheduleBurst());209210  scheduleBurst(260);211212  return () => {213    if (rafId) cancelAnimationFrame(rafId);214    resizeObserver.disconnect();215    mutationObserver.disconnect();216    window.removeEventListener("scroll", onScroll, true);217    window.removeEventListener("resize", onScroll);218    window.visualViewport?.removeEventListener("resize", onScroll);219    window.visualViewport?.removeEventListener("scroll", onScroll);220  };221}222223export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {224  const button = document.createElement("button");225  button.type = "button";226  button.setAttribute("aria-label", "Peach autofill");227  button.title = "Fill with Peach";228  button.innerHTML = peachIconSvg();229  // position: fixed is load-bearing — not absolute. Absolute positioning230  // inherits the nearest positioned ancestor's box, which on sites with231  // overflow:hidden or sticky parents clips or offsets the icon. Fixed232  // anchors to the viewport and escapes all parent layout.233  button.style.cssText = `234    position: fixed; z-index: 2147483646;235    width: 26px; height: 26px; cursor: pointer;236    pointer-events: auto; border: none; padding: 3px;237    display: inline-flex; align-items: center; justify-content: center;238    background: transparent; border-radius: 999px;239    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;240    opacity: 0;241  `;242  button.addEventListener("mouseenter", () => {243    button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;244    button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;245    button.style.transform = "scale(1.08)";246  });247  button.addEventListener("mouseleave", () => {248    button.style.background = "transparent";249    button.style.boxShadow = "none";250    button.style.transform = "none";251  });252  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });253  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });254255  document.body.appendChild(button);256257  function updatePosition() {258    const rect = field.getBoundingClientRect();259    if (rect.width === 0 || rect.height === 0) {260      button.style.opacity = "0"; button.style.pointerEvents = "none";261      return;262    }263    const sz = 26;264    const inset = Math.max(5, Math.min(10, rect.height * 0.22));265    const top = rect.top + rect.height / 2 - sz / 2;266    // Clamp left so the icon stays fully inside the field's right edge, never267    // spilling onto an adjacent sibling button.268    const minLeft = rect.left + 4;269    const left = Math.max(minLeft, rect.right - sz - inset);270    // Hide if the field is too narrow to fit the icon cleanly (< 40px)271    if (rect.width < 40) {272      button.style.opacity = "0"; button.style.pointerEvents = "none";273      return;274    }275    button.style.top = `${top}px`;276    button.style.left = `${left}px`;277    button.style.opacity = "1"; button.style.pointerEvents = "auto";278  }279  updatePosition();280  requestAnimationFrame(() => { button.style.opacity = "1"; });281  const stopObserving = observeAnchorPosition(field, updatePosition);282  return () => { stopObserving(); button.remove(); };283}284285// All theme-dependent values resolve through tokens scoped to286// .peach-inline-popup[data-theme="dark|light"]. Accent variables287// (--peach-accent, --peach-accent-dim, --peach-glow, --peach-border-accent)288// are identical in both themes — only surface tokens differ. See289// Projects.md §4.14: hardcoded hex inside this stylesheet breaks the290// theme switch — only literal brand-peach in peachIconSvg may stay raw.291const SELECTOR_STYLES = `292  :host { all: initial; }293  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }294295  .peach-inline-popup[data-theme="dark"] {296    --peach-bg:           #1a1410;297    --peach-bg-card:      #221c16;298    --peach-text:         #f5f0ea;299    --peach-text-dim:     #8a7a6a;300    --peach-text-muted:   #4a3a2a;301    --peach-border:       rgba(255,255,255,0.07);302    --peach-input-bg:     rgba(255,255,255,0.05);303    --peach-input-border: rgba(255,255,255,0.08);304  }305  .peach-inline-popup[data-theme="light"] {306    --peach-bg:           #fdf8f4;307    --peach-bg-card:      #f5ede4;308    --peach-text:         #1c1310;309    --peach-text-dim:     #6b5a4a;310    --peach-text-muted:   #a89080;311    --peach-border:       rgba(0,0,0,0.08);312    --peach-input-bg:     rgba(0,0,0,0.04);313    --peach-input-border: rgba(0,0,0,0.10);314  }315316  .peach-inline-popup {317    background: var(--peach-bg);318    border: 1px solid var(--peach-border-accent, var(--peach-border));319    border-radius: 14px;320    overflow: hidden;321    box-shadow:322      0 8px 40px var(--peach-glow, transparent),323      0 2px 12px rgba(0,0,0,0.5);324    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;325    animation: peach-enter 200ms cubic-bezier(0.16,1,0.3,1) forwards;326  }327  /* Light theme: shadow against a light page is too strong otherwise — drop both328     the accent glow and the dark drop-shadow opacity to keep the popup elegant. */329  .peach-inline-popup[data-theme="light"] {330    box-shadow:331      0 8px 40px color-mix(in srgb, var(--peach-accent) 10%, transparent),332      0 2px 12px rgba(0,0,0,0.12);333  }334  .peach-inline-popup.is-exiting {335    animation: peach-exit 130ms cubic-bezier(0.4,0,1,1) forwards;336    pointer-events: none;337  }338339  @keyframes peach-enter {340    0%   { opacity: 0; transform: translateY(-8px) scale(0.96); filter: blur(3px); }341    65%  { opacity: 1; filter: blur(0); }342    100% { opacity: 1; transform: translateY(0) scale(1); }343  }344  @keyframes peach-exit {345    0%   { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }346    100% { opacity: 0; transform: translateY(-4px) scale(0.98); filter: blur(2px); }347  }348  @keyframes peach-slide-in {349    from { opacity: 0; transform: translateX(12px); }350    to   { opacity: 1; transform: translateX(0); }351  }352  @keyframes peach-slide-back {353    from { opacity: 0; transform: translateX(-12px); }354    to   { opacity: 1; transform: translateX(0); }355  }356  .peach-content[data-dir="forward"] { animation: peach-slide-in 150ms cubic-bezier(0.16,1,0.3,1) both; }357  .peach-content[data-dir="back"]    { animation: peach-slide-back 150ms cubic-bezier(0.16,1,0.3,1) both; }358359  /* Domain header */360  .domain-header {361    padding: 9px 13px 7px;362    border-bottom: 1px solid var(--peach-border);363    display: flex; align-items: center; justify-content: space-between;364  }365  .domain-eyebrow {366    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;367    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;368  }369  .domain-name {370    font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);371    font-family: "IBM Plex Mono", monospace;372  }373374  /* Search */375  .search-wrap {376    padding: 9px 12px 7px;377    border-bottom: 1px solid var(--peach-border);378  }379  .search {380    width: 100%; background: var(--peach-input-bg);381    border: 1px solid var(--peach-input-border);382    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;383    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;384    transition: border-color 120ms, box-shadow 120ms;385  }386  .search:focus {387    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);388    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);389  }390  .search::placeholder { color: var(--peach-text-muted); }391392  /* Results */393  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }394  .results::-webkit-scrollbar { display: none; }395396  /* Entry rows */397  .entry {398    display: flex; align-items: center; padding: 8px 12px;399    cursor: pointer; border-bottom: 1px solid var(--peach-border);400    transition: background 70ms; position: relative; outline: none;401  }402  .entry:last-child { border-bottom: none; }403  .entry:hover { background: color-mix(in srgb, var(--peach-text) 4%, transparent); }404  .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }405  .entry.focused::before {406    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;407    width: 2px; border-radius: 2px;408    background: color-mix(in srgb, var(--peach-accent) 55%, transparent);409  }410411  .entry-icon {412    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;413    background: var(--peach-input-bg); border: 1px solid var(--peach-border);414    display: flex; align-items: center; justify-content: center;415    overflow: hidden; margin-right: 10px;416  }417  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }418  .entry-icon.is-new {419    background: color-mix(in srgb, var(--peach-accent) 8%, transparent);420    border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);421    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);422  }423424  .entry-info { flex: 1; min-width: 0; }425  .entry-title {426    font-size: 12.5px; color: var(--peach-text);427    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;428    line-height: 1.3;429  }430  .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }431  .entry-sub {432    font-size: 10px; color: var(--peach-text-dim);433    font-family: "IBM Plex Mono", monospace;434    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;435    margin-top: 1px;436  }437438  .totp-badge {439    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;440    border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);441    color: color-mix(in srgb, var(--peach-accent) 75%, transparent);442    font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 8px;443  }444445  .divider { height: 1px; background: var(--peach-border); margin: 2px 0; }446447  .empty-hint {448    padding: 14px 13px; font-size: 11px; color: var(--peach-text-dim);449    font-family: "IBM Plex Mono", monospace;450  }451452  /* Create form */453  .create-form-wrap {454    animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both;455  }456  .create-head {457    display: flex; align-items: center; gap: 8px;458    padding: 10px 12px; border-bottom: 1px solid var(--peach-border);459  }460  .back-btn {461    background: transparent; border: none; cursor: pointer;462    color: var(--peach-text-dim); padding: 3px; border-radius: 5px;463    display: flex; align-items: center; line-height: 0;464    transition: color 100ms, background 100ms;465  }466  .back-btn:hover { color: var(--peach-text); background: var(--peach-input-bg); }467  .create-label { font-size: 12px; color: var(--peach-text); font-weight: 500; flex: 1; }468  .create-body { padding: 12px; display: grid; gap: 9px; }469  .f-group { display: grid; gap: 3px; }470  .f-label {471    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;472    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;473  }474  .f-input {475    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);476    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;477    font-size: 11px; font-family: "IBM Plex Mono", monospace;478    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;479  }480  .f-input:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); }481  .f-input::placeholder { color: var(--peach-text-muted); }482  .f-input.url-f { font-size: 10px; color: var(--peach-text-dim); letter-spacing: 0.01em; }483  .f-row { display: flex; gap: 6px; }484  .f-row .f-input { flex: 1; min-width: 0; }485  .gen-btn {486    padding: 0 10px; border-radius: 7px; border: none;487    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);488    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);489    font-size: 10px; font-family: "IBM Plex Mono", monospace;490    cursor: pointer; white-space: nowrap; flex-shrink: 0;491    display: flex; align-items: center; gap: 4px;492    transition: background 100ms;493  }494  .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }495  .save-btn {496    width: 100%; padding: 8px; border-radius: 8px; border: none;497    background: var(--peach-accent); color: #fff; font-size: 12px;498    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;499    transition: background 120ms, box-shadow 120ms, transform 80ms;500    letter-spacing: 0.01em;501  }502  .save-btn:hover {503    background: color-mix(in srgb, var(--peach-accent) 82%, #000);504    box-shadow: 0 4px 20px var(--peach-glow, transparent);505  }506  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }507  .url-hint {508    font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);509    font-family: "IBM Plex Mono", monospace; font-style: italic;510  }511512  /* Success */513  .success-wrap {514    display: flex; flex-direction: column; align-items: center;515    padding: 24px 16px 20px; gap: 8px;516    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;517  }518  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }519  .success-ring {520    width: 40px; height: 40px; border-radius: 50%;521    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);522    display: flex; align-items: center; justify-content: center;523  }524  .success-text { font-size: 12.5px; color: var(--peach-text); font-weight: 500; }525  .success-sub { font-size: 10px; color: var(--peach-text-dim); font-family:"IBM Plex Mono",monospace; }526527  /* Silver platter (State 1 — single match) */528  .silver-platter {529    padding: 14px 14px 12px;530    display: flex; flex-direction: column; gap: 6px;531  }532  .sp-title {533    font-size: 14px; font-weight: 500; color: var(--peach-text);534    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;535    line-height: 1.25;536  }537  .sp-username {538    font-size: 12px; color: var(--peach-text-dim);539    font-family: "IBM Plex Mono", monospace;540    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;541    margin-bottom: 6px;542  }543  .sp-fill-btn {544    width: 100%; padding: 11px; border-radius: 9px; border: none;545    background: var(--peach-accent); color: #fff;546    font-size: 13px; font-weight: 500; cursor: pointer;547    font-family: Inter, sans-serif; letter-spacing: 0.01em;548    display: flex; align-items: center; justify-content: center; min-height: 38px;549    transition: filter 120ms ease, transform 80ms ease, box-shadow 120ms ease, background 120ms ease;550  }551  .sp-fill-btn:hover {552    filter: brightness(1.1);553    box-shadow: 0 4px 20px var(--peach-glow, transparent);554    transform: translateY(-1px);555  }556  .sp-fill-btn:active { transform: translateY(0); filter: brightness(0.95); }557  .sp-fill-btn.filled {558    background: #16a34a; transform: none; filter: none;559    box-shadow: 0 4px 16px rgba(22,163,74,0.35);560    cursor: default;561  }562  .sp-fill-btn:disabled { cursor: default; }563  .sp-actions {564    display: flex; gap: 4px; justify-content: space-between; align-items: center;565    margin-top: 4px;566  }567  .sp-action-btn {568    flex: 1; height: 28px; border: none; border-radius: 6px;569    background: transparent; color: var(--peach-text-dim);570    font-size: 12px; cursor: pointer;571    transition: background 120ms ease, color 120ms ease;572    font-family: Inter, sans-serif; letter-spacing: 0.04em;573    display: inline-flex; align-items: center; justify-content: center; gap: 4px;574  }575  .sp-action-btn:hover:not(:disabled) {576    background: color-mix(in srgb, var(--peach-text) 5%, transparent);577    color: var(--peach-text);578  }579  .sp-action-btn:disabled { opacity: 0.3; cursor: default; }580  .sp-action-btn.flashed {581    color: var(--peach-accent);582    background: color-mix(in srgb, var(--peach-accent) 12%, transparent);583  }584585  /* Locked state */586  .locked-wrap {587    display: flex; flex-direction: column; align-items: stretch;588    padding: 14px 13px 13px; gap: 9px;589  }590  .locked-head { display: flex; align-items: center; gap: 8px; padding-bottom: 2px; }591  .locked-icon {592    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;593    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);594    display: flex; align-items: center; justify-content: center;595  }596  .locked-title { font-size: 11.5px; color: var(--peach-text); font-weight: 500; }597  .locked-input {598    width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);599    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;600    font-size: 11px; font-family: "IBM Plex Mono", monospace;601    outline: none; transition: border-color 120ms, box-shadow 120ms;602  }603  .locked-input:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); }604  .locked-input::placeholder { color: var(--peach-text-muted); }605  .locked-btn {606    width: 100%; padding: 8px; border-radius: 8px; border: none;607    background: var(--peach-accent); color: #fff; font-size: 12px;608    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;609    transition: background 120ms;610    letter-spacing: 0.01em;611    box-shadow: 0 2px 16px var(--peach-glow, transparent);612    animation: peach-pulse-glow 2.4s ease-in-out infinite;613  }614  .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }615  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; animation: none; }616  .locked-error {617    font-size: 10px; color: rgba(220,80,60,0.85);618    font-family: "IBM Plex Mono", monospace;619  }620621  @keyframes peach-pulse-glow {622    0%, 100% { box-shadow: 0 2px 16px var(--peach-glow, transparent); }623    50%      { box-shadow: 0 2px 28px var(--peach-glow, transparent),624                            0 0 48px var(--peach-glow, transparent); }625  }626`;627628export function showAutofillSelector(629  anchorEl: HTMLInputElement,630  entries: SelectorEntry[],631  onSelect: (id: string) => void,632  options: SelectorOptions = {}633): () => void {634  const host = document.createElement("div");635  host.style.cssText = "position: absolute; z-index: 2147483647;";636  host.style.setProperty("--peach-accent", accentHex);637  host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));638  host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));639  host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));640  const shadow = host.attachShadow({ mode: "closed" });641642  const style = shadow.appendChild(document.createElement("style"));643  style.textContent = SELECTOR_STYLES;644645  const rect = anchorEl.getBoundingClientRect();646  host.style.top = `${rect.bottom + window.scrollY + 5}px`;647  host.style.left = `${rect.left + window.scrollX}px`;648  // Spec §1.1: 320px width. The Math.max() floor preserves usability on649  // wide single-input fields (e.g. centered email input on signup pages).650  host.style.width = `${Math.max(rect.width, 320)}px`;651652  const container = shadow.appendChild(document.createElement("div"));653  container.className = "peach-inline-popup";654  container.setAttribute("data-theme", popupTheme);655656  const searchableEntries = options.searchableEntries ?? [];657  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();658659  let query = "";660  let focusedIndex = -1;661  let currentMode: "list" | "create" | "success" | "silver-platter" = "list";662  let searchInput: HTMLInputElement | null = null;663664  // Pre-capture the anchor field value for username prefill665  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";666  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");667  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";668669  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {670    const seen = new Set<string>();671    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });672  }673674  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {675    const q = value.trim().toLowerCase();676    const deduped = uniqueEntries(source);677    if (!q) return deduped.slice(0, 8);678    return deduped.filter(e =>679      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)680    ).slice(0, 12);681  }682683  function activeEntries(): SelectorEntry[] {684    // When user is typing a search, filter across all known entries (domain matches + vault)685    if (query.trim()) {686      const merged = uniqueEntries([...entries, ...searchableEntries]);687      return filterEntries(merged, query);688    }689    // Default: show domain matches; fall back to vault search when no matches690    if (entries.length > 0) return entries;691    return filterEntries(searchableEntries, "").slice(0, 8);692  }693694  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {695    const icon = document.createElement("div");696    icon.className = `entry-icon${isNew ? " is-new" : ""}`;697    if (isNew) {698      icon.innerHTML = plusSvgStr();699    } else {700      // Use the generic key glyph for all entries in the page-injected selector.701      // Avoids disclosing vault domains to any third party and avoids needing702      // to expose the browser favicon API to page context via web_accessible_resources.703      void url;704      icon.innerHTML = keySvgStr();705    }706    return icon;707  }708709  function renderList() {710    container.innerHTML = "";711    focusedIndex = -1;712    searchInput = null;713714    const current = activeEntries();715716    // Domain header (always show)717    const domHdr = document.createElement("div");718    domHdr.className = "domain-header";719    const eyebrow = document.createElement("div");720    eyebrow.className = "domain-eyebrow";721    eyebrow.textContent = options.isSignup722      ? "Sign up as"723      : entries.length > 0 ? "Saved for this site" : "No saved login";724    const domName = document.createElement("div");725    domName.className = "domain-name";726    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");727    domHdr.append(eyebrow, domName);728    container.appendChild(domHdr);729730    // Search — always show so user can find entries beyond the domain matches731    const sw = document.createElement("div"); sw.className = "search-wrap";732    searchInput = document.createElement("input");733    searchInput.className = "search";734    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";735    searchInput.value = query;736    searchInput.addEventListener("input", () => {737      query = searchInput?.value ?? "";738      renderList();739      searchInput?.focus();740      searchInput?.setSelectionRange(query.length, query.length);741    });742    sw.appendChild(searchInput);743    container.appendChild(sw);744745    // Results746    const results = document.createElement("div");747    results.className = "results";748    container.appendChild(results);749750    if (current.length === 0 && entries.length > 0) {751      // Show a hint but don't render empty entries752    }753754    for (let i = 0; i < current.length; i++) {755      const e = current[i]!;756      const entryEl = document.createElement("div");757      entryEl.className = "entry";758      entryEl.dataset.id = e.id;759      entryEl.dataset.index = String(i);760      entryEl.setAttribute("tabindex", "-1");761762      entryEl.appendChild(makeEntryIcon(e.url));763764      const info = document.createElement("div"); info.className = "entry-info";765      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;766      const subEl = document.createElement("div"); subEl.className = "entry-sub";767      const domain = e.url ? getDomainFromUrl(e.url) : "";768      subEl.textContent = domain || e.username || "";769      info.append(titleEl, subEl);770      entryEl.appendChild(info);771772      if (e.hasTotp) {773        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";774        entryEl.appendChild(badge);775      }776777      entryEl.addEventListener("click", () => {778        if (options.isSignup && options.onFillFields) {779          // Signup mode: clicking a saved identity fills the username field780          options.onFillFields(e.username ?? "", "");781          dismiss();782        } else {783          onSelect(e.id);784          dismiss();785        }786      });787      results.appendChild(entryEl);788    }789790    // Divider before "New entry"791    if (current.length > 0) {792      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));793    }794795    // "New entry" row — always present796    const newEl = document.createElement("div");797    newEl.className = "entry";798    newEl.setAttribute("tabindex", "-1");799    newEl.appendChild(makeEntryIcon(undefined, true));800801    const newInfo = document.createElement("div"); newInfo.className = "entry-info";802    const newTitle = document.createElement("div");803    newTitle.className = "entry-title is-new";804    newTitle.textContent = `New entry for ${sTitle}`;805    const newSub = document.createElement("div");806    newSub.className = "entry-sub";807    newSub.textContent = sOrigin;808    newInfo.append(newTitle, newSub);809    newEl.appendChild(newInfo);810    newEl.addEventListener("click", showCreateForm);811    results.appendChild(newEl);812813    // Focus search if present814    if (searchInput) {815      requestAnimationFrame(() => searchInput?.focus());816    }817  }818819  function setFocused(idx: number) {820    const all = container.querySelectorAll<HTMLElement>(".entry");821    all.forEach((el, i) => el.classList.toggle("focused", i === idx));822    focusedIndex = idx;823    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });824  }825826  async function showCreateForm() {827    currentMode = "create";828    container.innerHTML = "";829830    // Guard: the service worker may lock the vault between when the selector831    // opens and when the user clicks "New entry". Show the inline unlock form832    // instead of silently failing on save.833    const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;834    if (!statusRes?.unlocked) {835      renderLocked();836      return;837    }838839    // Detect page password policy once for this form — reused on every840    // regenerate click so the user always gets a compliant password.841    const sitePolicy = detectPasswordPolicy();842843    const wrap = document.createElement("div");844    wrap.className = "create-form-wrap";845846    // Header847    const head = document.createElement("div"); head.className = "create-head";848    const backBtn = document.createElement("button"); backBtn.className = "back-btn";849    backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1L1.5 7L6 13"/></svg>`;850    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });851    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";852    head.append(backBtn, lbl);853854    // Body855    const body = document.createElement("div"); body.className = "create-body";856857    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {858      const g = document.createElement("div"); g.className = "f-group";859      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;860      const inp = document.createElement("input"); inp.className = "f-input";861      g.append(l, inp); return { g, inp };862    }863864    // Title865    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";866    body.appendChild(tg.g);867868    // URL869    const ug = fGroup("website");870    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";871    if (loginSubdomain) {872      const h = document.createElement("div"); h.className = "url-hint";873      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);874    }875    body.appendChild(ug.g);876877    // Username (pre-fill from anchor field)878    const xg = fGroup("username / email");879    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";880    xg.inp.setAttribute("autocomplete", "off");881    body.appendChild(xg.g);882883    // Password + generate884    const pg = fGroup("password");885    const pwInp = document.createElement("input"); pwInp.className = "f-input";886    pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);887    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';888    pwInp.style.letterSpacing = "0.04em";889    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");890    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";891    genBtn.innerHTML = genSvgStr() + " regen";892    genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });893    const pwRow = document.createElement("div"); pwRow.className = "f-row";894    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);895    body.appendChild(pg.g);896897    // Save898    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";899    saveBtn.textContent = "Save entry";900    const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";901    function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }902    saveBtn.addEventListener("click", async () => {903      const title = tg.inp.value.trim();904      const pw = pwInp.value.trim();905      const username = xg.inp.value.trim();906      if (!title) {907        tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();908        showFormError("Entry needs a title");909        return;910      }911      if (!pw) {912        pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();913        showFormError("Entry needs a password");914        return;915      }916      errEl.style.display = "none";917      saveBtn.disabled = true; saveBtn.textContent = "Saving…";918      try {919        const res = await chrome.runtime.sendMessage({920          type: "ADD_ENTRY",921          payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },922        });923        if ((res as { ok?: boolean }).ok) {924          // In signup mode, fill the username + password fields on the page925          if (options.isSignup && options.onFillFields) {926            options.onFillFields(username, pw);927          }928          showSuccess(title);929        } else {930          const errMsg = (res as { error?: string }).error === "vault locked"931            ? "Vault locked — open Peach to save"932            : "Could not save entry";933          showFormError(errMsg);934          saveBtn.disabled = false; saveBtn.textContent = "Save entry";935        }936      } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }937    });938    body.appendChild(saveBtn);939    body.appendChild(errEl);940941    wrap.append(head, body);942    container.appendChild(wrap);943    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });944  }945946  function showSuccess(title: string) {947    currentMode = "success";948    container.innerHTML = "";949    const wrap = document.createElement("div"); wrap.className = "success-wrap";950    const ring = document.createElement("div"); ring.className = "success-ring";951    ring.innerHTML = checkSvgStr();952    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;953    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;954    wrap.append(ring, t, s);955    container.appendChild(wrap);956    setTimeout(() => dismiss(), 1600);957  }958959  function flashActionBtn(btn: HTMLButtonElement) {960    btn.classList.add("flashed");961    setTimeout(() => btn.classList.remove("flashed"), 700);962  }963964  function renderSilverPlatter(entry: SelectorEntry) {965    container.innerHTML = "";966    searchInput = null;967    focusedIndex = -1;968    currentMode = "silver-platter";969970    const domHdr = document.createElement("div");971    domHdr.className = "domain-header";972    const eyebrow = document.createElement("div");973    eyebrow.className = "domain-eyebrow";974    eyebrow.textContent = "Saved for this site";975    const domName = document.createElement("div");976    domName.className = "domain-name";977    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");978    domHdr.append(eyebrow, domName);979    container.appendChild(domHdr);980981    const platter = document.createElement("div");982    platter.className = "silver-platter";983984    const titleEl = document.createElement("div");985    titleEl.className = "sp-title";986    titleEl.textContent = entry.title;987    platter.appendChild(titleEl);988989    if (entry.username) {990      const userEl = document.createElement("div");991      userEl.className = "sp-username";992      userEl.textContent = entry.username;993      platter.appendChild(userEl);994    }995996    const fillBtn = document.createElement("button");997    fillBtn.className = "sp-fill-btn";998    fillBtn.type = "button";999    fillBtn.textContent = "Fill credentials";1000    fillBtn.addEventListener("click", () => {1001      if (fillBtn.disabled) return;1002      fillBtn.disabled = true;1003      fillBtn.classList.add("filled");1004      fillBtn.innerHTML = checkSvgStr();1005      // Fire the fill, then exit via the standard exit animation. The fill1006      // itself is async (handled in index.ts) — by 400ms the form is filled1007      // and the popup begins its exit. Total dwell from click to host1008      // removal: 400ms + 130ms (exit animation) = 530ms.1009      onSelect(entry.id);1010      setTimeout(() => dismiss(), 400);1011    });1012    platter.appendChild(fillBtn);10131014    const actions = document.createElement("div");1015    actions.className = "sp-actions";10161017    const copyU = document.createElement("button");1018    copyU.className = "sp-action-btn";1019    copyU.type = "button";1020    copyU.title = "Copy username";1021    copyU.textContent = "copy ⓤ";1022    copyU.disabled = !entry.username;1023    copyU.addEventListener("click", async (e) => {1024      e.stopPropagation();1025      if (!entry.username) return;1026      let alreadyWritten = false;1027      try { await navigator.clipboard.writeText(entry.username); alreadyWritten = true; } catch {}1028      // Mirror the popup's copy path so the COPY message can clear the1029      // clipboard via the configured timeout. alreadyWritten lets the SW1030      // skip a redundant write when the page-level write succeeded.1031      await chrome.runtime.sendMessage({1032        type: "COPY",1033        payload: { text: entry.username, label: "Username", alreadyWritten },1034      }).catch(() => {});1035      flashActionBtn(copyU);1036    });1037    actions.appendChild(copyU);10381039    const copyP = document.createElement("button");1040    copyP.className = "sp-action-btn";1041    copyP.type = "button";1042    copyP.title = "Copy password";1043    copyP.textContent = "copy ⓟ";1044    copyP.addEventListener("click", async (e) => {1045      e.stopPropagation();1046      const resp = await chrome.runtime.sendMessage({1047        type: "GET_ENTRY_FOR_FILL",1048        payload: { id: entry.id },1049      }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;1050      if (!resp?.ok || !resp.data?.password) return;1051      let alreadyWritten = false;1052      try { await navigator.clipboard.writeText(resp.data.password); alreadyWritten = true; } catch {}1053      await chrome.runtime.sendMessage({1054        type: "COPY",1055        payload: { text: resp.data.password, label: "Password", alreadyWritten },1056      }).catch(() => {});1057      flashActionBtn(copyP);1058    });1059    actions.appendChild(copyP);10601061    const closeBtn = document.createElement("button");1062    closeBtn.className = "sp-action-btn";1063    closeBtn.type = "button";1064    closeBtn.title = "Dismiss";1065    closeBtn.textContent = "✕";1066    closeBtn.addEventListener("click", (e) => {1067      e.stopPropagation();1068      dismiss(true);1069    });1070    actions.appendChild(closeBtn);10711072    platter.appendChild(actions);1073    container.appendChild(platter);1074  }10751076  function renderLocked() {1077    container.innerHTML = "";10781079    // Domain header (same as list view)1080    const domHdr = document.createElement("div");1081    domHdr.className = "domain-header";1082    const eyebrow = document.createElement("div");1083    eyebrow.className = "domain-eyebrow";1084    eyebrow.textContent = "vault locked";1085    const domName = document.createElement("div");1086    domName.className = "domain-name";1087    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");1088    domHdr.append(eyebrow, domName);1089    container.appendChild(domHdr);10901091    const wrap = document.createElement("div");1092    wrap.className = "locked-wrap";10931094    // Icon + label row1095    const head = document.createElement("div"); head.className = "locked-head";1096    const icon = document.createElement("div"); icon.className = "locked-icon";1097    icon.innerHTML = keySvgStr();1098    const title = document.createElement("div"); title.className = "locked-title";1099    title.textContent = "unlock to autofill";1100    head.append(icon, title);11011102    // Password input1103    const pwInp = document.createElement("input");1104    pwInp.className = "locked-input";1105    pwInp.type = "password";1106    pwInp.placeholder = "master password";1107    pwInp.setAttribute("autocomplete", "current-password");11081109    // Error line (hidden initially)1110    const errEl = document.createElement("div");1111    errEl.className = "locked-error";1112    errEl.style.display = "none";11131114    // Unlock button1115    const unlockBtn = document.createElement("button");1116    unlockBtn.className = "locked-btn";1117    unlockBtn.type = "button";1118    unlockBtn.textContent = "unlock";11191120    async function tryUnlock() {1121      const pw = pwInp.value.trim();1122      if (!pw) { pwInp.focus(); return; }1123      unlockBtn.disabled = true;1124      unlockBtn.textContent = "unlocking…";1125      errEl.style.display = "none";1126      try {1127        const res = await chrome.runtime.sendMessage({1128          type: "UNLOCK",1129          payload: { password: pw },1130        });1131        if ((res as { ok?: boolean }).ok) {1132          dismiss();1133          options.onUnlocked?.();1134        } else {1135          errEl.textContent = "incorrect password";1136          errEl.style.display = "block";1137          pwInp.value = "";1138          pwInp.focus();1139          unlockBtn.disabled = false;1140          unlockBtn.textContent = "unlock";1141        }1142      } catch {1143        errEl.textContent = "could not reach Peach";1144        errEl.style.display = "block";1145        unlockBtn.disabled = false;1146        unlockBtn.textContent = "unlock";1147      }1148    }11491150    pwInp.addEventListener("keydown", (e) => {1151      e.stopPropagation();1152      if (e.key === "Enter") void tryUnlock();1153    });1154    pwInp.addEventListener("keyup", (e) => e.stopPropagation());1155    pwInp.addEventListener("keypress", (e) => e.stopPropagation());1156    unlockBtn.addEventListener("click", () => void tryUnlock());11571158    wrap.append(head, pwInp, errEl, unlockBtn);1159    container.appendChild(wrap);11601161    // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)1162    // the page reclaims focus from our Shadow DOM input before the user can1163    // type. Fighting over focus with a focusout→refocus loop hangs the tab.1164    // Instead: intercept keystrokes at the document capture phase — this runs1165    // before any page handler regardless of which element technically has1166    // OS focus — and route them into our input.1167    const captureKeydown = (e: KeyboardEvent) => {1168      if (!host.isConnected) {1169        document.removeEventListener("keydown", captureKeydown, true);1170        return;1171      }1172      // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.1173      if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;1174      // Intercept before page handlers and give our input focus.1175      e.stopImmediatePropagation();1176      if (e.key === "Escape") { dismiss(true); return; }1177      if (e.key === "Enter") { void tryUnlock(); return; }1178      // For all other printable/editing keys, ensure our input has focus so1179      // the browser delivers the keystroke there naturally.1180      if (document.activeElement !== host) {1181        pwInp.focus({ preventScroll: true });1182      }1183    };1184    captureKeydownRef = captureKeydown;1185    document.addEventListener("keydown", captureKeydown, true);11861187    // Best-effort focus on open — may be overridden by page focus trap,1188    // but the capture listener above handles typing regardless.1189    pwInp.focus({ preventScroll: true });1190    requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });1191  }11921193  function handleKeyDown(e: KeyboardEvent) {1194    if (options.locked) {1195      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1196      return;1197    }1198    if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") {1199      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1200      return;1201    }1202    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");1203    const count = allEntryEls.length;1204    if (document.activeElement === searchInput) {1205      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }1206      if (count === 0) return;1207      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;1208    }1209    if (count === 0) return;1210    switch (e.key) {1211      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;1212      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;1213      case "Enter": {1214        if (focusedIndex >= 0) {1215          const el = allEntryEls[focusedIndex] as HTMLElement;1216          const id = el.dataset.id;1217          if (id) { onSelect(id); dismiss(); }1218          else if (el.classList.contains("is-new") || !id) {1219            // "New entry" row (last item, no data-id)1220            showCreateForm();1221          }1222        }1223        break;1224      }1225      case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;1226    }1227  }12281229  document.addEventListener("keydown", handleKeyDown);12301231  document.body.appendChild(host);12321233  function updatePosition() {1234    const r = anchorEl.getBoundingClientRect();1235    host.style.top = `${r.bottom + window.scrollY + 5}px`;1236    host.style.left = `${r.left + window.scrollX}px`;1237    host.style.width = `${Math.max(r.width, 260)}px`;1238  }1239  // These bindings are referenced from renderLocked()/dismiss() — they MUST1240  // be initialized before renderLocked() runs, or the synchronous assignment1241  // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ1242  // and throws ReferenceError. The throw lands after the popup is rendered1243  // but before showAutofillSelector returns its dismiss handle, leaving an1244  // orphan host in the DOM (which is what caused the locked-popup stacking1245  // and missing autofocus on every click).1246  let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;1247  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);12481249  if (options.locked) {1250    renderLocked();1251  } else if (entries.length === 1 && !options.isSignup) {1252    // Silver platter (State 1): single domain match → focused fill view.1253    // Multi-match goes through renderList instead. Signup mode always uses1254    // the list because the user is choosing which existing identity to1255    // surface — there's no single "fill credentials" interpretation.1256    renderSilverPlatter(entries[0]!);1257  } else {1258    renderList();1259  }12601261  let exitScheduled = false;1262  function dismiss(userAction = false) {1263    // Idempotent: dismiss() can be called from multiple paths (Escape,1264    // outside click, entry select, fill success, host page nav). The exit1265    // animation must only play once and host.remove() must run exactly once.1266    if (exitScheduled) return;1267    exitScheduled = true;1268    // Detach listeners synchronously so nothing can re-trigger during the1269    // 130ms exit window (a focusin firing here would race against the1270    // dismiss-lock work in Pass 2).1271    stopObserving();1272    document.removeEventListener("click", outsideClick, { capture: true });1273    document.removeEventListener("keydown", handleKeyDown);1274    if (captureKeydownRef) {1275      document.removeEventListener("keydown", captureKeydownRef, true);1276      captureKeydownRef = null;1277    }1278    if (userAction) options.onDismiss?.();1279    container.classList.add("is-exiting");1280    setTimeout(() => host.remove(), 130);1281  }12821283  function outsideClick(ev: MouseEvent) {1284    const path = ev.composedPath();1285    // Never dismiss when clicking the anchor field itself — that click is1286    // what triggered the selector to open, and the user may click the field1287    // again to re-focus it without intending to close the selector.1288    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);1289  }1290  // capture: true so pages that call stopPropagation() on click don't block our dismiss handler1291  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);12921293  return dismiss;1294}