inject.ts

38.4 KB · raw

1import { ACCENT_COLORS } from "../shared/types";23let accentHex = "#E8956D";4chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {5  const name = resp?.data?.accentColor ?? "peach";6  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";7});89export interface SelectorEntry {10  id: string;11  title: string;12  username?: string;13  url?: string;14  hasTotp?: boolean;15}1617interface SelectorOptions {18  domain?: string;19  searchableEntries?: SelectorEntry[];20  /** True when shown on a signup form — changes header label and entry-click behaviour */21  isSignup?: boolean;22  /**23   * Called after the user picks a saved identity (signup mode only) or24   * after successfully saving a new entry on a signup form.25   * Lets the caller fill the visible username + password fields.26   */27  onFillFields?: (username: string, password: string) => void;28  /**29   * When true, show an inline unlock form instead of the entries list.30   * Used when the vault is locked so the user can unlock without opening31   * the popup.32   */33  locked?: boolean;34  /**35   * Called after a successful inline unlock — the caller should re-query36   * entries and re-open the selector with real data.37   */38  onUnlocked?: () => void;39  /**40   * Called when the user explicitly dismisses the selector (Escape, Tab, or41   * click outside) — NOT when an entry is selected or on auto-close.42   * Used by the caller to suppress spurious reopens on the same field.43   */44  onDismiss?: () => void;45}4647function getDomainFromUrl(url: string): string {48  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }49}5051function keySvgStr(): string {52  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>`;53}5455function plusSvgStr(): string {56  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>`;57}5859function peachIconSvg(): string {60  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="${accentHex}"/><path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/></svg>`;61}6263function genSvgStr(): string {64  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>`;65}6667function checkSvgStr(): string {68  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>`;69}7071function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {72  const { hostname, origin } = location;73  const parts = hostname.split(".");74  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",75    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",76    "oidc", "federation", "saml", "cas", "ldap"];77  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());78  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;79  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);80  return { origin, title, loginSubdomain };81}8283/**84 * Shows a bottom-of-page toast after filling credentials when the entry's85 * saved domain doesn't match the current site — offering to set the current86 * origin as the entry's default URL. Closes automatically after 9 seconds.87 */88export function showDomainConfirmToast(89  pageDomain: string,90  entryId: string,91): void {92  const host = document.createElement("div");93  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";94  host.style.setProperty("--peach-accent", accentHex);95  const shadow = host.attachShadow({ mode: "closed" });9697  const style = document.createElement("style");98  style.textContent = `99    .bar {100      display:flex;align-items:center;gap:10px;padding:10px 14px;101      background:#131109;border:1px solid rgba(240,232,216,0.09);102      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);103      font-family:Inter,sans-serif;white-space:nowrap;104      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;105    }106    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }107    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }108    .yes {109      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;110      background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;111    }112    .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }113    .no {114      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;115      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;116    }117  `;118  shadow.appendChild(style);119120  const bar = document.createElement("div"); bar.className = "bar";121  const txt = document.createElement("span"); txt.className = "txt";122  txt.textContent = `Set ${pageDomain} as default for this entry?`;123  const yesBtn = document.createElement("button"); yesBtn.className = "yes";124  yesBtn.textContent = "Set default";125  yesBtn.addEventListener("click", async () => {126    await chrome.runtime.sendMessage({127      type: "UPDATE_ENTRY",128      payload: { id: entryId, patch: { url: location.origin } },129    });130    host.remove();131  });132  const noBtn = document.createElement("button"); noBtn.className = "no";133  noBtn.textContent = "Skip";134  noBtn.addEventListener("click", () => host.remove());135  bar.append(txt, yesBtn, noBtn);136  shadow.appendChild(bar);137  document.body.appendChild(host);138  setTimeout(() => host.remove(), 9000);139}140141function generatePassword(): string {142  const u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", l = "abcdefghijklmnopqrstuvwxyz";143  const n = "0123456789", s = "!@#$%^&*";144  const all = u + l + n + s;145  const arr = new Uint8Array(20);146  crypto.getRandomValues(arr);147  const res = [u[arr[0]! % u.length]!, l[arr[1]! % l.length]!, n[arr[2]! % n.length]!, s[arr[3]! % s.length]!,148    ...Array.from(arr.slice(4), b => all[b % all.length]!)];149  for (let i = res.length - 1; i > 0; i--) {150    const j = arr[i % arr.length]! % (i + 1);151    [res[i], res[j]] = [res[j]!, res[i]!];152  }153  return res.join("");154}155156export function observeAnchorPosition(157  anchorEl: HTMLElement,158  onUpdate: () => void159): () => void {160  let rafId = 0;161  let burstUntil = 0;162163  const runUpdate = () => {164    rafId = 0;165    onUpdate();166    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);167  };168  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };169  const scheduleBurst = (ms = 220) => {170    burstUntil = Math.max(burstUntil, performance.now() + ms);171    scheduleUpdate();172  };173174  const resizeObserver = new ResizeObserver(() => scheduleBurst());175  resizeObserver.observe(anchorEl);176  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);177178  const mutationObserver = new MutationObserver(() => scheduleBurst());179  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });180  if (anchorEl.parentElement) {181    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });182  }183184  const onScroll = () => scheduleUpdate();185  window.addEventListener("scroll", onScroll, true);186  window.addEventListener("resize", onScroll);187  window.visualViewport?.addEventListener("resize", onScroll);188  window.visualViewport?.addEventListener("scroll", onScroll);189  anchorEl.addEventListener("focus", () => scheduleBurst());190  anchorEl.addEventListener("input", () => scheduleBurst());191192  scheduleBurst(260);193194  return () => {195    if (rafId) cancelAnimationFrame(rafId);196    resizeObserver.disconnect();197    mutationObserver.disconnect();198    window.removeEventListener("scroll", onScroll, true);199    window.removeEventListener("resize", onScroll);200    window.visualViewport?.removeEventListener("resize", onScroll);201    window.visualViewport?.removeEventListener("scroll", onScroll);202  };203}204205export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {206  const button = document.createElement("button");207  button.type = "button";208  button.setAttribute("aria-label", "Peach autofill");209  button.title = "Fill with Peach";210  button.innerHTML = peachIconSvg();211  button.style.cssText = `212    position: fixed; z-index: 2147483646;213    width: 26px; height: 26px; cursor: pointer;214    pointer-events: auto; border: none; padding: 3px;215    display: inline-flex; align-items: center; justify-content: center;216    background: transparent; border-radius: 999px;217    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;218    opacity: 0;219  `;220  button.addEventListener("mouseenter", () => {221    button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;222    button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;223    button.style.transform = "scale(1.08)";224  });225  button.addEventListener("mouseleave", () => {226    button.style.background = "transparent";227    button.style.boxShadow = "none";228    button.style.transform = "none";229  });230  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });231  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });232233  document.body.appendChild(button);234235  function updatePosition() {236    const rect = field.getBoundingClientRect();237    if (rect.width === 0 || rect.height === 0) {238      button.style.opacity = "0"; button.style.pointerEvents = "none";239      return;240    }241    const sz = 26;242    const inset = Math.max(5, Math.min(10, rect.height * 0.22));243    const top = rect.top + rect.height / 2 - sz / 2;244    // Clamp left so the icon stays fully inside the field's right edge, never245    // spilling onto an adjacent sibling button.246    const minLeft = rect.left + 4;247    const left = Math.max(minLeft, rect.right - sz - inset);248    // Hide if the field is too narrow to fit the icon cleanly (< 40px)249    if (rect.width < 40) {250      button.style.opacity = "0"; button.style.pointerEvents = "none";251      return;252    }253    button.style.top = `${top}px`;254    button.style.left = `${left}px`;255    button.style.opacity = "1"; button.style.pointerEvents = "auto";256  }257  updatePosition();258  requestAnimationFrame(() => { button.style.opacity = "1"; });259  const stopObserving = observeAnchorPosition(field, updatePosition);260  return () => { stopObserving(); button.remove(); };261}262263// ── Shared selector + new-entry styles ────────────────────────────264const SELECTOR_STYLES = `265  :host { all: initial; }266  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }267  .selector {268    background: #0f0e0b;269    border: 1px solid rgba(240,232,216,0.10);270    border-radius: 13px;271    overflow: hidden;272    box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7);273    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;274    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;275  }276  @keyframes sl-in {277    from { opacity:0; transform:translateY(-6px) scale(0.97) }278    to   { opacity:1; transform:none }279  }280281  /* Domain header */282  .domain-header {283    padding: 9px 13px 7px;284    border-bottom: 1px solid rgba(240,232,216,0.055);285    display: flex; align-items: center; justify-content: space-between;286  }287  .domain-eyebrow {288    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;289    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;290  }291  .domain-name {292    font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);293    font-family: "IBM Plex Mono", monospace;294  }295296  /* Search */297  .search-wrap {298    padding: 9px 12px 7px;299    border-bottom: 1px solid rgba(240,232,216,0.04);300  }301  .search {302    width: 100%; background: rgba(240,232,216,0.03);303    border: 1px solid rgba(240,232,216,0.08);304    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;305    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;306    transition: border-color 120ms, box-shadow 120ms;307  }308  .search:focus {309    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);310    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);311  }312  .search::placeholder { color: rgba(240,232,216,0.2); }313314  /* Results */315  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }316  .results::-webkit-scrollbar { display: none; }317318  /* Entry rows */319  .entry {320    display: flex; align-items: center; padding: 8px 12px;321    cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04);322    transition: background 70ms; position: relative; outline: none;323  }324  .entry:last-child { border-bottom: none; }325  .entry:hover { background: rgba(240,232,216,0.04); }326  .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }327  .entry.focused::before {328    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;329    width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent);330  }331332  .entry-icon {333    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;334    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07);335    display: flex; align-items: center; justify-content: center;336    overflow: hidden; margin-right: 10px;337  }338  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }339  .entry-icon.is-new {340    background: color-mix(in srgb, var(--peach-accent) 8%, transparent);341    border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);342    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);343  }344345  .entry-info { flex: 1; min-width: 0; }346  .entry-title {347    font-size: 12.5px; color: #F0E8D8;348    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;349    line-height: 1.3;350  }351  .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }352  .entry-sub {353    font-size: 10px; color: rgba(240,232,216,0.32);354    font-family: "IBM Plex Mono", monospace;355    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;356    margin-top: 1px;357  }358359  .totp-badge {360    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;361    border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);362    color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace;363    flex-shrink: 0; margin-left: 8px;364  }365366  .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; }367368  .empty-hint {369    padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28);370    font-family: "IBM Plex Mono", monospace;371  }372373  /* ── Create form ─────────────────────────────────────── */374  .create-form-wrap {375    animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both;376  }377  @keyframes cf-in {378    from { opacity:0; transform:translateX(8px) }379    to { opacity:1; transform:none }380  }381  .create-head {382    display: flex; align-items: center; gap: 8px;383    padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055);384  }385  .back-btn {386    background: transparent; border: none; cursor: pointer;387    color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px;388    display: flex; align-items: center; line-height: 0;389    transition: color 100ms, background 100ms;390  }391  .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); }392  .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; }393  .create-body { padding: 12px; display: grid; gap: 9px; }394  .f-group { display: grid; gap: 3px; }395  .f-label {396    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;397    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;398  }399  .f-input {400    background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);401    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;402    font-size: 11px; font-family: "IBM Plex Mono", monospace;403    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;404  }405  .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); }406  .f-input::placeholder { color: rgba(240,232,216,0.18); }407  .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; }408  .f-row { display: flex; gap: 6px; }409  .f-row .f-input { flex: 1; min-width: 0; }410  .gen-btn {411    padding: 0 10px; border-radius: 7px; border: none;412    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);413    color: color-mix(in srgb, var(--peach-accent) 80%, transparent);414    font-size: 10px; font-family: "IBM Plex Mono", monospace;415    cursor: pointer; white-space: nowrap; flex-shrink: 0;416    display: flex; align-items: center; gap: 4px;417    transition: background 100ms;418  }419  .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }420  .save-btn {421    width: 100%; padding: 8px; border-radius: 8px; border: none;422    background: var(--peach-accent); color: #fff; font-size: 12px;423    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;424    transition: background 120ms; letter-spacing: 0.01em;425  }426  .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }427  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }428  .url-hint {429    font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);430    font-family: "IBM Plex Mono", monospace; font-style: italic;431  }432433  /* ── Success ─────────────────────────────────────────── */434  .success-wrap {435    display: flex; flex-direction: column; align-items: center;436    padding: 24px 16px 20px; gap: 8px;437    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;438  }439  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }440  .success-ring {441    width: 40px; height: 40px; border-radius: 50%;442    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);443    display: flex; align-items: center; justify-content: center;444  }445  .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; }446  .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; }447448  /* ── Locked state ────────────────────────────────────── */449  .locked-wrap {450    display: flex; flex-direction: column; align-items: stretch;451    padding: 14px 13px 13px; gap: 9px;452    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;453  }454  .locked-head {455    display: flex; align-items: center; gap: 8px; padding-bottom: 2px;456  }457  .locked-icon {458    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;459    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08);460    display: flex; align-items: center; justify-content: center;461  }462  .locked-title {463    font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500;464  }465  .locked-input {466    width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);467    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;468    font-size: 11px; font-family: "IBM Plex Mono", monospace;469    outline: none; transition: border-color 120ms, box-shadow 120ms;470  }471  .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); }472  .locked-input::placeholder { color: rgba(240,232,216,0.2); }473  .locked-btn {474    width: 100%; padding: 8px; border-radius: 8px; border: none;475    background: var(--peach-accent); color: #fff; font-size: 12px;476    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;477    transition: background 120ms; letter-spacing: 0.01em;478  }479  .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }480  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; }481  .locked-error {482    font-size: 10px; color: rgba(220,80,60,0.85);483    font-family: "IBM Plex Mono", monospace;484  }485`;486487export function showAutofillSelector(488  anchorEl: HTMLInputElement,489  entries: SelectorEntry[],490  onSelect: (id: string) => void,491  options: SelectorOptions = {}492): () => void {493  const host = document.createElement("div");494  host.style.cssText = "position: absolute; z-index: 2147483647;";495  host.style.setProperty("--peach-accent", accentHex);496  const shadow = host.attachShadow({ mode: "closed" });497498  const style = shadow.appendChild(document.createElement("style"));499  style.textContent = SELECTOR_STYLES;500501  const rect = anchorEl.getBoundingClientRect();502  host.style.top = `${rect.bottom + window.scrollY + 5}px`;503  host.style.left = `${rect.left + window.scrollX}px`;504  host.style.width = `${Math.max(rect.width, 260)}px`;505506  const container = shadow.appendChild(document.createElement("div"));507  container.className = "selector";508509  const searchableEntries = options.searchableEntries ?? [];510  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();511512  let query = "";513  let focusedIndex = -1;514  let currentMode: "list" | "create" | "success" = "list";515  let searchInput: HTMLInputElement | null = null;516517  // Pre-capture the anchor field value for username prefill518  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";519  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");520  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";521522  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {523    const seen = new Set<string>();524    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });525  }526527  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {528    const q = value.trim().toLowerCase();529    const deduped = uniqueEntries(source);530    if (!q) return deduped.slice(0, 8);531    return deduped.filter(e =>532      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)533    ).slice(0, 12);534  }535536  function activeEntries(): SelectorEntry[] {537    // When user is typing a search, filter across all known entries (domain matches + vault)538    if (query.trim()) {539      const merged = uniqueEntries([...entries, ...searchableEntries]);540      return filterEntries(merged, query);541    }542    // Default: show domain matches; fall back to vault search when no matches543    if (entries.length > 0) return entries;544    return filterEntries(searchableEntries, "").slice(0, 8);545  }546547  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {548    const icon = document.createElement("div");549    icon.className = `entry-icon${isNew ? " is-new" : ""}`;550    if (isNew) {551      icon.innerHTML = plusSvgStr();552    } else {553      // Use the generic key glyph for all entries in the page-injected selector.554      // Avoids disclosing vault domains to any third party and avoids needing555      // to expose the browser favicon API to page context via web_accessible_resources.556      void url;557      icon.innerHTML = keySvgStr();558    }559    return icon;560  }561562  function renderList() {563    container.innerHTML = "";564    focusedIndex = -1;565    searchInput = null;566567    const current = activeEntries();568569    // Domain header (always show)570    const domHdr = document.createElement("div");571    domHdr.className = "domain-header";572    const eyebrow = document.createElement("div");573    eyebrow.className = "domain-eyebrow";574    eyebrow.textContent = options.isSignup575      ? "Sign up as"576      : entries.length > 0 ? "Saved for this site" : "No saved login";577    const domName = document.createElement("div");578    domName.className = "domain-name";579    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");580    domHdr.append(eyebrow, domName);581    container.appendChild(domHdr);582583    // Search — always show so user can find entries beyond the domain matches584    const sw = document.createElement("div"); sw.className = "search-wrap";585    searchInput = document.createElement("input");586    searchInput.className = "search";587    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";588    searchInput.value = query;589    searchInput.addEventListener("input", () => {590      query = searchInput?.value ?? "";591      renderList();592      searchInput?.focus();593      searchInput?.setSelectionRange(query.length, query.length);594    });595    sw.appendChild(searchInput);596    container.appendChild(sw);597598    // Results599    const results = document.createElement("div");600    results.className = "results";601    container.appendChild(results);602603    if (current.length === 0 && entries.length > 0) {604      // Show a hint but don't render empty entries605    }606607    for (let i = 0; i < current.length; i++) {608      const e = current[i]!;609      const entryEl = document.createElement("div");610      entryEl.className = "entry";611      entryEl.dataset.id = e.id;612      entryEl.dataset.index = String(i);613      entryEl.setAttribute("tabindex", "-1");614615      entryEl.appendChild(makeEntryIcon(e.url));616617      const info = document.createElement("div"); info.className = "entry-info";618      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;619      const subEl = document.createElement("div"); subEl.className = "entry-sub";620      const domain = e.url ? getDomainFromUrl(e.url) : "";621      subEl.textContent = domain || e.username || "";622      info.append(titleEl, subEl);623      entryEl.appendChild(info);624625      if (e.hasTotp) {626        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";627        entryEl.appendChild(badge);628      }629630      entryEl.addEventListener("click", () => {631        if (options.isSignup && options.onFillFields) {632          // Signup mode: clicking a saved identity fills the username field633          options.onFillFields(e.username ?? "", "");634          dismiss();635        } else {636          onSelect(e.id);637          dismiss();638        }639      });640      results.appendChild(entryEl);641    }642643    // Divider before "New entry"644    if (current.length > 0) {645      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));646    }647648    // "New entry" row — always present649    const newEl = document.createElement("div");650    newEl.className = "entry";651    newEl.setAttribute("tabindex", "-1");652    newEl.appendChild(makeEntryIcon(undefined, true));653654    const newInfo = document.createElement("div"); newInfo.className = "entry-info";655    const newTitle = document.createElement("div");656    newTitle.className = "entry-title is-new";657    newTitle.textContent = `New entry for ${sTitle}`;658    const newSub = document.createElement("div");659    newSub.className = "entry-sub";660    newSub.textContent = sOrigin;661    newInfo.append(newTitle, newSub);662    newEl.appendChild(newInfo);663    newEl.addEventListener("click", showCreateForm);664    results.appendChild(newEl);665666    // Focus search if present667    if (searchInput) {668      requestAnimationFrame(() => searchInput?.focus());669    }670  }671672  function setFocused(idx: number) {673    const all = container.querySelectorAll<HTMLElement>(".entry");674    all.forEach((el, i) => el.classList.toggle("focused", i === idx));675    focusedIndex = idx;676    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });677  }678679  function showCreateForm() {680    currentMode = "create";681    container.innerHTML = "";682683    const wrap = document.createElement("div");684    wrap.className = "create-form-wrap";685686    // Header687    const head = document.createElement("div"); head.className = "create-head";688    const backBtn = document.createElement("button"); backBtn.className = "back-btn";689    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>`;690    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });691    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";692    head.append(backBtn, lbl);693694    // Body695    const body = document.createElement("div"); body.className = "create-body";696697    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {698      const g = document.createElement("div"); g.className = "f-group";699      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;700      const inp = document.createElement("input"); inp.className = "f-input";701      g.append(l, inp); return { g, inp };702    }703704    // Title705    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";706    body.appendChild(tg.g);707708    // URL709    const ug = fGroup("website");710    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";711    if (loginSubdomain) {712      const h = document.createElement("div"); h.className = "url-hint";713      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);714    }715    body.appendChild(ug.g);716717    // Username (pre-fill from anchor field)718    const xg = fGroup("username / email");719    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";720    xg.inp.setAttribute("autocomplete", "off");721    body.appendChild(xg.g);722723    // Password + generate724    const pg = fGroup("password");725    const pwInp = document.createElement("input"); pwInp.className = "f-input";726    pwInp.type = "text"; pwInp.value = prePassword || generatePassword();727    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';728    pwInp.style.letterSpacing = "0.04em";729    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");730    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";731    genBtn.innerHTML = genSvgStr() + " regen";732    genBtn.addEventListener("click", () => { pwInp.value = generatePassword(); });733    const pwRow = document.createElement("div"); pwRow.className = "f-row";734    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);735    body.appendChild(pg.g);736737    // Save738    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";739    saveBtn.textContent = "Save entry";740    saveBtn.addEventListener("click", async () => {741      const title = tg.inp.value.trim();742      const pw = pwInp.value.trim();743      const username = xg.inp.value.trim();744      if (!title || !pw) {745        if (!title) { tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus(); }746        else { pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus(); }747        return;748      }749      saveBtn.disabled = true; saveBtn.textContent = "Saving…";750      try {751        const res = await chrome.runtime.sendMessage({752          type: "ADD_ENTRY",753          payload: { entry: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" } },754        });755        if ((res as { ok?: boolean }).ok) {756          // In signup mode, fill the username + password fields on the page757          if (options.isSignup && options.onFillFields) {758            options.onFillFields(username, pw);759          }760          showSuccess(title);761        } else {762          saveBtn.disabled = false; saveBtn.textContent = "Save entry";763        }764      } catch { saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }765    });766    body.appendChild(saveBtn);767768    wrap.append(head, body);769    container.appendChild(wrap);770    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });771  }772773  function showSuccess(title: string) {774    currentMode = "success";775    container.innerHTML = "";776    const wrap = document.createElement("div"); wrap.className = "success-wrap";777    const ring = document.createElement("div"); ring.className = "success-ring";778    ring.innerHTML = checkSvgStr();779    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;780    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;781    wrap.append(ring, t, s);782    container.appendChild(wrap);783    setTimeout(() => dismiss(), 1600);784  }785786  function renderLocked() {787    container.innerHTML = "";788789    // Domain header (same as list view)790    const domHdr = document.createElement("div");791    domHdr.className = "domain-header";792    const eyebrow = document.createElement("div");793    eyebrow.className = "domain-eyebrow";794    eyebrow.textContent = "vault locked";795    const domName = document.createElement("div");796    domName.className = "domain-name";797    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");798    domHdr.append(eyebrow, domName);799    container.appendChild(domHdr);800801    const wrap = document.createElement("div");802    wrap.className = "locked-wrap";803804    // Icon + label row805    const head = document.createElement("div"); head.className = "locked-head";806    const icon = document.createElement("div"); icon.className = "locked-icon";807    icon.innerHTML = keySvgStr();808    const title = document.createElement("div"); title.className = "locked-title";809    title.textContent = "unlock to autofill";810    head.append(icon, title);811812    // Password input813    const pwInp = document.createElement("input");814    pwInp.className = "locked-input";815    pwInp.type = "password";816    pwInp.placeholder = "master password";817    pwInp.setAttribute("autocomplete", "current-password");818819    // Error line (hidden initially)820    const errEl = document.createElement("div");821    errEl.className = "locked-error";822    errEl.style.display = "none";823824    // Unlock button825    const unlockBtn = document.createElement("button");826    unlockBtn.className = "locked-btn";827    unlockBtn.type = "button";828    unlockBtn.textContent = "unlock";829830    async function tryUnlock() {831      const pw = pwInp.value.trim();832      if (!pw) { pwInp.focus(); return; }833      unlockBtn.disabled = true;834      unlockBtn.textContent = "unlocking…";835      errEl.style.display = "none";836      try {837        const res = await chrome.runtime.sendMessage({838          type: "UNLOCK",839          payload: { password: pw },840        });841        if ((res as { ok?: boolean }).ok) {842          dismiss();843          options.onUnlocked?.();844        } else {845          errEl.textContent = "incorrect password";846          errEl.style.display = "block";847          pwInp.value = "";848          pwInp.focus();849          unlockBtn.disabled = false;850          unlockBtn.textContent = "unlock";851        }852      } catch {853        errEl.textContent = "could not reach Peach";854        errEl.style.display = "block";855        unlockBtn.disabled = false;856        unlockBtn.textContent = "unlock";857      }858    }859860    pwInp.addEventListener("keydown", (e) => { if (e.key === "Enter") void tryUnlock(); });861    unlockBtn.addEventListener("click", () => void tryUnlock());862863    wrap.append(head, pwInp, errEl, unlockBtn);864    container.appendChild(wrap);865    requestAnimationFrame(() => pwInp.focus());866  }867868  function handleKeyDown(e: KeyboardEvent) {869    if (options.locked) {870      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }871      return;872    }873    if (currentMode === "create" || currentMode === "success") {874      if (e.key === "Escape") { e.preventDefault(); dismiss(true); }875      return;876    }877    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");878    const count = allEntryEls.length;879    if (document.activeElement === searchInput) {880      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }881      if (count === 0) return;882      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;883    }884    if (count === 0) return;885    switch (e.key) {886      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;887      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;888      case "Enter": {889        if (focusedIndex >= 0) {890          const el = allEntryEls[focusedIndex] as HTMLElement;891          const id = el.dataset.id;892          if (id) { onSelect(id); dismiss(); }893          else if (el.classList.contains("is-new") || !id) {894            // "New entry" row (last item, no data-id)895            showCreateForm();896          }897        }898        break;899      }900      case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;901    }902  }903904  document.addEventListener("keydown", handleKeyDown);905906  document.body.appendChild(host);907  if (options.locked) {908    renderLocked();909  } else {910    renderList();911  }912913  function updatePosition() {914    const r = anchorEl.getBoundingClientRect();915    host.style.top = `${r.bottom + window.scrollY + 5}px`;916    host.style.left = `${r.left + window.scrollX}px`;917    host.style.width = `${Math.max(r.width, 260)}px`;918  }919  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);920921  function dismiss(userAction = false) {922    stopObserving();923    host.remove();924    document.removeEventListener("click", outsideClick, { capture: true });925    document.removeEventListener("keydown", handleKeyDown);926    if (userAction) options.onDismiss?.();927  }928929  function outsideClick(ev: MouseEvent) {930    const path = ev.composedPath();931    // Never dismiss when clicking the anchor field itself — that click is932    // what triggered the selector to open, and the user may click the field933    // again to re-focus it without intending to close the selector.934    if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);935  }936  // capture: true so pages that call stopPropagation() on click don't block our dismiss handler937  setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);938939  return dismiss;940}