inject.ts

40.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 } from "../shared/types";1314let accentHex = "#E8956D";15chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {16  const name = resp?.data?.accentColor ?? "peach";17  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";18});1920export interface SelectorEntry {21  id: string;22  title: string;23  username?: string;24  url?: string;25  hasTotp?: boolean;26}2728interface SelectorOptions {29  domain?: string;30  searchableEntries?: SelectorEntry[];31  /** True when shown on a signup form — changes header label and entry-click behaviour */32  isSignup?: boolean;33  /**34   * Called after the user picks a saved identity (signup mode only) or35   * after successfully saving a new entry on a signup form.36   * Lets the caller fill the visible username + password fields.37   */38  onFillFields?: (username: string, password: string) => void;39  /**40   * When true, show an inline unlock form instead of the entries list.41   * Used when the vault is locked so the user can unlock without opening42   * the popup.43   */44  locked?: boolean;45  /**46   * Called after a successful inline unlock — the caller should re-query47   * entries and re-open the selector with real data.48   */49  onUnlocked?: () => void;50  /**51   * Called when the user explicitly dismisses the selector (Escape, Tab, or52   * click outside) — NOT when an entry is selected or on auto-close.53   * Used by the caller to suppress spurious reopens on the same field.54   */55  onDismiss?: () => void;56}5758function getDomainFromUrl(url: string): string {59  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }60}6162function keySvgStr(): string {63  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>`;64}6566function plusSvgStr(): string {67  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>`;68}6970function peachIconSvg(): string {71  // Peach body is always brand-peach (#D96830), not `accentHex` — a sage72  // or sky accent must not turn the autofill icon green or blue. Accent73  // color still tints surrounding glow/hover halos below.74  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>`;75}7677function genSvgStr(): string {78  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>`;79}8081function checkSvgStr(): string {82  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>`;83}8485function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {86  const { hostname, origin } = location;87  const parts = hostname.split(".");88  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",89    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",90    "oidc", "federation", "saml", "cas", "ldap"];91  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());92  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;93  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);94  return { origin, title, loginSubdomain };95}9697/**98 * Shows a bottom-of-page toast after filling credentials when the entry's99 * saved domain doesn't match the current site — offering to set the current100 * origin as the entry's default URL. Closes automatically after 9 seconds.101 */102export function showDomainConfirmToast(103  pageDomain: string,104  entryId: string,105): void {106  const host = document.createElement("div");107  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";108  host.style.setProperty("--peach-accent", accentHex);109  const shadow = host.attachShadow({ mode: "closed" });110111  const style = document.createElement("style");112  style.textContent = `113    .bar {114      display:flex;align-items:center;gap:10px;padding:10px 14px;115      background:#131109;border:1px solid rgba(240,232,216,0.09);116      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);117      font-family:Inter,sans-serif;white-space:nowrap;118      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;119    }120    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }121    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }122    .yes {123      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;124      background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;125    }126    .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }127    .no {128      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;129      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;130    }131  `;132  shadow.appendChild(style);133134  const bar = document.createElement("div"); bar.className = "bar";135  const txt = document.createElement("span"); txt.className = "txt";136  txt.textContent = `Set ${pageDomain} as default for this entry?`;137  const yesBtn = document.createElement("button"); yesBtn.className = "yes";138  yesBtn.textContent = "Set default";139  yesBtn.addEventListener("click", async () => {140    await chrome.runtime.sendMessage({141      type: "UPDATE_ENTRY",142      payload: { id: entryId, patch: { url: location.origin } },143    });144    host.remove();145  });146  const noBtn = document.createElement("button"); noBtn.className = "no";147  noBtn.textContent = "Skip";148  noBtn.addEventListener("click", () => host.remove());149  bar.append(txt, yesBtn, noBtn);150  shadow.appendChild(bar);151  document.body.appendChild(host);152  setTimeout(() => host.remove(), 9000);153}154155function generatePassword(): string {156  const u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", l = "abcdefghijklmnopqrstuvwxyz";157  const n = "0123456789", s = "!@#$%^&*";158  const all = u + l + n + s;159  const arr = new Uint8Array(20);160  crypto.getRandomValues(arr);161  const res = [u[arr[0]! % u.length]!, l[arr[1]! % l.length]!, n[arr[2]! % n.length]!, s[arr[3]! % s.length]!,162    ...Array.from(arr.slice(4), b => all[b % all.length]!)];163  for (let i = res.length - 1; i > 0; i--) {164    const j = arr[i % arr.length]! % (i + 1);165    [res[i], res[j]] = [res[j]!, res[i]!];166  }167  return res.join("");168}169170export function observeAnchorPosition(171  anchorEl: HTMLElement,172  onUpdate: () => void173): () => void {174  let rafId = 0;175  let burstUntil = 0;176177  const runUpdate = () => {178    rafId = 0;179    onUpdate();180    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);181  };182  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };183  const scheduleBurst = (ms = 220) => {184    burstUntil = Math.max(burstUntil, performance.now() + ms);185    scheduleUpdate();186  };187188  const resizeObserver = new ResizeObserver(() => scheduleBurst());189  resizeObserver.observe(anchorEl);190  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);191192  const mutationObserver = new MutationObserver(() => scheduleBurst());193  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });194  if (anchorEl.parentElement) {195    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });196  }197198  const onScroll = () => scheduleUpdate();199  window.addEventListener("scroll", onScroll, true);200  window.addEventListener("resize", onScroll);201  window.visualViewport?.addEventListener("resize", onScroll);202  window.visualViewport?.addEventListener("scroll", onScroll);203  anchorEl.addEventListener("focus", () => scheduleBurst());204  anchorEl.addEventListener("input", () => scheduleBurst());205206  scheduleBurst(260);207208  return () => {209    if (rafId) cancelAnimationFrame(rafId);210    resizeObserver.disconnect();211    mutationObserver.disconnect();212    window.removeEventListener("scroll", onScroll, true);213    window.removeEventListener("resize", onScroll);214    window.visualViewport?.removeEventListener("resize", onScroll);215    window.visualViewport?.removeEventListener("scroll", onScroll);216  };217}218219export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {220  const button = document.createElement("button");221  button.type = "button";222  button.setAttribute("aria-label", "Peach autofill");223  button.title = "Fill with Peach";224  button.innerHTML = peachIconSvg();225  // position: fixed is load-bearing — not absolute. Absolute positioning226  // inherits the nearest positioned ancestor's box, which on sites with227  // overflow:hidden or sticky parents clips or offsets the icon. Fixed228  // anchors to the viewport and escapes all parent layout.229  button.style.cssText = `230    position: fixed; z-index: 2147483646;231    width: 26px; height: 26px; cursor: pointer;232    pointer-events: auto; border: none; padding: 3px;233    display: inline-flex; align-items: center; justify-content: center;234    background: transparent; border-radius: 999px;235    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;236    opacity: 0;237  `;238  button.addEventListener("mouseenter", () => {239    button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;240    button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;241    button.style.transform = "scale(1.08)";242  });243  button.addEventListener("mouseleave", () => {244    button.style.background = "transparent";245    button.style.boxShadow = "none";246    button.style.transform = "none";247  });248  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });249  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });250251  document.body.appendChild(button);252253  function updatePosition() {254    const rect = field.getBoundingClientRect();255    if (rect.width === 0 || rect.height === 0) {256      button.style.opacity = "0"; button.style.pointerEvents = "none";257      return;258    }259    const sz = 26;260    const inset = Math.max(5, Math.min(10, rect.height * 0.22));261    const top = rect.top + rect.height / 2 - sz / 2;262    // Clamp left so the icon stays fully inside the field's right edge, never263    // spilling onto an adjacent sibling button.264    const minLeft = rect.left + 4;265    const left = Math.max(minLeft, rect.right - sz - inset);266    // Hide if the field is too narrow to fit the icon cleanly (< 40px)267    if (rect.width < 40) {268      button.style.opacity = "0"; button.style.pointerEvents = "none";269      return;270    }271    button.style.top = `${top}px`;272    button.style.left = `${left}px`;273    button.style.opacity = "1"; button.style.pointerEvents = "auto";274  }275  updatePosition();276  requestAnimationFrame(() => { button.style.opacity = "1"; });277  const stopObserving = observeAnchorPosition(field, updatePosition);278  return () => { stopObserving(); button.remove(); };279}280281// ── Shared selector + new-entry styles ────────────────────────────282const SELECTOR_STYLES = `283  :host { all: initial; }284  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }285  .selector {286    background: #0f0e0b;287    border: 1px solid rgba(240,232,216,0.10);288    border-radius: 13px;289    overflow: hidden;290    box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7);291    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;292    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;293  }294  @keyframes sl-in {295    from { opacity:0; transform:translateY(-6px) scale(0.97) }296    to   { opacity:1; transform:none }297  }298299  /* Domain header */300  .domain-header {301    padding: 9px 13px 7px;302    border-bottom: 1px solid rgba(240,232,216,0.055);303    display: flex; align-items: center; justify-content: space-between;304  }305  .domain-eyebrow {306    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;307    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;308  }309  .domain-name {310    font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);311    font-family: "IBM Plex Mono", monospace;312  }313314  /* Search */315  .search-wrap {316    padding: 9px 12px 7px;317    border-bottom: 1px solid rgba(240,232,216,0.04);318  }319  .search {320    width: 100%; background: rgba(240,232,216,0.03);321    border: 1px solid rgba(240,232,216,0.08);322    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;323    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;324    transition: border-color 120ms, box-shadow 120ms;325  }326  .search:focus {327    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);328    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);329  }330  .search::placeholder { color: rgba(240,232,216,0.2); }331332  /* Results */333  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }334  .results::-webkit-scrollbar { display: none; }335336  /* Entry rows */337  .entry {338    display: flex; align-items: center; padding: 8px 12px;339    cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04);340    transition: background 70ms; position: relative; outline: none;341  }342  .entry:last-child { border-bottom: none; }343  .entry:hover { background: rgba(240,232,216,0.04); }344  .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }345  .entry.focused::before {346    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;347    width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent);348  }349350  .entry-icon {351    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;352    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07);353    display: flex; align-items: center; justify-content: center;354    overflow: hidden; margin-right: 10px;355  }356  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }357  .entry-icon.is-new {358    background: color-mix(in srgb, var(--peach-accent) 8%, transparent);359    border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);360    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);361  }362363  .entry-info { flex: 1; min-width: 0; }364  .entry-title {365    font-size: 12.5px; color: #F0E8D8;366    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;367    line-height: 1.3;368  }369  .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }370  .entry-sub {371    font-size: 10px; color: rgba(240,232,216,0.32);372    font-family: "IBM Plex Mono", monospace;373    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;374    margin-top: 1px;375  }376377  .totp-badge {378    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;379    border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);380    color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace;381    flex-shrink: 0; margin-left: 8px;382  }383384  .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; }385386  .empty-hint {387    padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28);388    font-family: "IBM Plex Mono", monospace;389  }390391  /* ── Create form ─────────────────────────────────────── */392  .create-form-wrap {393    animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both;394  }395  @keyframes cf-in {396    from { opacity:0; transform:translateX(8px) }397    to { opacity:1; transform:none }398  }399  .create-head {400    display: flex; align-items: center; gap: 8px;401    padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055);402  }403  .back-btn {404    background: transparent; border: none; cursor: pointer;405    color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px;406    display: flex; align-items: center; line-height: 0;407    transition: color 100ms, background 100ms;408  }409  .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); }410  .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; }411  .create-body { padding: 12px; display: grid; gap: 9px; }412  .f-group { display: grid; gap: 3px; }413  .f-label {414    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;415    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;416  }417  .f-input {418    background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);419    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;420    font-size: 11px; font-family: "IBM Plex Mono", monospace;421    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;422  }423  .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); }424  .f-input::placeholder { color: rgba(240,232,216,0.18); }425  .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; }426  .f-row { display: flex; gap: 6px; }427  .f-row .f-input { flex: 1; min-width: 0; }428  .gen-btn {429    padding: 0 10px; border-radius: 7px; border: none;430    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);431    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);432    font-size: 10px; font-family: "IBM Plex Mono", monospace;433    cursor: pointer; white-space: nowrap; flex-shrink: 0;434    display: flex; align-items: center; gap: 4px;435    transition: background 100ms;436  }437  .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }438  .save-btn {439    width: 100%; padding: 8px; border-radius: 8px; border: none;440    background: var(--peach-accent); color: #fff; font-size: 12px;441    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;442    transition: background 120ms; letter-spacing: 0.01em;443  }444  .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }445  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }446  .url-hint {447    font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);448    font-family: "IBM Plex Mono", monospace; font-style: italic;449  }450451  /* ── Success ─────────────────────────────────────────── */452  .success-wrap {453    display: flex; flex-direction: column; align-items: center;454    padding: 24px 16px 20px; gap: 8px;455    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;456  }457  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }458  .success-ring {459    width: 40px; height: 40px; border-radius: 50%;460    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);461    display: flex; align-items: center; justify-content: center;462  }463  .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; }464  .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; }465466  /* ── Locked state ────────────────────────────────────── */467  .locked-wrap {468    display: flex; flex-direction: column; align-items: stretch;469    padding: 14px 13px 13px; gap: 9px;470    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;471  }472  .locked-head {473    display: flex; align-items: center; gap: 8px; padding-bottom: 2px;474  }475  .locked-icon {476    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;477    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08);478    display: flex; align-items: center; justify-content: center;479  }480  .locked-title {481    font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500;482  }483  .locked-input {484    width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);485    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;486    font-size: 11px; font-family: "IBM Plex Mono", monospace;487    outline: none; transition: border-color 120ms, box-shadow 120ms;488  }489  .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); }490  .locked-input::placeholder { color: rgba(240,232,216,0.2); }491  .locked-btn {492    width: 100%; padding: 8px; border-radius: 8px; border: none;493    background: var(--peach-accent); color: #fff; font-size: 12px;494    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;495    transition: background 120ms; letter-spacing: 0.01em;496  }497  .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }498  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; }499  .locked-error {500    font-size: 10px; color: rgba(220,80,60,0.85);501    font-family: "IBM Plex Mono", monospace;502  }503`;504505export function showAutofillSelector(506  anchorEl: HTMLInputElement,507  entries: SelectorEntry[],508  onSelect: (id: string) => void,509  options: SelectorOptions = {}510): () => void {511  const host = document.createElement("div");512  host.style.cssText = "position: absolute; z-index: 2147483647;";513  host.style.setProperty("--peach-accent", accentHex);514  const shadow = host.attachShadow({ mode: "closed" });515516  const style = shadow.appendChild(document.createElement("style"));517  style.textContent = SELECTOR_STYLES;518519  const rect = anchorEl.getBoundingClientRect();520  host.style.top = `${rect.bottom + window.scrollY + 5}px`;521  host.style.left = `${rect.left + window.scrollX}px`;522  host.style.width = `${Math.max(rect.width, 260)}px`;523524  const container = shadow.appendChild(document.createElement("div"));525  container.className = "selector";526527  const searchableEntries = options.searchableEntries ?? [];528  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();529530  let query = "";531  let focusedIndex = -1;532  let currentMode: "list" | "create" | "success" = "list";533  let searchInput: HTMLInputElement | null = null;534535  // Pre-capture the anchor field value for username prefill536  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";537  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");538  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";539540  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {541    const seen = new Set<string>();542    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });543  }544545  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {546    const q = value.trim().toLowerCase();547    const deduped = uniqueEntries(source);548    if (!q) return deduped.slice(0, 8);549    return deduped.filter(e =>550      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)551    ).slice(0, 12);552  }553554  function activeEntries(): SelectorEntry[] {555    // When user is typing a search, filter across all known entries (domain matches + vault)556    if (query.trim()) {557      const merged = uniqueEntries([...entries, ...searchableEntries]);558      return filterEntries(merged, query);559    }560    // Default: show domain matches; fall back to vault search when no matches561    if (entries.length > 0) return entries;562    return filterEntries(searchableEntries, "").slice(0, 8);563  }564565  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {566    const icon = document.createElement("div");567    icon.className = `entry-icon${isNew ? " is-new" : ""}`;568    if (isNew) {569      icon.innerHTML = plusSvgStr();570    } else {571      // Use the generic key glyph for all entries in the page-injected selector.572      // Avoids disclosing vault domains to any third party and avoids needing573      // to expose the browser favicon API to page context via web_accessible_resources.574      void url;575      icon.innerHTML = keySvgStr();576    }577    return icon;578  }579580  function renderList() {581    container.innerHTML = "";582    focusedIndex = -1;583    searchInput = null;584585    const current = activeEntries();586587    // Domain header (always show)588    const domHdr = document.createElement("div");589    domHdr.className = "domain-header";590    const eyebrow = document.createElement("div");591    eyebrow.className = "domain-eyebrow";592    eyebrow.textContent = options.isSignup593      ? "Sign up as"594      : entries.length > 0 ? "Saved for this site" : "No saved login";595    const domName = document.createElement("div");596    domName.className = "domain-name";597    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");598    domHdr.append(eyebrow, domName);599    container.appendChild(domHdr);600601    // Search — always show so user can find entries beyond the domain matches602    const sw = document.createElement("div"); sw.className = "search-wrap";603    searchInput = document.createElement("input");604    searchInput.className = "search";605    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";606    searchInput.value = query;607    searchInput.addEventListener("input", () => {608      query = searchInput?.value ?? "";609      renderList();610      searchInput?.focus();611      searchInput?.setSelectionRange(query.length, query.length);612    });613    sw.appendChild(searchInput);614    container.appendChild(sw);615616    // Results617    const results = document.createElement("div");618    results.className = "results";619    container.appendChild(results);620621    if (current.length === 0 && entries.length > 0) {622      // Show a hint but don't render empty entries623    }624625    for (let i = 0; i < current.length; i++) {626      const e = current[i]!;627      const entryEl = document.createElement("div");628      entryEl.className = "entry";629      entryEl.dataset.id = e.id;630      entryEl.dataset.index = String(i);631      entryEl.setAttribute("tabindex", "-1");632633      entryEl.appendChild(makeEntryIcon(e.url));634635      const info = document.createElement("div"); info.className = "entry-info";636      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;637      const subEl = document.createElement("div"); subEl.className = "entry-sub";638      const domain = e.url ? getDomainFromUrl(e.url) : "";639      subEl.textContent = domain || e.username || "";640      info.append(titleEl, subEl);641      entryEl.appendChild(info);642643      if (e.hasTotp) {644        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";645        entryEl.appendChild(badge);646      }647648      entryEl.addEventListener("click", () => {649        if (options.isSignup && options.onFillFields) {650          // Signup mode: clicking a saved identity fills the username field651          options.onFillFields(e.username ?? "", "");652          dismiss();653        } else {654          onSelect(e.id);655          dismiss();656        }657      });658      results.appendChild(entryEl);659    }660661    // Divider before "New entry"662    if (current.length > 0) {663      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));664    }665666    // "New entry" row — always present667    const newEl = document.createElement("div");668    newEl.className = "entry";669    newEl.setAttribute("tabindex", "-1");670    newEl.appendChild(makeEntryIcon(undefined, true));671672    const newInfo = document.createElement("div"); newInfo.className = "entry-info";673    const newTitle = document.createElement("div");674    newTitle.className = "entry-title is-new";675    newTitle.textContent = `New entry for ${sTitle}`;676    const newSub = document.createElement("div");677    newSub.className = "entry-sub";678    newSub.textContent = sOrigin;679    newInfo.append(newTitle, newSub);680    newEl.appendChild(newInfo);681    newEl.addEventListener("click", showCreateForm);682    results.appendChild(newEl);683684    // Focus search if present685    if (searchInput) {686      requestAnimationFrame(() => searchInput?.focus());687    }688  }689690  function setFocused(idx: number) {691    const all = container.querySelectorAll<HTMLElement>(".entry");692    all.forEach((el, i) => el.classList.toggle("focused", i === idx));693    focusedIndex = idx;694    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });695  }696697  async function showCreateForm() {698    currentMode = "create";699    container.innerHTML = "";700701    // Guard: the service worker may lock the vault between when the selector702    // opens and when the user clicks "New entry". Show the inline unlock form703    // instead of silently failing on save.704    const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;705    if (!statusRes?.unlocked) {706      renderLocked();707      return;708    }709710    const wrap = document.createElement("div");711    wrap.className = "create-form-wrap";712713    // Header714    const head = document.createElement("div"); head.className = "create-head";715    const backBtn = document.createElement("button"); backBtn.className = "back-btn";716    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>`;717    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });718    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";719    head.append(backBtn, lbl);720721    // Body722    const body = document.createElement("div"); body.className = "create-body";723724    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {725      const g = document.createElement("div"); g.className = "f-group";726      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;727      const inp = document.createElement("input"); inp.className = "f-input";728      g.append(l, inp); return { g, inp };729    }730731    // Title732    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";733    body.appendChild(tg.g);734735    // URL736    const ug = fGroup("website");737    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";738    if (loginSubdomain) {739      const h = document.createElement("div"); h.className = "url-hint";740      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);741    }742    body.appendChild(ug.g);743744    // Username (pre-fill from anchor field)745    const xg = fGroup("username / email");746    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";747    xg.inp.setAttribute("autocomplete", "off");748    body.appendChild(xg.g);749750    // Password + generate751    const pg = fGroup("password");752    const pwInp = document.createElement("input"); pwInp.className = "f-input";753    pwInp.type = "text"; pwInp.value = prePassword || generatePassword();754    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';755    pwInp.style.letterSpacing = "0.04em";756    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");757    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";758    genBtn.innerHTML = genSvgStr() + " regen";759    genBtn.addEventListener("click", () => { pwInp.value = generatePassword(); });760    const pwRow = document.createElement("div"); pwRow.className = "f-row";761    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);762    body.appendChild(pg.g);763764    // Save765    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";766    saveBtn.textContent = "Save entry";767    const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";768    function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }769    saveBtn.addEventListener("click", async () => {770      const title = tg.inp.value.trim();771      const pw = pwInp.value.trim();772      const username = xg.inp.value.trim();773      if (!title) {774        tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();775        showFormError("Entry needs a title");776        return;777      }778      if (!pw) {779        pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();780        showFormError("Entry needs a password");781        return;782      }783      errEl.style.display = "none";784      saveBtn.disabled = true; saveBtn.textContent = "Saving…";785      try {786        const res = await chrome.runtime.sendMessage({787          type: "ADD_ENTRY",788          payload: { entry: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" } },789        });790        if ((res as { ok?: boolean }).ok) {791          // In signup mode, fill the username + password fields on the page792          if (options.isSignup && options.onFillFields) {793            options.onFillFields(username, pw);794          }795          showSuccess(title);796        } else {797          const errMsg = (res as { error?: string }).error === "vault locked"798            ? "Vault locked — open Peach to save"799            : "Could not save entry";800          showFormError(errMsg);801          saveBtn.disabled = false; saveBtn.textContent = "Save entry";802        }803      } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }804    });805    body.appendChild(saveBtn);806    body.appendChild(errEl);807808    wrap.append(head, body);809    container.appendChild(wrap);810    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });811  }812813  function showSuccess(title: string) {814    currentMode = "success";815    container.innerHTML = "";816    const wrap = document.createElement("div"); wrap.className = "success-wrap";817    const ring = document.createElement("div"); ring.className = "success-ring";818    ring.innerHTML = checkSvgStr();819    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;820    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;821    wrap.append(ring, t, s);822    container.appendChild(wrap);823    setTimeout(() => dismiss(), 1600);824  }825826  function renderLocked() {827    container.innerHTML = "";828829    // Domain header (same as list view)830    const domHdr = document.createElement("div");831    domHdr.className = "domain-header";832    const eyebrow = document.createElement("div");833    eyebrow.className = "domain-eyebrow";834    eyebrow.textContent = "vault locked";835    const domName = document.createElement("div");836    domName.className = "domain-name";837    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");838    domHdr.append(eyebrow, domName);839    container.appendChild(domHdr);840841    const wrap = document.createElement("div");842    wrap.className = "locked-wrap";843844    // Icon + label row845    const head = document.createElement("div"); head.className = "locked-head";846    const icon = document.createElement("div"); icon.className = "locked-icon";847    icon.innerHTML = keySvgStr();848    const title = document.createElement("div"); title.className = "locked-title";849    title.textContent = "unlock to autofill";850    head.append(icon, title);851852    // Password input853    const pwInp = document.createElement("input");854    pwInp.className = "locked-input";855    pwInp.type = "password";856    pwInp.placeholder = "master password";857    pwInp.setAttribute("autocomplete", "current-password");858859    // Error line (hidden initially)860    const errEl = document.createElement("div");861    errEl.className = "locked-error";862    errEl.style.display = "none";863864    // Unlock button865    const unlockBtn = document.createElement("button");866    unlockBtn.className = "locked-btn";867    unlockBtn.type = "button";868    unlockBtn.textContent = "unlock";869870    async function tryUnlock() {871      const pw = pwInp.value.trim();872      if (!pw) { pwInp.focus(); return; }873      unlockBtn.disabled = true;874      unlockBtn.textContent = "unlocking…";875      errEl.style.display = "none";876      try {877        const res = await chrome.runtime.sendMessage({878          type: "UNLOCK",879          payload: { password: pw },880        });881        if ((res as { ok?: boolean }).ok) {882          dismiss();883          options.onUnlocked?.();884        } else {885          errEl.textContent = "incorrect password";886          errEl.style.display = "block";887          pwInp.value = "";888          pwInp.focus();889          unlockBtn.disabled = false;890          unlockBtn.textContent = "unlock";891        }892      } catch {893        errEl.textContent = "could not reach Peach";894        errEl.style.display = "block";895        unlockBtn.disabled = false;896        unlockBtn.textContent = "unlock";897      }898    }899900    pwInp.addEventListener("keydown", (e) => { if (e.key === "Enter") void tryUnlock(); });901    unlockBtn.addEventListener("click", () => void tryUnlock());902903    wrap.append(head, pwInp, errEl, unlockBtn);904    container.appendChild(wrap);905    requestAnimationFrame(() => pwInp.focus());906  }907908  function handleKeyDown(e: KeyboardEvent) {909    if (options.locked) {910      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }911      return;912    }913    if (currentMode === "create" || currentMode === "success") {914      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }915      return;916    }917    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");918    const count = allEntryEls.length;919    if (document.activeElement === searchInput) {920      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }921      if (count === 0) return;922      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;923    }924    if (count === 0) return;925    switch (e.key) {926      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;927      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;928      case "Enter": {929        if (focusedIndex >= 0) {930          const el = allEntryEls[focusedIndex] as HTMLElement;931          const id = el.dataset.id;932          if (id) { onSelect(id); dismiss(); }933          else if (el.classList.contains("is-new") || !id) {934            // "New entry" row (last item, no data-id)935            showCreateForm();936          }937        }938        break;939      }940      case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;941    }942  }943944  document.addEventListener("keydown", handleKeyDown);945946  document.body.appendChild(host);947  if (options.locked) {948    renderLocked();949  } else {950    renderList();951  }952953  function updatePosition() {954    const r = anchorEl.getBoundingClientRect();955    host.style.top = `${r.bottom + window.scrollY + 5}px`;956    host.style.left = `${r.left + window.scrollX}px`;957    host.style.width = `${Math.max(r.width, 260)}px`;958  }959  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);960961  function dismiss(userAction = false) {962    stopObserving();963    host.remove();964    document.removeEventListener("click", outsideClick, { capture: true });965    document.removeEventListener("keydown", handleKeyDown);966    if (userAction) options.onDismiss?.();967  }968969  function outsideClick(ev: MouseEvent) {970    const path = ev.composedPath();971    // Never dismiss when clicking the anchor field itself — that click is972    // what triggered the selector to open, and the user may click the field973    // again to re-focus it without intending to close the selector.974    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);975  }976  // capture: true so pages that call stopPropagation() on click don't block our dismiss handler977  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);978979  return dismiss;980}