inject.ts

36.6 KB · raw

1export interface SelectorEntry {2  id: string;3  title: string;4  username?: string;5  url?: string;6  hasTotp?: boolean;7}89interface SelectorOptions {10  domain?: string;11  searchableEntries?: SelectorEntry[];12  /** True when shown on a signup form — changes header label and entry-click behaviour */13  isSignup?: boolean;14  /**15   * Called after the user picks a saved identity (signup mode only) or16   * after successfully saving a new entry on a signup form.17   * Lets the caller fill the visible username + password fields.18   */19  onFillFields?: (username: string, password: string) => void;20  /**21   * When true, show an inline unlock form instead of the entries list.22   * Used when the vault is locked so the user can unlock without opening23   * the popup.24   */25  locked?: boolean;26  /**27   * Called after a successful inline unlock — the caller should re-query28   * entries and re-open the selector with real data.29   */30  onUnlocked?: () => void;31}3233function getDomainFromUrl(url: string): string {34  try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }35}3637function keySvgStr(): string {38  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>`;39}4041function plusSvgStr(): string {42  return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="rgba(217,104,48,0.8)" stroke-width="1.2" stroke-linecap="round"><path d="M6 1.5v9M1.5 6h9"/></svg>`;43}4445function peachIconSvg(): string {46  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>`;47}4849function genSvgStr(): string {50  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>`;51}5253function checkSvgStr(): string {54  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>`;55}5657function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {58  const { hostname, origin } = location;59  const parts = hostname.split(".");60  const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",61    "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",62    "oidc", "federation", "saml", "cas", "ldap"];63  const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());64  const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;65  const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);66  return { origin, title, loginSubdomain };67}6869/**70 * Shows a bottom-of-page toast after filling credentials when the entry's71 * saved domain doesn't match the current site — offering to set the current72 * origin as the entry's default URL. Closes automatically after 9 seconds.73 */74export function showDomainConfirmToast(75  pageDomain: string,76  entryId: string,77): void {78  const host = document.createElement("div");79  host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";80  const shadow = host.attachShadow({ mode: "closed" });8182  const style = document.createElement("style");83  style.textContent = `84    .bar {85      display:flex;align-items:center;gap:10px;padding:10px 14px;86      background:#131109;border:1px solid rgba(240,232,216,0.09);87      border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);88      font-family:Inter,sans-serif;white-space:nowrap;89      animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;90    }91    @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }92    .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }93    .yes {94      padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;95      background:#D96830;color:#fff;transition:background 100ms;font-family:Inter,sans-serif;96    }97    .yes:hover { background:#c55e28; }98    .no {99      padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;100      background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;101    }102  `;103  shadow.appendChild(style);104105  const bar = document.createElement("div"); bar.className = "bar";106  const txt = document.createElement("span"); txt.className = "txt";107  txt.textContent = `Set ${pageDomain} as default for this entry?`;108  const yesBtn = document.createElement("button"); yesBtn.className = "yes";109  yesBtn.textContent = "Set default";110  yesBtn.addEventListener("click", async () => {111    await chrome.runtime.sendMessage({112      type: "UPDATE_ENTRY",113      payload: { id: entryId, patch: { url: location.origin } },114    });115    host.remove();116  });117  const noBtn = document.createElement("button"); noBtn.className = "no";118  noBtn.textContent = "Skip";119  noBtn.addEventListener("click", () => host.remove());120  bar.append(txt, yesBtn, noBtn);121  shadow.appendChild(bar);122  document.body.appendChild(host);123  setTimeout(() => host.remove(), 9000);124}125126function generatePassword(): string {127  const u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", l = "abcdefghijklmnopqrstuvwxyz";128  const n = "0123456789", s = "!@#$%^&*";129  const all = u + l + n + s;130  const arr = new Uint8Array(20);131  crypto.getRandomValues(arr);132  const res = [u[arr[0]! % u.length]!, l[arr[1]! % l.length]!, n[arr[2]! % n.length]!, s[arr[3]! % s.length]!,133    ...Array.from(arr.slice(4), b => all[b % all.length]!)];134  for (let i = res.length - 1; i > 0; i--) {135    const j = arr[i % arr.length]! % (i + 1);136    [res[i], res[j]] = [res[j]!, res[i]!];137  }138  return res.join("");139}140141export function observeAnchorPosition(142  anchorEl: HTMLElement,143  onUpdate: () => void144): () => void {145  let rafId = 0;146  let burstUntil = 0;147148  const runUpdate = () => {149    rafId = 0;150    onUpdate();151    if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);152  };153  const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };154  const scheduleBurst = (ms = 220) => {155    burstUntil = Math.max(burstUntil, performance.now() + ms);156    scheduleUpdate();157  };158159  const resizeObserver = new ResizeObserver(() => scheduleBurst());160  resizeObserver.observe(anchorEl);161  if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);162163  const mutationObserver = new MutationObserver(() => scheduleBurst());164  mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });165  if (anchorEl.parentElement) {166    mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });167  }168169  const onScroll = () => scheduleUpdate();170  window.addEventListener("scroll", onScroll, true);171  window.addEventListener("resize", onScroll);172  window.visualViewport?.addEventListener("resize", onScroll);173  window.visualViewport?.addEventListener("scroll", onScroll);174  anchorEl.addEventListener("focus", () => scheduleBurst());175  anchorEl.addEventListener("input", () => scheduleBurst());176177  scheduleBurst(260);178179  return () => {180    if (rafId) cancelAnimationFrame(rafId);181    resizeObserver.disconnect();182    mutationObserver.disconnect();183    window.removeEventListener("scroll", onScroll, true);184    window.removeEventListener("resize", onScroll);185    window.visualViewport?.removeEventListener("resize", onScroll);186    window.visualViewport?.removeEventListener("scroll", onScroll);187  };188}189190export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {191  const button = document.createElement("button");192  button.type = "button";193  button.setAttribute("aria-label", "Peach autofill");194  button.title = "Fill with Peach";195  button.innerHTML = peachIconSvg();196  button.style.cssText = `197    position: fixed; z-index: 2147483646;198    width: 26px; height: 26px; cursor: pointer;199    pointer-events: auto; border: none; padding: 3px;200    display: inline-flex; align-items: center; justify-content: center;201    background: transparent; border-radius: 999px;202    transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;203    opacity: 0;204  `;205  button.addEventListener("mouseenter", () => {206    button.style.background = "rgba(217,104,48,0.12)";207    button.style.boxShadow = "0 0 0 1px rgba(217,104,48,0.18), 0 0 14px rgba(217,104,48,0.12)";208    button.style.transform = "scale(1.08)";209  });210  button.addEventListener("mouseleave", () => {211    button.style.background = "transparent";212    button.style.boxShadow = "none";213    button.style.transform = "none";214  });215  button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });216  button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });217218  document.body.appendChild(button);219220  function updatePosition() {221    const rect = field.getBoundingClientRect();222    if (rect.width === 0 || rect.height === 0) {223      button.style.opacity = "0"; button.style.pointerEvents = "none";224      return;225    }226    const sz = 26;227    const inset = Math.max(5, Math.min(10, rect.height * 0.22));228    const top = rect.top + rect.height / 2 - sz / 2;229    // Clamp left so the icon stays fully inside the field's right edge, never230    // spilling onto an adjacent sibling button.231    const minLeft = rect.left + 4;232    const left = Math.max(minLeft, rect.right - sz - inset);233    // Hide if the field is too narrow to fit the icon cleanly (< 40px)234    if (rect.width < 40) {235      button.style.opacity = "0"; button.style.pointerEvents = "none";236      return;237    }238    button.style.top = `${top}px`;239    button.style.left = `${left}px`;240    button.style.opacity = "1"; button.style.pointerEvents = "auto";241  }242  updatePosition();243  requestAnimationFrame(() => { button.style.opacity = "1"; });244  const stopObserving = observeAnchorPosition(field, updatePosition);245  return () => { stopObserving(); button.remove(); };246}247248// ── Shared selector + new-entry styles ────────────────────────────249const SELECTOR_STYLES = `250  :host { all: initial; }251  *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }252  .selector {253    background: #0f0e0b;254    border: 1px solid rgba(240,232,216,0.10);255    border-radius: 13px;256    overflow: hidden;257    box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7);258    font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;259    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;260  }261  @keyframes sl-in {262    from { opacity:0; transform:translateY(-6px) scale(0.97) }263    to   { opacity:1; transform:none }264  }265266  /* Domain header */267  .domain-header {268    padding: 9px 13px 7px;269    border-bottom: 1px solid rgba(240,232,216,0.055);270    display: flex; align-items: center; justify-content: space-between;271  }272  .domain-eyebrow {273    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;274    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;275  }276  .domain-name {277    font-size: 10px; color: rgba(217,104,48,0.6);278    font-family: "IBM Plex Mono", monospace;279  }280281  /* Search */282  .search-wrap {283    padding: 9px 12px 7px;284    border-bottom: 1px solid rgba(240,232,216,0.04);285  }286  .search {287    width: 100%; background: rgba(240,232,216,0.03);288    border: 1px solid rgba(240,232,216,0.08);289    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;290    font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;291    transition: border-color 120ms, box-shadow 120ms;292  }293  .search:focus {294    border-color: rgba(217,104,48,0.4);295    box-shadow: 0 0 0 2px rgba(217,104,48,0.10);296  }297  .search::placeholder { color: rgba(240,232,216,0.2); }298299  /* Results */300  .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }301  .results::-webkit-scrollbar { display: none; }302303  /* Entry rows */304  .entry {305    display: flex; align-items: center; padding: 8px 12px;306    cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04);307    transition: background 70ms; position: relative; outline: none;308  }309  .entry:last-child { border-bottom: none; }310  .entry:hover { background: rgba(240,232,216,0.04); }311  .entry.focused { background: rgba(217,104,48,0.08); }312  .entry.focused::before {313    content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;314    width: 2px; border-radius: 2px; background: rgba(217,104,48,0.55);315  }316317  .entry-icon {318    width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;319    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07);320    display: flex; align-items: center; justify-content: center;321    overflow: hidden; margin-right: 10px;322  }323  .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }324  .entry-icon.is-new {325    background: rgba(217,104,48,0.08); border-color: rgba(217,104,48,0.15);326  }327328  .entry-info { flex: 1; min-width: 0; }329  .entry-title {330    font-size: 12.5px; color: #F0E8D8;331    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;332    line-height: 1.3;333  }334  .entry-title.is-new { color: rgba(217,104,48,0.9); font-size: 12px; }335  .entry-sub {336    font-size: 10px; color: rgba(240,232,216,0.32);337    font-family: "IBM Plex Mono", monospace;338    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;339    margin-top: 1px;340  }341342  .totp-badge {343    font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;344    border-radius: 4px; background: rgba(217,104,48,0.10);345    color: rgba(217,104,48,0.75); font-family: "IBM Plex Mono", monospace;346    flex-shrink: 0; margin-left: 8px;347  }348349  .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; }350351  .empty-hint {352    padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28);353    font-family: "IBM Plex Mono", monospace;354  }355356  /* ── Create form ─────────────────────────────────────── */357  .create-form-wrap {358    animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both;359  }360  @keyframes cf-in {361    from { opacity:0; transform:translateX(8px) }362    to { opacity:1; transform:none }363  }364  .create-head {365    display: flex; align-items: center; gap: 8px;366    padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055);367  }368  .back-btn {369    background: transparent; border: none; cursor: pointer;370    color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px;371    display: flex; align-items: center; line-height: 0;372    transition: color 100ms, background 100ms;373  }374  .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); }375  .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; }376  .create-body { padding: 12px; display: grid; gap: 9px; }377  .f-group { display: grid; gap: 3px; }378  .f-label {379    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;380    color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;381  }382  .f-input {383    background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);384    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;385    font-size: 11px; font-family: "IBM Plex Mono", monospace;386    outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;387  }388  .f-input:focus { border-color: rgba(217,104,48,0.4); box-shadow: 0 0 0 2px rgba(217,104,48,0.10); }389  .f-input::placeholder { color: rgba(240,232,216,0.18); }390  .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; }391  .f-row { display: flex; gap: 6px; }392  .f-row .f-input { flex: 1; min-width: 0; }393  .gen-btn {394    padding: 0 10px; border-radius: 7px; border: none;395    background: rgba(217,104,48,0.10); color: rgba(217,104,48,0.8);396    font-size: 10px; font-family: "IBM Plex Mono", monospace;397    cursor: pointer; white-space: nowrap; flex-shrink: 0;398    display: flex; align-items: center; gap: 4px;399    transition: background 100ms;400  }401  .gen-btn:hover { background: rgba(217,104,48,0.18); }402  .save-btn {403    width: 100%; padding: 8px; border-radius: 8px; border: none;404    background: #D96830; color: #fff; font-size: 12px;405    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;406    transition: background 120ms; letter-spacing: 0.01em;407  }408  .save-btn:hover { background: #c55e28; }409  .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }410  .url-hint {411    font-size: 9px; color: rgba(217,104,48,0.45);412    font-family: "IBM Plex Mono", monospace; font-style: italic;413  }414415  /* ── Success ─────────────────────────────────────────── */416  .success-wrap {417    display: flex; flex-direction: column; align-items: center;418    padding: 24px 16px 20px; gap: 8px;419    animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;420  }421  @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }422  .success-ring {423    width: 40px; height: 40px; border-radius: 50%;424    background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);425    display: flex; align-items: center; justify-content: center;426  }427  .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; }428  .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; }429430  /* ── Locked state ────────────────────────────────────── */431  .locked-wrap {432    display: flex; flex-direction: column; align-items: stretch;433    padding: 14px 13px 13px; gap: 9px;434    animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;435  }436  .locked-head {437    display: flex; align-items: center; gap: 8px; padding-bottom: 2px;438  }439  .locked-icon {440    width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;441    background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08);442    display: flex; align-items: center; justify-content: center;443  }444  .locked-title {445    font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500;446  }447  .locked-input {448    width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);449    border-radius: 7px; color: #F0E8D8; padding: 7px 10px;450    font-size: 11px; font-family: "IBM Plex Mono", monospace;451    outline: none; transition: border-color 120ms, box-shadow 120ms;452  }453  .locked-input:focus { border-color: rgba(217,104,48,0.4); box-shadow: 0 0 0 2px rgba(217,104,48,0.10); }454  .locked-input::placeholder { color: rgba(240,232,216,0.2); }455  .locked-btn {456    width: 100%; padding: 8px; border-radius: 8px; border: none;457    background: #D96830; color: #fff; font-size: 12px;458    font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;459    transition: background 120ms; letter-spacing: 0.01em;460  }461  .locked-btn:hover { background: #c55e28; }462  .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; }463  .locked-error {464    font-size: 10px; color: rgba(220,80,60,0.85);465    font-family: "IBM Plex Mono", monospace;466  }467`;468469export function showAutofillSelector(470  anchorEl: HTMLInputElement,471  entries: SelectorEntry[],472  onSelect: (id: string) => void,473  options: SelectorOptions = {}474): () => void {475  const host = document.createElement("div");476  host.style.cssText = "position: absolute; z-index: 2147483647;";477  const shadow = host.attachShadow({ mode: "closed" });478479  const style = shadow.appendChild(document.createElement("style"));480  style.textContent = SELECTOR_STYLES;481482  const rect = anchorEl.getBoundingClientRect();483  host.style.top = `${rect.bottom + window.scrollY + 5}px`;484  host.style.left = `${rect.left + window.scrollX}px`;485  host.style.width = `${Math.max(rect.width, 260)}px`;486487  const container = shadow.appendChild(document.createElement("div"));488  container.className = "selector";489490  const searchableEntries = options.searchableEntries ?? [];491  const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();492493  let query = "";494  let focusedIndex = -1;495  let currentMode: "list" | "create" | "success" = "list";496  let searchInput: HTMLInputElement | null = null;497498  // Pre-capture the anchor field value for username prefill499  const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";500  const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");501  const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";502503  function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {504    const seen = new Set<string>();505    return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });506  }507508  function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {509    const q = value.trim().toLowerCase();510    const deduped = uniqueEntries(source);511    if (!q) return deduped.slice(0, 8);512    return deduped.filter(e =>513      [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)514    ).slice(0, 12);515  }516517  function activeEntries(): SelectorEntry[] {518    // When user is typing a search, filter across all known entries (domain matches + vault)519    if (query.trim()) {520      const merged = uniqueEntries([...entries, ...searchableEntries]);521      return filterEntries(merged, query);522    }523    // Default: show domain matches; fall back to vault search when no matches524    if (entries.length > 0) return entries;525    return filterEntries(searchableEntries, "").slice(0, 8);526  }527528  function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {529    const icon = document.createElement("div");530    icon.className = `entry-icon${isNew ? " is-new" : ""}`;531    if (isNew) {532      icon.innerHTML = plusSvgStr();533    } else {534      // Use the generic key glyph for all entries in the page-injected selector.535      // Avoids disclosing vault domains to any third party and avoids needing536      // to expose the browser favicon API to page context via web_accessible_resources.537      void url;538      icon.innerHTML = keySvgStr();539    }540    return icon;541  }542543  function renderList() {544    container.innerHTML = "";545    focusedIndex = -1;546    searchInput = null;547548    const current = activeEntries();549550    // Domain header (always show)551    const domHdr = document.createElement("div");552    domHdr.className = "domain-header";553    const eyebrow = document.createElement("div");554    eyebrow.className = "domain-eyebrow";555    eyebrow.textContent = options.isSignup556      ? "Sign up as"557      : entries.length > 0 ? "Saved for this site" : "No saved login";558    const domName = document.createElement("div");559    domName.className = "domain-name";560    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");561    domHdr.append(eyebrow, domName);562    container.appendChild(domHdr);563564    // Search — always show so user can find entries beyond the domain matches565    const sw = document.createElement("div"); sw.className = "search-wrap";566    searchInput = document.createElement("input");567    searchInput.className = "search";568    searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";569    searchInput.value = query;570    searchInput.addEventListener("input", () => {571      query = searchInput?.value ?? "";572      renderList();573      searchInput?.focus();574      searchInput?.setSelectionRange(query.length, query.length);575    });576    sw.appendChild(searchInput);577    container.appendChild(sw);578579    // Results580    const results = document.createElement("div");581    results.className = "results";582    container.appendChild(results);583584    if (current.length === 0 && entries.length > 0) {585      // Show a hint but don't render empty entries586    }587588    for (let i = 0; i < current.length; i++) {589      const e = current[i]!;590      const entryEl = document.createElement("div");591      entryEl.className = "entry";592      entryEl.dataset.id = e.id;593      entryEl.dataset.index = String(i);594      entryEl.setAttribute("tabindex", "-1");595596      entryEl.appendChild(makeEntryIcon(e.url));597598      const info = document.createElement("div"); info.className = "entry-info";599      const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;600      const subEl = document.createElement("div"); subEl.className = "entry-sub";601      const domain = e.url ? getDomainFromUrl(e.url) : "";602      subEl.textContent = domain || e.username || "";603      info.append(titleEl, subEl);604      entryEl.appendChild(info);605606      if (e.hasTotp) {607        const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";608        entryEl.appendChild(badge);609      }610611      entryEl.addEventListener("click", () => {612        if (options.isSignup && options.onFillFields) {613          // Signup mode: clicking a saved identity fills the username field614          options.onFillFields(e.username ?? "", "");615          dismiss();616        } else {617          onSelect(e.id);618          dismiss();619        }620      });621      results.appendChild(entryEl);622    }623624    // Divider before "New entry"625    if (current.length > 0) {626      results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));627    }628629    // "New entry" row — always present630    const newEl = document.createElement("div");631    newEl.className = "entry";632    newEl.setAttribute("tabindex", "-1");633    newEl.appendChild(makeEntryIcon(undefined, true));634635    const newInfo = document.createElement("div"); newInfo.className = "entry-info";636    const newTitle = document.createElement("div");637    newTitle.className = "entry-title is-new";638    newTitle.textContent = `New entry for ${sTitle}`;639    const newSub = document.createElement("div");640    newSub.className = "entry-sub";641    newSub.textContent = sOrigin;642    newInfo.append(newTitle, newSub);643    newEl.appendChild(newInfo);644    newEl.addEventListener("click", showCreateForm);645    results.appendChild(newEl);646647    // Focus search if present648    if (searchInput) {649      requestAnimationFrame(() => searchInput?.focus());650    }651  }652653  function setFocused(idx: number) {654    const all = container.querySelectorAll<HTMLElement>(".entry");655    all.forEach((el, i) => el.classList.toggle("focused", i === idx));656    focusedIndex = idx;657    if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });658  }659660  function showCreateForm() {661    currentMode = "create";662    container.innerHTML = "";663664    const wrap = document.createElement("div");665    wrap.className = "create-form-wrap";666667    // Header668    const head = document.createElement("div"); head.className = "create-head";669    const backBtn = document.createElement("button"); backBtn.className = "back-btn";670    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>`;671    backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });672    const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";673    head.append(backBtn, lbl);674675    // Body676    const body = document.createElement("div"); body.className = "create-body";677678    function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {679      const g = document.createElement("div"); g.className = "f-group";680      const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;681      const inp = document.createElement("input"); inp.className = "f-input";682      g.append(l, inp); return { g, inp };683    }684685    // Title686    const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";687    body.appendChild(tg.g);688689    // URL690    const ug = fGroup("website");691    ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";692    if (loginSubdomain) {693      const h = document.createElement("div"); h.className = "url-hint";694      h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);695    }696    body.appendChild(ug.g);697698    // Username (pre-fill from anchor field)699    const xg = fGroup("username / email");700    xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";701    xg.inp.setAttribute("autocomplete", "off");702    body.appendChild(xg.g);703704    // Password + generate705    const pg = fGroup("password");706    const pwInp = document.createElement("input"); pwInp.className = "f-input";707    pwInp.type = "text"; pwInp.value = prePassword || generatePassword();708    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';709    pwInp.style.letterSpacing = "0.04em";710    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");711    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";712    genBtn.innerHTML = genSvgStr() + " regen";713    genBtn.addEventListener("click", () => { pwInp.value = generatePassword(); });714    const pwRow = document.createElement("div"); pwRow.className = "f-row";715    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);716    body.appendChild(pg.g);717718    // Save719    const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";720    saveBtn.textContent = "Save entry";721    saveBtn.addEventListener("click", async () => {722      const title = tg.inp.value.trim();723      const pw = pwInp.value.trim();724      const username = xg.inp.value.trim();725      if (!title || !pw) {726        if (!title) { tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus(); }727        else { pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus(); }728        return;729      }730      saveBtn.disabled = true; saveBtn.textContent = "Saving…";731      try {732        const res = await chrome.runtime.sendMessage({733          type: "ADD_ENTRY",734          payload: { entry: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" } },735        });736        if ((res as { ok?: boolean }).ok) {737          // In signup mode, fill the username + password fields on the page738          if (options.isSignup && options.onFillFields) {739            options.onFillFields(username, pw);740          }741          showSuccess(title);742        } else {743          saveBtn.disabled = false; saveBtn.textContent = "Save entry";744        }745      } catch { saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }746    });747    body.appendChild(saveBtn);748749    wrap.append(head, body);750    container.appendChild(wrap);751    requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });752  }753754  function showSuccess(title: string) {755    currentMode = "success";756    container.innerHTML = "";757    const wrap = document.createElement("div"); wrap.className = "success-wrap";758    const ring = document.createElement("div"); ring.className = "success-ring";759    ring.innerHTML = checkSvgStr();760    const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;761    const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;762    wrap.append(ring, t, s);763    container.appendChild(wrap);764    setTimeout(() => dismiss(), 1600);765  }766767  function renderLocked() {768    container.innerHTML = "";769770    // Domain header (same as list view)771    const domHdr = document.createElement("div");772    domHdr.className = "domain-header";773    const eyebrow = document.createElement("div");774    eyebrow.className = "domain-eyebrow";775    eyebrow.textContent = "vault locked";776    const domName = document.createElement("div");777    domName.className = "domain-name";778    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");779    domHdr.append(eyebrow, domName);780    container.appendChild(domHdr);781782    const wrap = document.createElement("div");783    wrap.className = "locked-wrap";784785    // Icon + label row786    const head = document.createElement("div"); head.className = "locked-head";787    const icon = document.createElement("div"); icon.className = "locked-icon";788    icon.innerHTML = keySvgStr();789    const title = document.createElement("div"); title.className = "locked-title";790    title.textContent = "unlock to autofill";791    head.append(icon, title);792793    // Password input794    const pwInp = document.createElement("input");795    pwInp.className = "locked-input";796    pwInp.type = "password";797    pwInp.placeholder = "master password";798    pwInp.setAttribute("autocomplete", "current-password");799800    // Error line (hidden initially)801    const errEl = document.createElement("div");802    errEl.className = "locked-error";803    errEl.style.display = "none";804805    // Unlock button806    const unlockBtn = document.createElement("button");807    unlockBtn.className = "locked-btn";808    unlockBtn.type = "button";809    unlockBtn.textContent = "unlock";810811    async function tryUnlock() {812      const pw = pwInp.value.trim();813      if (!pw) { pwInp.focus(); return; }814      unlockBtn.disabled = true;815      unlockBtn.textContent = "unlocking…";816      errEl.style.display = "none";817      try {818        const res = await chrome.runtime.sendMessage({819          type: "UNLOCK",820          payload: { password: pw },821        });822        if ((res as { ok?: boolean }).ok) {823          dismiss();824          options.onUnlocked?.();825        } else {826          errEl.textContent = "incorrect password";827          errEl.style.display = "block";828          pwInp.value = "";829          pwInp.focus();830          unlockBtn.disabled = false;831          unlockBtn.textContent = "unlock";832        }833      } catch {834        errEl.textContent = "could not reach Peach";835        errEl.style.display = "block";836        unlockBtn.disabled = false;837        unlockBtn.textContent = "unlock";838      }839    }840841    pwInp.addEventListener("keydown", (e) => { if (e.key === "Enter") void tryUnlock(); });842    unlockBtn.addEventListener("click", () => void tryUnlock());843844    wrap.append(head, pwInp, errEl, unlockBtn);845    container.appendChild(wrap);846    requestAnimationFrame(() => pwInp.focus());847  }848849  function handleKeyDown(e: KeyboardEvent) {850    if (options.locked) {851      if (e.key === "Escape") { e.preventDefault(); dismiss(); }852      return;853    }854    if (currentMode === "create" || currentMode === "success") {855      if (e.key === "Escape") { e.preventDefault(); dismiss(); }856      return;857    }858    const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");859    const count = allEntryEls.length;860    if (document.activeElement === searchInput) {861      if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(); return; }862      if (count === 0) return;863      if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;864    }865    if (count === 0) return;866    switch (e.key) {867      case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;868      case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;869      case "Enter": {870        if (focusedIndex >= 0) {871          const el = allEntryEls[focusedIndex] as HTMLElement;872          const id = el.dataset.id;873          if (id) { onSelect(id); dismiss(); }874          else if (el.classList.contains("is-new") || !id) {875            // "New entry" row (last item, no data-id)876            showCreateForm();877          }878        }879        break;880      }881      case "Escape": case "Tab": e.preventDefault(); dismiss(); break;882    }883  }884885  document.addEventListener("keydown", handleKeyDown);886887  document.body.appendChild(host);888  if (options.locked) {889    renderLocked();890  } else {891    renderList();892  }893894  function updatePosition() {895    const r = anchorEl.getBoundingClientRect();896    host.style.top = `${r.bottom + window.scrollY + 5}px`;897    host.style.left = `${r.left + window.scrollX}px`;898    host.style.width = `${Math.max(r.width, 260)}px`;899  }900  const stopObserving = observeAnchorPosition(anchorEl, updatePosition);901902  function dismiss() {903    stopObserving();904    host.remove();905    document.removeEventListener("click", outsideClick);906    document.removeEventListener("keydown", handleKeyDown);907  }908909  function outsideClick(ev: MouseEvent) {910    const path = ev.composedPath();911    // Never dismiss when clicking the anchor field itself — that click is912    // what triggered the selector to open, and the user may click the field913    // again to re-focus it without intending to close the selector.914    if (!path.includes(host) && !path.includes(anchorEl)) dismiss();915  }916  setTimeout(() => document.addEventListener("click", outsideClick), 0);917918  return dismiss;919}