inject.ts

68.8 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 { generateTOTP } from "../crypto/totp";15import { detectPasswordPolicy } from "./policy";1617let accentHex = "#E8956D";18let popupTheme: "dark" | "light" = "dark";19chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string; theme?: string } } | undefined) => {20  const data = resp?.data;21  const name = data?.accentColor ?? "peach";22  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";23  popupTheme = data?.theme === "light" ? "light" : "dark";24});2526// 8-digit hex alpha helpers for the accent-derived shadow tokens.27// `#E8956D` + alpha `28` → `#E8956D28`. The browser parses the trailing28// two hex digits as the alpha channel.29function withAlpha(hex: string, alpha: string): string {30  // Defensive: only 6-digit hex inputs are supported here. ACCENT_COLORS31  // always exposes 6-digit hex, so this is a guard against future changes32  // shipping shorthand colors that would silently break the var injection.33  return /^#[0-9a-f]{6}$/i.test(hex) ? hex + alpha : hex;34}3536export interface SelectorEntry {37  id: string;38  title: string;39  username?: string;40  url?: string;41  hasTotp?: boolean;42}4344interface SelectorOptions {45  domain?: string;46  searchableEntries?: SelectorEntry[];47  /** True when shown on a signup form — changes header label and entry-click behaviour */48  isSignup?: boolean;49  /**50   * Called after the user picks a saved identity (signup mode only) or51   * after successfully saving a new entry on a signup form.52   * Lets the caller fill the visible username + password fields.53   */54  onFillFields?: (username: string, password: string) => void;55  /**56   * When true, show an inline unlock form instead of the entries list.57   * Used when the vault is locked so the user can unlock without opening58   * the popup.59   */60  locked?: boolean;61  /**62   * Called after a successful inline unlock — the caller should re-query63   * entries and re-open the selector with real data.64   */65  onUnlocked?: () => void;66  /**67   * Called when the user explicitly dismisses the selector (Escape, Tab, or68   * click outside) — NOT when an entry is selected or on auto-close.69   * Used by the caller to suppress spurious reopens on the same field.70   */71  onDismiss?: () => void;72}7374function getDomainFromUrl(url: string): string {75  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }76}7778function keySvgStr(): string {79  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>`;80}8182function plusSvgStr(): string {83  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>`;84}8586function peachIconSvg(): string {87  // Peach body is always brand-peach (#D96830), not `accentHex` — a sage88  // or sky accent must not turn the autofill icon green or blue. Accent89  // color still tints surrounding glow/hover halos below.90  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>`;91}9293function genSvgStr(): string {94  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>`;95}9697function checkSvgStr(): string {98  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>`;99}100101function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {102  const { hostname, origin } = location;103  const parts = hostname.split(".");104  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",105    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",106    "oidc", "federation", "saml", "cas", "ldap"];107  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());108  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;109  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);110  return { origin, title, loginSubdomain };111}112113/**114 * Shows a bottom-of-page toast after filling credentials when the entry has115 * never matched the current site — offering to add the current origin to the116 * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s.117 */118export function showRememberSiteToast(119  pageDomain: string,120  entryId: string,121): void {122  const host = document.createElement("div");123  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";124  host.style.setProperty("--peach-accent", accentHex);125  const shadow = host.attachShadow({ mode: "closed" });126127  const style = document.createElement("style");128  style.textContent = `129    .bar {130      display:flex;align-items:center;gap:10px;padding:10px 14px;131      background:#131109;border:1px solid rgba(240,232,216,0.09);132      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);133      font-family:Inter,sans-serif;white-space:nowrap;134      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;135    }136    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }137    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }138    .yes {139      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;140      background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;141    }142    .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }143    .no {144      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;145      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;146    }147  `;148  shadow.appendChild(style);149150  const bar = document.createElement("div"); bar.className = "bar";151  const txt = document.createElement("span"); txt.className = "txt";152  txt.textContent = `Remember ${pageDomain} for this entry?`;153  const yesBtn = document.createElement("button"); yesBtn.className = "yes";154  yesBtn.textContent = "Remember";155  yesBtn.addEventListener("click", async () => {156    await chrome.runtime.sendMessage({157      type: "ADD_ADDITIONAL_URL",158      payload: { id: entryId, url: location.origin },159    });160    host.remove();161  });162  const noBtn = document.createElement("button"); noBtn.className = "no";163  noBtn.textContent = "Not now";164  noBtn.addEventListener("click", () => host.remove());165  bar.append(txt, yesBtn, noBtn);166  shadow.appendChild(bar);167  document.body.appendChild(host);168  setTimeout(() => host.remove(), 9000);169}170171function generatePasswordForSite(policy: PasswordPolicy = {}): string {172  return generateSmartPassword(policy);173}174175export function observeAnchorPosition(176  anchorEl: HTMLElement,177  onUpdate: () => void178): () => void {179  let rafId = 0;180  let burstUntil = 0;181182  const runUpdate = () => {183    rafId = 0;184    onUpdate();185    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);186  };187  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };188  const scheduleBurst = (ms = 220) => {189    burstUntil = Math.max(burstUntil, performance.now() + ms);190    scheduleUpdate();191  };192193  const resizeObserver = new ResizeObserver(() => scheduleBurst());194  resizeObserver.observe(anchorEl);195  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);196197  const mutationObserver = new MutationObserver(() => scheduleBurst());198  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });199  if (anchorEl.parentElement) {200    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });201  }202203  const onScroll = () => scheduleUpdate();204  window.addEventListener("scroll", onScroll, true);205  window.addEventListener("resize", onScroll);206  window.visualViewport?.addEventListener("resize", onScroll);207  window.visualViewport?.addEventListener("scroll", onScroll);208  anchorEl.addEventListener("focus", () => scheduleBurst());209  anchorEl.addEventListener("input", () => scheduleBurst());210211  scheduleBurst(260);212213  return () => {214    if (rafId) cancelAnimationFrame(rafId);215    resizeObserver.disconnect();216    mutationObserver.disconnect();217    window.removeEventListener("scroll", onScroll, true);218    window.removeEventListener("resize", onScroll);219    window.visualViewport?.removeEventListener("resize", onScroll);220    window.visualViewport?.removeEventListener("scroll", onScroll);221  };222}223224export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {225  const button = document.createElement("button");226  button.type = "button";227  button.setAttribute("aria-label", "Peach autofill");228  button.title = "Fill with Peach";229  button.innerHTML = peachIconSvg();230  // position: fixed is load-bearing — not absolute. Absolute positioning231  // inherits the nearest positioned ancestor's box, which on sites with232  // overflow:hidden or sticky parents clips or offsets the icon. Fixed233  // anchors to the viewport and escapes all parent layout.234  button.style.cssText = `235    position: fixed; z-index: 2147483646;236    width: 26px; height: 26px; cursor: pointer;237    pointer-events: auto; border: none; padding: 3px;238    display: inline-flex; align-items: center; justify-content: center;239    background: transparent; border-radius: 999px;240    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;241    opacity: 0;242  `;243  button.addEventListener("mouseenter", () => {244    button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;245    button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;246    button.style.transform = "scale(1.08)";247  });248  button.addEventListener("mouseleave", () => {249    button.style.background = "transparent";250    button.style.boxShadow = "none";251    button.style.transform = "none";252  });253  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });254  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });255256  document.body.appendChild(button);257258  function updatePosition() {259    const rect = field.getBoundingClientRect();260    if (rect.width === 0 || rect.height === 0) {261      button.style.opacity = "0"; button.style.pointerEvents = "none";262      return;263    }264    const sz = 26;265    const inset = Math.max(5, Math.min(10, rect.height * 0.22));266    const top = rect.top + rect.height / 2 - sz / 2;267    // Clamp left so the icon stays fully inside the field's right edge, never268    // spilling onto an adjacent sibling button.269    const minLeft = rect.left + 4;270    const left = Math.max(minLeft, rect.right - sz - inset);271    // Hide if the field is too narrow to fit the icon cleanly (< 40px)272    if (rect.width < 40) {273      button.style.opacity = "0"; button.style.pointerEvents = "none";274      return;275    }276    button.style.top = `${top}px`;277    button.style.left = `${left}px`;278    button.style.opacity = "1"; button.style.pointerEvents = "auto";279  }280  updatePosition();281  requestAnimationFrame(() => { button.style.opacity = "1"; });282  const stopObserving = observeAnchorPosition(field, updatePosition);283  return () => { stopObserving(); button.remove(); };284}285286// All theme-dependent values resolve through tokens scoped to287// .peach-inline-popup[data-theme="dark|light"]. Accent variables288// (--peach-accent, --peach-accent-dim, --peach-glow, --peach-border-accent)289// are identical in both themes — only surface tokens differ. See290// Projects.md §4.14: hardcoded hex inside this stylesheet breaks the291// theme switch — only literal brand-peach in peachIconSvg may stay raw.292const SELECTOR_STYLES = `293  :host { all: initial; }294  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }295296  .peach-inline-popup[data-theme="dark"] {297    --peach-bg:           #1a1410;298    --peach-bg-card:      #221c16;299    --peach-text:         #f5f0ea;300    --peach-text-dim:     #8a7a6a;301    --peach-text-muted:   #4a3a2a;302    --peach-border:       rgba(255,255,255,0.07);303    --peach-input-bg:     rgba(255,255,255,0.05);304    --peach-input-border: rgba(255,255,255,0.08);305  }306  .peach-inline-popup[data-theme="light"] {307    --peach-bg:           #fdf8f4;308    --peach-bg-card:      #f5ede4;309    --peach-text:         #1c1310;310    --peach-text-dim:     #6b5a4a;311    --peach-text-muted:   #a89080;312    --peach-border:       rgba(0,0,0,0.08);313    --peach-input-bg:     rgba(0,0,0,0.04);314    --peach-input-border: rgba(0,0,0,0.10);315  }316317  .peach-inline-popup {318    background: var(--peach-bg);319    border: 1px solid var(--peach-border-accent, var(--peach-border));320    border-radius: 14px;321    overflow: hidden;322    box-shadow:323      0 8px 40px var(--peach-glow, transparent),324      0 2px 12px rgba(0,0,0,0.5);325    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;326    animation: peach-enter 200ms cubic-bezier(0.16,1,0.3,1) forwards;327  }328  /* Light theme: shadow against a light page is too strong otherwise — drop both329     the accent glow and the dark drop-shadow opacity to keep the popup elegant. */330  .peach-inline-popup[data-theme="light"] {331    box-shadow:332      0 8px 40px color-mix(in srgb, var(--peach-accent) 10%, transparent),333      0 2px 12px rgba(0,0,0,0.12);334  }335  .peach-inline-popup.is-exiting {336    animation: peach-exit 130ms cubic-bezier(0.4,0,1,1) forwards;337    pointer-events: none;338  }339340  @keyframes peach-enter {341    0%   { opacity: 0; transform: translateY(-8px) scale(0.96); filter: blur(3px); }342    65%  { opacity: 1; filter: blur(0); }343    100% { opacity: 1; transform: translateY(0) scale(1); }344  }345  @keyframes peach-exit {346    0%   { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }347    100% { opacity: 0; transform: translateY(-4px) scale(0.98); filter: blur(2px); }348  }349  @keyframes peach-slide-in {350    from { opacity: 0; transform: translateX(12px); }351    to   { opacity: 1; transform: translateX(0); }352  }353  @keyframes peach-slide-back {354    from { opacity: 0; transform: translateX(-12px); }355    to   { opacity: 1; transform: translateX(0); }356  }357  .peach-content[data-dir="forward"] { animation: peach-slide-in 150ms cubic-bezier(0.16,1,0.3,1) both; }358  .peach-content[data-dir="back"]    { animation: peach-slide-back 150ms cubic-bezier(0.16,1,0.3,1) both; }359360  /* Domain header */361  .domain-header {362    padding: 9px 13px 7px;363    border-bottom: 1px solid var(--peach-border);364    display: flex; align-items: center; justify-content: space-between;365  }366  .domain-eyebrow {367    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;368    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;369  }370  .domain-name {371    font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);372    font-family: "IBM Plex Mono", monospace;373  }374375  /* Search */376  .search-wrap {377    padding: 9px 12px 7px;378    border-bottom: 1px solid var(--peach-border);379  }380  .search {381    width: 100%; background: var(--peach-input-bg);382    border: 1px solid var(--peach-input-border);383    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;384    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;385    transition: border-color 120ms, box-shadow 120ms;386  }387  .search:focus {388    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);389    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);390  }391  .search::placeholder { color: var(--peach-text-muted); }392393  /* Results */394  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }395  .results::-webkit-scrollbar { display: none; }396397  /* Entry rows */398  .entry {399    display: flex; align-items: center; padding: 8px 12px;400    cursor: pointer; border-bottom: 1px solid var(--peach-border);401    transition: background 70ms; position: relative; outline: none;402  }403  .entry:last-child { border-bottom: none; }404  .entry:hover { background: color-mix(in srgb, var(--peach-text) 4%, transparent); }405  .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }406  .entry.focused::before {407    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;408    width: 2px; border-radius: 2px;409    background: color-mix(in srgb, var(--peach-accent) 55%, transparent);410  }411412  .entry-icon {413    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;414    background: var(--peach-input-bg); border: 1px solid var(--peach-border);415    display: flex; align-items: center; justify-content: center;416    overflow: hidden; margin-right: 10px;417  }418  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }419  .entry-icon.is-new {420    background: color-mix(in srgb, var(--peach-accent) 8%, transparent);421    border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);422    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);423  }424425  .entry-info { flex: 1; min-width: 0; }426  .entry-title {427    font-size: 12.5px; color: var(--peach-text);428    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;429    line-height: 1.3;430  }431  .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }432  .entry-sub {433    font-size: 10px; color: var(--peach-text-dim);434    font-family: "IBM Plex Mono", monospace;435    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;436    margin-top: 1px;437  }438439  .totp-badge {440    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;441    border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);442    color: color-mix(in srgb, var(--peach-accent) 75%, transparent);443    font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 8px;444  }445446  .divider { height: 1px; background: var(--peach-border); margin: 2px 0; }447448  .empty-hint {449    padding: 14px 13px; font-size: 11px; color: var(--peach-text-dim);450    font-family: "IBM Plex Mono", monospace;451  }452453  /* Create form */454  .create-form-wrap {455    animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both;456  }457  .create-head {458    display: flex; align-items: center; gap: 8px;459    padding: 10px 12px; border-bottom: 1px solid var(--peach-border);460  }461  .back-btn {462    background: transparent; border: none; cursor: pointer;463    color: var(--peach-text-dim); padding: 3px; border-radius: 5px;464    display: flex; align-items: center; line-height: 0;465    transition: color 100ms, background 100ms;466  }467  .back-btn:hover { color: var(--peach-text); background: var(--peach-input-bg); }468  .create-label { font-size: 12px; color: var(--peach-text); font-weight: 500; flex: 1; }469  .create-body { padding: 12px; display: grid; gap: 9px; }470  .f-group { display: grid; gap: 3px; }471  .f-label {472    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;473    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;474  }475  .f-input {476    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);477    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;478    font-size: 11px; font-family: "IBM Plex Mono", monospace;479    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;480  }481  .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); }482  .f-input::placeholder { color: var(--peach-text-muted); }483  .f-input.url-f { font-size: 10px; color: var(--peach-text-dim); letter-spacing: 0.01em; }484  .f-row { display: flex; gap: 6px; }485  .f-row .f-input { flex: 1; min-width: 0; }486  .gen-btn {487    padding: 0 10px; border-radius: 7px; border: none;488    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);489    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);490    font-size: 10px; font-family: "IBM Plex Mono", monospace;491    cursor: pointer; white-space: nowrap; flex-shrink: 0;492    display: flex; align-items: center; gap: 4px;493    transition: background 100ms;494  }495  .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }496  .save-btn {497    width: 100%; padding: 8px; border-radius: 8px; border: none;498    background: var(--peach-accent); color: #fff; font-size: 12px;499    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;500    transition: background 120ms, box-shadow 120ms, transform 80ms;501    letter-spacing: 0.01em;502  }503  .save-btn:hover {504    background: color-mix(in srgb, var(--peach-accent) 82%, #000);505    box-shadow: 0 4px 20px var(--peach-glow, transparent);506  }507  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }508  .url-hint {509    font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);510    font-family: "IBM Plex Mono", monospace; font-style: italic;511  }512513  /* Success */514  .success-wrap {515    display: flex; flex-direction: column; align-items: center;516    padding: 24px 16px 20px; gap: 8px;517    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;518  }519  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }520  .success-ring {521    width: 40px; height: 40px; border-radius: 50%;522    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);523    display: flex; align-items: center; justify-content: center;524  }525  .success-text { font-size: 12.5px; color: var(--peach-text); font-weight: 500; }526  .success-sub { font-size: 10px; color: var(--peach-text-dim); font-family:"IBM Plex Mono",monospace; }527528  /* Silver platter (State 1 — single match) */529  .silver-platter {530    padding: 14px 14px 12px;531    display: flex; flex-direction: column; gap: 6px;532  }533  .sp-title {534    font-size: 14px; font-weight: 500; color: var(--peach-text);535    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;536    line-height: 1.25;537  }538  .sp-username {539    font-size: 12px; color: var(--peach-text-dim);540    font-family: "IBM Plex Mono", monospace;541    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;542    margin-bottom: 6px;543  }544  .sp-fill-btn {545    width: 100%; padding: 11px; border-radius: 9px; border: none;546    background: var(--peach-accent); color: #fff;547    font-size: 13px; font-weight: 500; cursor: pointer;548    font-family: Inter, sans-serif; letter-spacing: 0.01em;549    display: flex; align-items: center; justify-content: center; min-height: 38px;550    transition: filter 120ms ease, transform 80ms ease, box-shadow 120ms ease, background 120ms ease;551  }552  .sp-fill-btn:hover {553    filter: brightness(1.1);554    box-shadow: 0 4px 20px var(--peach-glow, transparent);555    transform: translateY(-1px);556  }557  .sp-fill-btn:active { transform: translateY(0); filter: brightness(0.95); }558  .sp-fill-btn.filled {559    background: #16a34a; transform: none; filter: none;560    box-shadow: 0 4px 16px rgba(22,163,74,0.35);561    cursor: default;562  }563  .sp-fill-btn:disabled { cursor: default; }564  .sp-actions {565    display: flex; gap: 4px; justify-content: space-between; align-items: center;566    margin-top: 4px;567  }568  .sp-action-btn {569    flex: 1; height: 28px; border: none; border-radius: 6px;570    background: transparent; color: var(--peach-text-dim);571    font-size: 12px; cursor: pointer;572    transition: background 120ms ease, color 120ms ease;573    font-family: Inter, sans-serif; letter-spacing: 0.04em;574    display: inline-flex; align-items: center; justify-content: center; gap: 4px;575  }576  .sp-action-btn:hover:not(:disabled) {577    background: color-mix(in srgb, var(--peach-text) 5%, transparent);578    color: var(--peach-text);579  }580  .sp-action-btn:disabled { opacity: 0.3; cursor: default; }581  .sp-action-btn.flashed {582    color: var(--peach-accent);583    background: color-mix(in srgb, var(--peach-accent) 12%, transparent);584  }585586  /* TOTP tray — shown below the silver-platter secondary actions */587  .sp-tray {588    margin-top: 8px;589    padding: 10px 12px;590    border-radius: 9px;591    background: var(--peach-input-bg);592    border: 1px solid var(--peach-input-border);593    display: flex; align-items: center; gap: 12px;594    animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both;595  }596  .sp-tray-label {597    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;598    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;599    flex-shrink: 0;600  }601  .sp-totp-code {602    font-size: 18px; font-weight: 500;603    color: var(--peach-text);604    font-family: "IBM Plex Mono", monospace;605    letter-spacing: 0.12em;606    flex: 1;607    text-align: center;608  }609  .sp-totp-ring {610    width: 22px; height: 22px; flex-shrink: 0;611  }612  .sp-totp-ring .ring-track { stroke: var(--peach-input-border); }613  .sp-totp-ring .ring-fill {614    stroke: var(--peach-accent);615    transition: stroke-dashoffset 1s linear;616    transform: rotate(-90deg);617    transform-origin: center;618  }619  .sp-totp-copy {620    background: transparent; border: none; cursor: pointer;621    padding: 4px 8px; border-radius: 5px;622    color: var(--peach-text-dim); font-size: 11px;623    font-family: Inter, sans-serif;624    transition: background 120ms, color 120ms;625  }626  .sp-totp-copy:hover {627    background: color-mix(in srgb, var(--peach-text) 5%, transparent);628    color: var(--peach-text);629  }630631  /* Card silver platter (State 7) — reuses .sp-* primary action classes */632  .card-platter {633    padding: 12px 14px 10px;634    display: flex; flex-direction: column; gap: 4px;635  }636  .cp-network {637    font-size: 10px; font-weight: 600;638    color: var(--peach-accent);639    letter-spacing: 0.08em; text-transform: uppercase;640    font-family: Inter, sans-serif;641  }642  .cp-nickname {643    font-size: 14px; font-weight: 500; color: var(--peach-text);644    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;645    line-height: 1.2;646  }647  .cp-last4 {648    font-size: 12px; color: var(--peach-text-dim);649    font-family: "IBM Plex Mono", monospace;650    letter-spacing: 0.04em;651    margin-top: 2px;652  }653  .cp-meta {654    font-size: 11px; color: var(--peach-text-dim);655    font-family: "IBM Plex Mono", monospace;656    margin-top: 2px;657    display: flex; gap: 10px; align-items: center;658  }659  .cp-meta-sep { opacity: 0.5; }660  .cp-fill-wrap { margin-top: 10px; }661662  /* Locked state */663  .locked-wrap {664    display: flex; flex-direction: column; align-items: stretch;665    padding: 14px 13px 13px; gap: 9px;666  }667  .locked-head { display: flex; align-items: center; gap: 8px; padding-bottom: 2px; }668  .locked-icon {669    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;670    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);671    display: flex; align-items: center; justify-content: center;672  }673  .locked-title { font-size: 11.5px; color: var(--peach-text); font-weight: 500; }674  .locked-input {675    width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);676    border-radius: 7px; color: var(--peach-text); padding: 7px 10px;677    font-size: 11px; font-family: "IBM Plex Mono", monospace;678    outline: none; transition: border-color 120ms, box-shadow 120ms;679  }680  .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); }681  .locked-input::placeholder { color: var(--peach-text-muted); }682  .locked-btn {683    width: 100%; padding: 8px; border-radius: 8px; border: none;684    background: var(--peach-accent); color: #fff; font-size: 12px;685    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;686    transition: background 120ms;687    letter-spacing: 0.01em;688    box-shadow: 0 2px 16px var(--peach-glow, transparent);689    animation: peach-pulse-glow 2.4s ease-in-out infinite;690  }691  .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }692  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; animation: none; }693  .locked-error {694    font-size: 10px; color: rgba(220,80,60,0.85);695    font-family: "IBM Plex Mono", monospace;696  }697698  @keyframes peach-pulse-glow {699    0%, 100% { box-shadow: 0 2px 16px var(--peach-glow, transparent); }700    50%      { box-shadow: 0 2px 28px var(--peach-glow, transparent),701                            0 0 48px var(--peach-glow, transparent); }702  }703`;704705export function showAutofillSelector(706  anchorEl: HTMLInputElement,707  entries: SelectorEntry[],708  onSelect: (id: string) => void,709  options: SelectorOptions = {}710): () => void {711  const host = document.createElement("div");712  host.style.cssText = "position: absolute; z-index: 2147483647;";713  host.style.setProperty("--peach-accent", accentHex);714  host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));715  host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));716  host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));717  const shadow = host.attachShadow({ mode: "closed" });718719  const style = shadow.appendChild(document.createElement("style"));720  style.textContent = SELECTOR_STYLES;721722  const rect = anchorEl.getBoundingClientRect();723  host.style.top = `${rect.bottom + window.scrollY + 5}px`;724  host.style.left = `${rect.left + window.scrollX}px`;725  // Spec §1.1: 320px width. The Math.max() floor preserves usability on726  // wide single-input fields (e.g. centered email input on signup pages).727  host.style.width = `${Math.max(rect.width, 320)}px`;728729  const container = shadow.appendChild(document.createElement("div"));730  container.className = "peach-inline-popup";731  container.setAttribute("data-theme", popupTheme);732733  const searchableEntries = options.searchableEntries ?? [];734  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();735736  let query = "";737  let focusedIndex = -1;738  let currentMode: "list" | "create" | "success" | "silver-platter" = "list";739  let searchInput: HTMLInputElement | null = null;740  // Hooks any sub-state owns for cleanup at dismiss time. Today: TOTP tray741  // setInterval. Add to this list — don't fan out the dismiss function.742  const cleanupHooks: Array<() => void> = [];743744  // Pre-capture the anchor field value for username prefill745  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";746  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");747  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";748749  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {750    const seen = new Set<string>();751    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });752  }753754  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {755    const q = value.trim().toLowerCase();756    const deduped = uniqueEntries(source);757    if (!q) return deduped.slice(0, 8);758    return deduped.filter(e =>759      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)760    ).slice(0, 12);761  }762763  function activeEntries(): SelectorEntry[] {764    // When user is typing a search, filter across all known entries (domain matches + vault)765    if (query.trim()) {766      const merged = uniqueEntries([...entries, ...searchableEntries]);767      return filterEntries(merged, query);768    }769    // Default: show domain matches; fall back to vault search when no matches770    if (entries.length > 0) return entries;771    return filterEntries(searchableEntries, "").slice(0, 8);772  }773774  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {775    const icon = document.createElement("div");776    icon.className = `entry-icon${isNew ? " is-new" : ""}`;777    if (isNew) {778      icon.innerHTML = plusSvgStr();779    } else {780      // Use the generic key glyph for all entries in the page-injected selector.781      // Avoids disclosing vault domains to any third party and avoids needing782      // to expose the browser favicon API to page context via web_accessible_resources.783      void url;784      icon.innerHTML = keySvgStr();785    }786    return icon;787  }788789  function renderList() {790    container.innerHTML = "";791    focusedIndex = -1;792    searchInput = null;793794    const current = activeEntries();795796    // Domain header (always show)797    const domHdr = document.createElement("div");798    domHdr.className = "domain-header";799    const eyebrow = document.createElement("div");800    eyebrow.className = "domain-eyebrow";801    eyebrow.textContent = options.isSignup802      ? "Sign up as"803      : entries.length > 0 ? "Saved for this site" : "No saved login";804    const domName = document.createElement("div");805    domName.className = "domain-name";806    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");807    domHdr.append(eyebrow, domName);808    container.appendChild(domHdr);809810    // Search — always show so user can find entries beyond the domain matches811    const sw = document.createElement("div"); sw.className = "search-wrap";812    searchInput = document.createElement("input");813    searchInput.className = "search";814    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";815    searchInput.value = query;816    searchInput.addEventListener("input", () => {817      query = searchInput?.value ?? "";818      renderList();819      searchInput?.focus();820      searchInput?.setSelectionRange(query.length, query.length);821    });822    sw.appendChild(searchInput);823    container.appendChild(sw);824825    // Results826    const results = document.createElement("div");827    results.className = "results";828    container.appendChild(results);829830    if (current.length === 0 && entries.length > 0) {831      // Show a hint but don't render empty entries832    }833834    for (let i = 0; i < current.length; i++) {835      const e = current[i]!;836      const entryEl = document.createElement("div");837      entryEl.className = "entry";838      entryEl.dataset.id = e.id;839      entryEl.dataset.index = String(i);840      entryEl.setAttribute("tabindex", "-1");841842      entryEl.appendChild(makeEntryIcon(e.url));843844      const info = document.createElement("div"); info.className = "entry-info";845      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;846      const subEl = document.createElement("div"); subEl.className = "entry-sub";847      const domain = e.url ? getDomainFromUrl(e.url) : "";848      subEl.textContent = domain || e.username || "";849      info.append(titleEl, subEl);850      entryEl.appendChild(info);851852      if (e.hasTotp) {853        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";854        entryEl.appendChild(badge);855      }856857      entryEl.addEventListener("click", () => {858        if (options.isSignup && options.onFillFields) {859          // Signup mode: clicking a saved identity fills the username field860          options.onFillFields(e.username ?? "", "");861          dismiss();862        } else {863          onSelect(e.id);864          dismiss();865        }866      });867      results.appendChild(entryEl);868    }869870    // Divider before "New entry"871    if (current.length > 0) {872      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));873    }874875    // "New entry" row — always present876    const newEl = document.createElement("div");877    newEl.className = "entry";878    newEl.setAttribute("tabindex", "-1");879    newEl.appendChild(makeEntryIcon(undefined, true));880881    const newInfo = document.createElement("div"); newInfo.className = "entry-info";882    const newTitle = document.createElement("div");883    newTitle.className = "entry-title is-new";884    newTitle.textContent = `New entry for ${sTitle}`;885    const newSub = document.createElement("div");886    newSub.className = "entry-sub";887    newSub.textContent = sOrigin;888    newInfo.append(newTitle, newSub);889    newEl.appendChild(newInfo);890    newEl.addEventListener("click", showCreateForm);891    results.appendChild(newEl);892893    // Focus search if present894    if (searchInput) {895      requestAnimationFrame(() => searchInput?.focus());896    }897  }898899  function setFocused(idx: number) {900    const all = container.querySelectorAll<HTMLElement>(".entry");901    all.forEach((el, i) => el.classList.toggle("focused", i === idx));902    focusedIndex = idx;903    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });904  }905906  async function showCreateForm() {907    currentMode = "create";908    container.innerHTML = "";909910    // Guard: the service worker may lock the vault between when the selector911    // opens and when the user clicks "New entry". Show the inline unlock form912    // instead of silently failing on save.913    const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;914    if (!statusRes?.unlocked) {915      renderLocked();916      return;917    }918919    // Detect page password policy once for this form — reused on every920    // regenerate click so the user always gets a compliant password.921    const sitePolicy = detectPasswordPolicy();922923    const wrap = document.createElement("div");924    wrap.className = "create-form-wrap";925926    // Header927    const head = document.createElement("div"); head.className = "create-head";928    const backBtn = document.createElement("button"); backBtn.className = "back-btn";929    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>`;930    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });931    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";932    head.append(backBtn, lbl);933934    // Body935    const body = document.createElement("div"); body.className = "create-body";936937    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {938      const g = document.createElement("div"); g.className = "f-group";939      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;940      const inp = document.createElement("input"); inp.className = "f-input";941      g.append(l, inp); return { g, inp };942    }943944    // Title945    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";946    body.appendChild(tg.g);947948    // URL949    const ug = fGroup("website");950    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";951    if (loginSubdomain) {952      const h = document.createElement("div"); h.className = "url-hint";953      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);954    }955    body.appendChild(ug.g);956957    // Username (pre-fill from anchor field)958    const xg = fGroup("username / email");959    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";960    xg.inp.setAttribute("autocomplete", "off");961    body.appendChild(xg.g);962963    // Password + generate964    const pg = fGroup("password");965    const pwInp = document.createElement("input"); pwInp.className = "f-input";966    pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);967    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';968    pwInp.style.letterSpacing = "0.04em";969    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");970    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";971    genBtn.innerHTML = genSvgStr() + " regen";972    genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });973    const pwRow = document.createElement("div"); pwRow.className = "f-row";974    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);975    body.appendChild(pg.g);976977    // Save978    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";979    saveBtn.textContent = "Save entry";980    const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";981    function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }982    saveBtn.addEventListener("click", async () => {983      const title = tg.inp.value.trim();984      const pw = pwInp.value.trim();985      const username = xg.inp.value.trim();986      if (!title) {987        tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();988        showFormError("Entry needs a title");989        return;990      }991      if (!pw) {992        pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();993        showFormError("Entry needs a password");994        return;995      }996      errEl.style.display = "none";997      saveBtn.disabled = true; saveBtn.textContent = "Saving…";998      try {999        const res = await chrome.runtime.sendMessage({1000          type: "ADD_ENTRY",1001          payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },1002        });1003        if ((res as { ok?: boolean }).ok) {1004          // Fill the detected form regardless of mode. For login the user1005          // created an entry to use here; for signup the password was just1006          // generated for this site. Either way the credentials land in1007          // the form. options.onFillFields is wired by the caller (index.ts)1008          // and also records the form into filledForms so the popup will1009          // not re-attach to the filled fields.1010          options.onFillFields?.(username, pw);1011          showSuccess(title);1012        } else {1013          const errMsg = (res as { error?: string }).error === "vault locked"1014            ? "Vault locked — open Peach to save"1015            : "Could not save entry";1016          showFormError(errMsg);1017          saveBtn.disabled = false; saveBtn.textContent = "Save entry";1018        }1019      } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }1020    });1021    body.appendChild(saveBtn);1022    body.appendChild(errEl);10231024    wrap.append(head, body);1025    container.appendChild(wrap);1026    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });1027  }10281029  function showSuccess(title: string) {1030    currentMode = "success";1031    container.innerHTML = "";1032    const wrap = document.createElement("div"); wrap.className = "success-wrap";1033    const ring = document.createElement("div"); ring.className = "success-ring";1034    ring.innerHTML = checkSvgStr();1035    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;1036    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;1037    wrap.append(ring, t, s);1038    container.appendChild(wrap);1039    setTimeout(() => dismiss(), 1600);1040  }10411042  function flashActionBtn(btn: HTMLButtonElement) {1043    btn.classList.add("flashed");1044    setTimeout(() => btn.classList.remove("flashed"), 700);1045  }10461047  function renderSilverPlatter(entry: SelectorEntry) {1048    container.innerHTML = "";1049    searchInput = null;1050    focusedIndex = -1;1051    currentMode = "silver-platter";10521053    const domHdr = document.createElement("div");1054    domHdr.className = "domain-header";1055    const eyebrow = document.createElement("div");1056    eyebrow.className = "domain-eyebrow";1057    eyebrow.textContent = "Saved for this site";1058    const domName = document.createElement("div");1059    domName.className = "domain-name";1060    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");1061    domHdr.append(eyebrow, domName);1062    container.appendChild(domHdr);10631064    const platter = document.createElement("div");1065    platter.className = "silver-platter";10661067    const titleEl = document.createElement("div");1068    titleEl.className = "sp-title";1069    titleEl.textContent = entry.title;1070    platter.appendChild(titleEl);10711072    if (entry.username) {1073      const userEl = document.createElement("div");1074      userEl.className = "sp-username";1075      userEl.textContent = entry.username;1076      platter.appendChild(userEl);1077    }10781079    const fillBtn = document.createElement("button");1080    fillBtn.className = "sp-fill-btn";1081    fillBtn.type = "button";1082    fillBtn.textContent = "Fill credentials";1083    fillBtn.addEventListener("click", () => {1084      if (fillBtn.disabled) return;1085      fillBtn.disabled = true;1086      fillBtn.classList.add("filled");1087      fillBtn.innerHTML = checkSvgStr();1088      // Fire the fill, then exit via the standard exit animation. The fill1089      // itself is async (handled in index.ts) — by 400ms the form is filled1090      // and the popup begins its exit. Total dwell from click to host1091      // removal: 400ms + 130ms (exit animation) = 530ms.1092      onSelect(entry.id);1093      setTimeout(() => dismiss(), 400);1094    });1095    platter.appendChild(fillBtn);10961097    const actions = document.createElement("div");1098    actions.className = "sp-actions";10991100    const copyU = document.createElement("button");1101    copyU.className = "sp-action-btn";1102    copyU.type = "button";1103    copyU.title = "Copy username";1104    copyU.textContent = "Copy username";1105    copyU.disabled = !entry.username;1106    copyU.addEventListener("click", async (e) => {1107      e.stopPropagation();1108      if (!entry.username) return;1109      let alreadyWritten = false;1110      try { await navigator.clipboard.writeText(entry.username); alreadyWritten = true; } catch {}1111      // Mirror the popup's copy path so the COPY message can clear the1112      // clipboard via the configured timeout. alreadyWritten lets the SW1113      // skip a redundant write when the page-level write succeeded.1114      await chrome.runtime.sendMessage({1115        type: "COPY",1116        payload: { text: entry.username, label: "Username", alreadyWritten },1117      }).catch(() => {});1118      flashActionBtn(copyU);1119    });1120    actions.appendChild(copyU);11211122    const copyP = document.createElement("button");1123    copyP.className = "sp-action-btn";1124    copyP.type = "button";1125    copyP.title = "Copy password";1126    copyP.textContent = "Copy password";1127    copyP.addEventListener("click", async (e) => {1128      e.stopPropagation();1129      const resp = await chrome.runtime.sendMessage({1130        type: "GET_ENTRY_FOR_FILL",1131        payload: { id: entry.id },1132      }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;1133      if (!resp?.ok || !resp.data?.password) return;1134      let alreadyWritten = false;1135      try { await navigator.clipboard.writeText(resp.data.password); alreadyWritten = true; } catch {}1136      await chrome.runtime.sendMessage({1137        type: "COPY",1138        payload: { text: resp.data.password, label: "Password", alreadyWritten },1139      }).catch(() => {});1140      flashActionBtn(copyP);1141    });1142    actions.appendChild(copyP);11431144    let trayEl: HTMLDivElement | null = null;1145    let trayInterval: ReturnType<typeof setInterval> | null = null;11461147    function closeTray() {1148      if (trayInterval) { clearInterval(trayInterval); trayInterval = null; }1149      trayEl?.remove();1150      trayEl = null;1151    }1152    // Register so dismiss() will close the tray and clear the interval1153    // even if the user dismisses without explicitly closing the tray.1154    cleanupHooks.push(closeTray);11551156    async function openTOTPTray() {1157      // Fetch the entry to get the TOTP secret. The selector summary doesn't1158      // carry it (and shouldn't — passing secrets only at fill/copy time keeps1159      // the message-passing surface minimal per Projects.md §5.2).1160      const resp = await chrome.runtime.sendMessage({1161        type: "GET_ENTRY_FOR_FILL",1162        payload: { id: entry.id },1163      }).catch(() => null) as { ok: boolean; data?: { totp?: string } } | null;1164      const secret = resp?.ok ? resp.data?.totp : undefined;1165      if (!secret) return;11661167      trayEl = document.createElement("div");1168      trayEl.className = "sp-tray";11691170      const label = document.createElement("span");1171      label.className = "sp-tray-label";1172      label.textContent = "2FA";1173      trayEl.appendChild(label);11741175      const codeEl = document.createElement("div");1176      codeEl.className = "sp-totp-code";1177      trayEl.appendChild(codeEl);11781179      const ringNs = "http://www.w3.org/2000/svg";1180      const ring = document.createElementNS(ringNs, "svg");1181      ring.setAttribute("class", "sp-totp-ring");1182      ring.setAttribute("viewBox", "0 0 24 24");1183      const trackC = document.createElementNS(ringNs, "circle");1184      trackC.setAttribute("class", "ring-track");1185      trackC.setAttribute("cx", "12"); trackC.setAttribute("cy", "12"); trackC.setAttribute("r", "10");1186      trackC.setAttribute("fill", "none"); trackC.setAttribute("stroke-width", "2");1187      const fillC = document.createElementNS(ringNs, "circle");1188      fillC.setAttribute("class", "ring-fill");1189      fillC.setAttribute("cx", "12"); fillC.setAttribute("cy", "12"); fillC.setAttribute("r", "10");1190      fillC.setAttribute("fill", "none"); fillC.setAttribute("stroke-width", "2");1191      fillC.setAttribute("stroke-linecap", "round");1192      fillC.setAttribute("stroke-dasharray", "62.83");1193      ring.append(trackC, fillC);1194      trayEl.appendChild(ring);11951196      const copyBtn = document.createElement("button");1197      copyBtn.className = "sp-totp-copy";1198      copyBtn.type = "button";1199      copyBtn.textContent = "Copy";1200      trayEl.appendChild(copyBtn);12011202      function refresh() {1203        const { code, remaining } = generateTOTP(secret!);1204        // Group as "847 291" — easier to read than "847291"1205        codeEl.textContent = code.length === 61206          ? `${code.slice(0, 3)} ${code.slice(3)}`1207          : code;1208        // Ring fills counter-clockwise as time elapses: dashoffset goes from1209        // 0 (full ring) to 62.83 (empty) over the 30s window.1210        const fraction = (30 - remaining) / 30;1211        fillC.setAttribute("stroke-dashoffset", String(62.83 * fraction));1212      }1213      refresh();1214      trayInterval = setInterval(refresh, 1000);12151216      copyBtn.addEventListener("click", async (e) => {1217        e.stopPropagation();1218        const { code } = generateTOTP(secret!);1219        let alreadyWritten = false;1220        try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}1221        await chrome.runtime.sendMessage({1222          type: "COPY",1223          payload: { text: code, label: "TOTP", alreadyWritten },1224        }).catch(() => {});1225        copyBtn.textContent = "Copied";1226        setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = "Copy"; }, 1200);1227      });12281229      platter.appendChild(trayEl);1230    }12311232    if (entry.hasTotp) {1233      const moreBtn = document.createElement("button");1234      moreBtn.className = "sp-action-btn";1235      moreBtn.type = "button";1236      moreBtn.title = "Show 2FA code";1237      moreBtn.textContent = "···";1238      moreBtn.addEventListener("click", (e) => {1239        e.stopPropagation();1240        if (trayEl) {1241          closeTray();1242          moreBtn.classList.remove("flashed");1243        } else {1244          moreBtn.classList.add("flashed");1245          void openTOTPTray();1246        }1247      });1248      actions.appendChild(moreBtn);1249    }12501251    const closeBtn = document.createElement("button");1252    closeBtn.className = "sp-action-btn";1253    closeBtn.type = "button";1254    closeBtn.title = "Dismiss";1255    closeBtn.textContent = "✕";1256    closeBtn.addEventListener("click", (e) => {1257      e.stopPropagation();1258      closeTray();1259      dismiss(true);1260    });1261    actions.appendChild(closeBtn);12621263    platter.appendChild(actions);1264    container.appendChild(platter);1265  }12661267  function renderLocked() {1268    container.innerHTML = "";12691270    // Domain header (same as list view)1271    const domHdr = document.createElement("div");1272    domHdr.className = "domain-header";1273    const eyebrow = document.createElement("div");1274    eyebrow.className = "domain-eyebrow";1275    eyebrow.textContent = "vault locked";1276    const domName = document.createElement("div");1277    domName.className = "domain-name";1278    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");1279    domHdr.append(eyebrow, domName);1280    container.appendChild(domHdr);12811282    const wrap = document.createElement("div");1283    wrap.className = "locked-wrap";12841285    // Icon + label row1286    const head = document.createElement("div"); head.className = "locked-head";1287    const icon = document.createElement("div"); icon.className = "locked-icon";1288    icon.innerHTML = keySvgStr();1289    const title = document.createElement("div"); title.className = "locked-title";1290    title.textContent = "unlock to autofill";1291    head.append(icon, title);12921293    // Password input1294    const pwInp = document.createElement("input");1295    pwInp.className = "locked-input";1296    pwInp.type = "password";1297    pwInp.placeholder = "master password";1298    pwInp.setAttribute("autocomplete", "current-password");12991300    // Error line (hidden initially)1301    const errEl = document.createElement("div");1302    errEl.className = "locked-error";1303    errEl.style.display = "none";13041305    // Unlock button1306    const unlockBtn = document.createElement("button");1307    unlockBtn.className = "locked-btn";1308    unlockBtn.type = "button";1309    unlockBtn.textContent = "unlock";13101311    async function tryUnlock() {1312      const pw = pwInp.value.trim();1313      if (!pw) { pwInp.focus(); return; }1314      unlockBtn.disabled = true;1315      unlockBtn.textContent = "unlocking…";1316      errEl.style.display = "none";1317      try {1318        const res = await chrome.runtime.sendMessage({1319          type: "UNLOCK",1320          payload: { password: pw },1321        });1322        if ((res as { ok?: boolean }).ok) {1323          dismiss();1324          options.onUnlocked?.();1325        } else {1326          errEl.textContent = "incorrect password";1327          errEl.style.display = "block";1328          pwInp.value = "";1329          pwInp.focus();1330          unlockBtn.disabled = false;1331          unlockBtn.textContent = "unlock";1332        }1333      } catch {1334        errEl.textContent = "could not reach Peach";1335        errEl.style.display = "block";1336        unlockBtn.disabled = false;1337        unlockBtn.textContent = "unlock";1338      }1339    }13401341    pwInp.addEventListener("keydown", (e) => {1342      e.stopPropagation();1343      if (e.key === "Enter") void tryUnlock();1344    });1345    pwInp.addEventListener("keyup", (e) => e.stopPropagation());1346    pwInp.addEventListener("keypress", (e) => e.stopPropagation());1347    unlockBtn.addEventListener("click", () => void tryUnlock());13481349    wrap.append(head, pwInp, errEl, unlockBtn);1350    container.appendChild(wrap);13511352    // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)1353    // the page reclaims focus from our Shadow DOM input before the user can1354    // type. Fighting over focus with a focusout→refocus loop hangs the tab.1355    // Instead: intercept keystrokes at the document capture phase — this runs1356    // before any page handler regardless of which element technically has1357    // OS focus — and route them into our input.1358    const captureKeydown = (e: KeyboardEvent) => {1359      if (!host.isConnected) {1360        document.removeEventListener("keydown", captureKeydown, true);1361        return;1362      }1363      // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.1364      if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;1365      // Intercept before page handlers and give our input focus.1366      e.stopImmediatePropagation();1367      if (e.key === "Escape") { dismiss(true); return; }1368      if (e.key === "Enter") { void tryUnlock(); return; }1369      // For all other printable/editing keys, ensure our input has focus so1370      // the browser delivers the keystroke there naturally.1371      if (document.activeElement !== host) {1372        pwInp.focus({ preventScroll: true });1373      }1374    };1375    captureKeydownRef = captureKeydown;1376    document.addEventListener("keydown", captureKeydown, true);13771378    // Best-effort focus on open — may be overridden by page focus trap,1379    // but the capture listener above handles typing regardless.1380    pwInp.focus({ preventScroll: true });1381    requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });1382  }13831384  function handleKeyDown(e: KeyboardEvent) {1385    if (options.locked) {1386      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1387      return;1388    }1389    if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") {1390      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1391      return;1392    }1393    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");1394    const count = allEntryEls.length;1395    if (document.activeElement === searchInput) {1396      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }1397      if (count === 0) return;1398      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;1399    }1400    if (count === 0) return;1401    switch (e.key) {1402      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;1403      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;1404      case "Enter": {1405        if (focusedIndex >= 0) {1406          const el = allEntryEls[focusedIndex] as HTMLElement;1407          const id = el.dataset.id;1408          if (id) { onSelect(id); dismiss(); }1409          else if (el.classList.contains("is-new") || !id) {1410            // "New entry" row (last item, no data-id)1411            showCreateForm();1412          }1413        }1414        break;1415      }1416      case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;1417    }1418  }14191420  document.addEventListener("keydown", handleKeyDown);14211422  document.body.appendChild(host);14231424  function updatePosition() {1425    const r = anchorEl.getBoundingClientRect();1426    host.style.top = `${r.bottom + window.scrollY + 5}px`;1427    host.style.left = `${r.left + window.scrollX}px`;1428    host.style.width = `${Math.max(r.width, 260)}px`;1429  }1430  // These bindings are referenced from renderLocked()/dismiss() — they MUST1431  // be initialized before renderLocked() runs, or the synchronous assignment1432  // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ1433  // and throws ReferenceError. The throw lands after the popup is rendered1434  // but before showAutofillSelector returns its dismiss handle, leaving an1435  // orphan host in the DOM (which is what caused the locked-popup stacking1436  // and missing autofocus on every click).1437  let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;1438  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);14391440  if (options.locked) {1441    renderLocked();1442  } else if (entries.length === 1 && !options.isSignup) {1443    // Silver platter (State 1): single domain match → focused fill view.1444    // Multi-match goes through renderList instead. Signup mode always uses1445    // the list because the user is choosing which existing identity to1446    // surface — there's no single "fill credentials" interpretation.1447    renderSilverPlatter(entries[0]!);1448  } else {1449    renderList();1450  }14511452  let exitScheduled = false;1453  function dismiss(userAction = false) {1454    // Idempotent: dismiss() can be called from multiple paths (Escape,1455    // outside click, entry select, fill success, host page nav). The exit1456    // animation must only play once and host.remove() must run exactly once.1457    if (exitScheduled) return;1458    exitScheduled = true;1459    // Detach listeners synchronously so nothing can re-trigger during the1460    // 130ms exit window (a focusin firing here would race against the1461    // dismiss-lock work in Pass 2).1462    stopObserving();1463    document.removeEventListener("click", outsideClick, { capture: true });1464    document.removeEventListener("keydown", handleKeyDown);1465    if (captureKeydownRef) {1466      document.removeEventListener("keydown", captureKeydownRef, true);1467      captureKeydownRef = null;1468    }1469    for (const cleanup of cleanupHooks) {1470      try { cleanup(); } catch { /* defensive — one hook must not block others */ }1471    }1472    if (userAction) options.onDismiss?.();1473    container.classList.add("is-exiting");1474    setTimeout(() => host.remove(), 130);1475  }14761477  function outsideClick(ev: MouseEvent) {1478    const path = ev.composedPath();1479    // Never dismiss when clicking the anchor field itself — that click is1480    // what triggered the selector to open, and the user may click the field1481    // again to re-focus it without intending to close the selector.1482    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);1483  }1484  // capture: true so pages that call stopPropagation() on click don't block our dismiss handler1485  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);14861487  return dismiss;1488}14891490export interface CardSelectorEntry {1491  id: string;1492  title: string;1493  cardholderName?: string;1494  last4?: string;1495  expMonth?: string;1496  expYear?: string;1497  network?: import("../shared/types").CardNetwork;1498}14991500interface CardSelectorOptions {1501  domain?: string;1502  /** Caller-provided fill — receives the full CardEntry payload from the SW. */1503  onFill: (entry: import("../shared/types").VaultEntry) => void;1504  /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */1505  onDismiss?: () => void;1506}15071508/**1509 * State 7 — card silver platter. Single-card focused fill view rendered1510 * over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the1511 * theme tokens, and the .sp-fill-btn / .sp-actions primary action classes1512 * so card and login flows render identically. Multi-card list view is a1513 * follow-up; today this picks the first card.1514 */1515export function showCardSelector(1516  anchorEl: HTMLInputElement,1517  cards: CardSelectorEntry[],1518  options: CardSelectorOptions1519): () => void {1520  if (cards.length === 0) return () => {};1521  const card = cards[0]!;15221523  const host = document.createElement("div");1524  host.style.cssText = "position: absolute; z-index: 2147483647;";1525  host.style.setProperty("--peach-accent", accentHex);1526  host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));1527  host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));1528  host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));1529  const shadow = host.attachShadow({ mode: "closed" });15301531  const style = shadow.appendChild(document.createElement("style"));1532  style.textContent = SELECTOR_STYLES;15331534  const rect = anchorEl.getBoundingClientRect();1535  host.style.top = `${rect.bottom + window.scrollY + 5}px`;1536  host.style.left = `${rect.left + window.scrollX}px`;1537  host.style.width = `${Math.max(rect.width, 320)}px`;15381539  const container = shadow.appendChild(document.createElement("div"));1540  container.className = "peach-inline-popup";1541  container.setAttribute("data-theme", popupTheme);15421543  // Domain header — eyebrow communicates the card context, not the site1544  const domHdr = document.createElement("div");1545  domHdr.className = "domain-header";1546  const eyebrow = document.createElement("div");1547  eyebrow.className = "domain-eyebrow";1548  eyebrow.textContent = "Pay with Peach";1549  const domName = document.createElement("div");1550  domName.className = "domain-name";1551  domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");1552  domHdr.append(eyebrow, domName);1553  container.appendChild(domHdr);15541555  const platter = document.createElement("div");1556  platter.className = "card-platter";15571558  if (card.network && card.network !== "unknown") {1559    const net = document.createElement("div");1560    net.className = "cp-network";1561    net.textContent = card.network;1562    platter.appendChild(net);1563  }15641565  const nick = document.createElement("div");1566  nick.className = "cp-nickname";1567  nick.textContent = card.title;1568  platter.appendChild(nick);15691570  if (card.last4) {1571    const l4 = document.createElement("div");1572    l4.className = "cp-last4";1573    l4.textContent = `•••• ${card.last4}`;1574    platter.appendChild(l4);1575  }15761577  const meta = document.createElement("div");1578  meta.className = "cp-meta";1579  if (card.cardholderName) {1580    const name = document.createElement("span");1581    name.textContent = card.cardholderName;1582    meta.appendChild(name);1583  }1584  if (card.expMonth && card.expYear) {1585    if (card.cardholderName) {1586      const sep = document.createElement("span");1587      sep.className = "cp-meta-sep";1588      sep.textContent = "·";1589      meta.appendChild(sep);1590    }1591    const exp = document.createElement("span");1592    const yy = card.expYear.length === 4 ? card.expYear.slice(-2) : card.expYear;1593    exp.textContent = `${card.expMonth.padStart(2, "0")}/${yy}`;1594    meta.appendChild(exp);1595  }1596  if (meta.childElementCount > 0) platter.appendChild(meta);15971598  const fillWrap = document.createElement("div");1599  fillWrap.className = "cp-fill-wrap";1600  const fillBtn = document.createElement("button");1601  fillBtn.className = "sp-fill-btn";1602  fillBtn.type = "button";1603  fillBtn.textContent = "Fill card details";1604  fillBtn.addEventListener("click", async () => {1605    if (fillBtn.disabled) return;1606    fillBtn.disabled = true;1607    fillBtn.classList.add("filled");1608    fillBtn.innerHTML = checkSvgStr();1609    const resp = await chrome.runtime.sendMessage({1610      type: "GET_ENTRY_FOR_FILL",1611      payload: { id: card.id },1612    }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;1613    if (resp?.ok && resp.data) options.onFill(resp.data);1614    setTimeout(() => dismiss(), 400);1615  });1616  fillWrap.appendChild(fillBtn);1617  platter.appendChild(fillWrap);16181619  // Secondary actions — copy number, copy CVC, dismiss1620  const actions = document.createElement("div");1621  actions.className = "sp-actions";16221623  function flashBtn(b: HTMLButtonElement) {1624    b.classList.add("flashed");1625    setTimeout(() => b.classList.remove("flashed"), 700);1626  }16271628  async function fetchFullCard(): Promise<import("../shared/types").VaultEntry | null> {1629    const resp = await chrome.runtime.sendMessage({1630      type: "GET_ENTRY_FOR_FILL",1631      payload: { id: card.id },1632    }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;1633    return resp?.ok && resp.data ? resp.data : null;1634  }16351636  const copyNum = document.createElement("button");1637  copyNum.className = "sp-action-btn";1638  copyNum.type = "button";1639  copyNum.title = "Copy card number";1640  copyNum.textContent = "Copy number";1641  copyNum.addEventListener("click", async (e) => {1642    e.stopPropagation();1643    const full = await fetchFullCard();1644    if (!full?.cardNumber) return;1645    let alreadyWritten = false;1646    try { await navigator.clipboard.writeText(full.cardNumber); alreadyWritten = true; } catch {}1647    await chrome.runtime.sendMessage({1648      type: "COPY",1649      payload: { text: full.cardNumber, label: "Card number", alreadyWritten },1650    }).catch(() => {});1651    flashBtn(copyNum);1652  });1653  actions.appendChild(copyNum);16541655  const copyCvc = document.createElement("button");1656  copyCvc.className = "sp-action-btn";1657  copyCvc.type = "button";1658  copyCvc.title = "Copy CVC";1659  copyCvc.textContent = "Copy CVC";1660  copyCvc.addEventListener("click", async (e) => {1661    e.stopPropagation();1662    const full = await fetchFullCard();1663    if (!full?.cvc) return;1664    let alreadyWritten = false;1665    try { await navigator.clipboard.writeText(full.cvc); alreadyWritten = true; } catch {}1666    await chrome.runtime.sendMessage({1667      type: "COPY",1668      payload: { text: full.cvc, label: "CVC", alreadyWritten },1669    }).catch(() => {});1670    flashBtn(copyCvc);1671  });1672  actions.appendChild(copyCvc);16731674  const closeBtn = document.createElement("button");1675  closeBtn.className = "sp-action-btn";1676  closeBtn.type = "button";1677  closeBtn.title = "Dismiss";1678  closeBtn.textContent = "✕";1679  closeBtn.addEventListener("click", (e) => {1680    e.stopPropagation();1681    dismiss(true);1682  });1683  actions.appendChild(closeBtn);16841685  platter.appendChild(actions);1686  container.appendChild(platter);16871688  document.body.appendChild(host);1689  function updatePosition() {1690    const r = anchorEl.getBoundingClientRect();1691    host.style.top = `${r.bottom + window.scrollY + 5}px`;1692    host.style.left = `${r.left + window.scrollX}px`;1693    host.style.width = `${Math.max(r.width, 320)}px`;1694  }1695  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);16961697  function onKeyDown(e: KeyboardEvent) {1698    if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1699  }1700  document.addEventListener("keydown", onKeyDown);17011702  let exitScheduled = false;1703  function dismiss(userAction = false) {1704    if (exitScheduled) return;1705    exitScheduled = true;1706    stopObserving();1707    document.removeEventListener("click", outsideClick, { capture: true });1708    document.removeEventListener("keydown", onKeyDown);1709    if (userAction) options.onDismiss?.();1710    container.classList.add("is-exiting");1711    setTimeout(() => host.remove(), 130);1712  }17131714  function outsideClick(ev: MouseEvent) {1715    const path = ev.composedPath();1716    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);1717  }1718  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);17191720  return dismiss;1721}