/** * inject.ts — autofill icon + selector overlay, injected into pages. * * Runs as a content script. Injects the Peach icon next to detected login * fields, anchors it via event-driven positioning (no perpetual rAF — see * Projects.md §4.7), and renders the inline autofill selector in a shadow * DOM. Never holds decrypted vault data directly: all entry lookups go * through `chrome.runtime.sendMessage`, and only the minimum fields needed * to fill come back across the boundary. */ import { ACCENT_COLORS } from "../shared/types"; let accentHex = "#E8956D"; chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => { const name = resp?.data?.accentColor ?? "peach"; accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D"; }); export interface SelectorEntry { id: string; title: string; username?: string; url?: string; hasTotp?: boolean; } interface SelectorOptions { domain?: string; searchableEntries?: SelectorEntry[]; /** True when shown on a signup form — changes header label and entry-click behaviour */ isSignup?: boolean; /** * Called after the user picks a saved identity (signup mode only) or * after successfully saving a new entry on a signup form. * Lets the caller fill the visible username + password fields. */ onFillFields?: (username: string, password: string) => void; /** * When true, show an inline unlock form instead of the entries list. * Used when the vault is locked so the user can unlock without opening * the popup. */ locked?: boolean; /** * Called after a successful inline unlock — the caller should re-query * entries and re-open the selector with real data. */ onUnlocked?: () => void; /** * Called when the user explicitly dismisses the selector (Escape, Tab, or * click outside) — NOT when an entry is selected or on auto-close. * Used by the caller to suppress spurious reopens on the same field. */ onDismiss?: () => void; } function getDomainFromUrl(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; } } function keySvgStr(): string { return ``; } function plusSvgStr(): string { return ``; } function peachIconSvg(): string { // Peach body is always brand-peach (#D96830), not `accentHex` — a sage // or sky accent must not turn the autofill icon green or blue. Accent // color still tints surrounding glow/hover halos below. return ``; } function genSvgStr(): string { return ``; } function checkSvgStr(): string { return ``; } function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } { const { hostname, origin } = location; const parts = hostname.split("."); const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso", "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth", "oidc", "federation", "saml", "cas", "ldap"]; const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase()); const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!; const title = rootName.charAt(0).toUpperCase() + rootName.slice(1); return { origin, title, loginSubdomain }; } /** * Shows a bottom-of-page toast after filling credentials when the entry's * saved domain doesn't match the current site — offering to set the current * origin as the entry's default URL. Closes automatically after 9 seconds. */ export function showDomainConfirmToast( pageDomain: string, entryId: string, ): void { const host = document.createElement("div"); host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;"; host.style.setProperty("--peach-accent", accentHex); const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` .bar { display:flex;align-items:center;gap:10px;padding:10px 14px; background:#131109;border:1px solid rgba(240,232,216,0.09); border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75); font-family:Inter,sans-serif;white-space:nowrap; animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} } .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; } .yes { padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px; background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif; } .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); } .no { padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px; background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif; } `; shadow.appendChild(style); const bar = document.createElement("div"); bar.className = "bar"; const txt = document.createElement("span"); txt.className = "txt"; txt.textContent = `Set ${pageDomain} as default for this entry?`; const yesBtn = document.createElement("button"); yesBtn.className = "yes"; yesBtn.textContent = "Set default"; yesBtn.addEventListener("click", async () => { await chrome.runtime.sendMessage({ type: "UPDATE_ENTRY", payload: { id: entryId, patch: { url: location.origin } }, }); host.remove(); }); const noBtn = document.createElement("button"); noBtn.className = "no"; noBtn.textContent = "Skip"; noBtn.addEventListener("click", () => host.remove()); bar.append(txt, yesBtn, noBtn); shadow.appendChild(bar); document.body.appendChild(host); setTimeout(() => host.remove(), 9000); } function generatePassword(): string { const u = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", l = "abcdefghijklmnopqrstuvwxyz"; const n = "0123456789", s = "!@#$%^&*"; const all = u + l + n + s; const arr = new Uint8Array(20); crypto.getRandomValues(arr); const res = [u[arr[0]! % u.length]!, l[arr[1]! % l.length]!, n[arr[2]! % n.length]!, s[arr[3]! % s.length]!, ...Array.from(arr.slice(4), b => all[b % all.length]!)]; for (let i = res.length - 1; i > 0; i--) { const j = arr[i % arr.length]! % (i + 1); [res[i], res[j]] = [res[j]!, res[i]!]; } return res.join(""); } export function observeAnchorPosition( anchorEl: HTMLElement, onUpdate: () => void ): () => void { let rafId = 0; let burstUntil = 0; const runUpdate = () => { rafId = 0; onUpdate(); if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate); }; const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); }; const scheduleBurst = (ms = 220) => { burstUntil = Math.max(burstUntil, performance.now() + ms); scheduleUpdate(); }; const resizeObserver = new ResizeObserver(() => scheduleBurst()); resizeObserver.observe(anchorEl); if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement); const mutationObserver = new MutationObserver(() => scheduleBurst()); mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] }); if (anchorEl.parentElement) { mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] }); } const onScroll = () => scheduleUpdate(); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onScroll); window.visualViewport?.addEventListener("resize", onScroll); window.visualViewport?.addEventListener("scroll", onScroll); anchorEl.addEventListener("focus", () => scheduleBurst()); anchorEl.addEventListener("input", () => scheduleBurst()); scheduleBurst(260); return () => { if (rafId) cancelAnimationFrame(rafId); resizeObserver.disconnect(); mutationObserver.disconnect(); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); window.visualViewport?.removeEventListener("resize", onScroll); window.visualViewport?.removeEventListener("scroll", onScroll); }; } export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void { const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-label", "Peach autofill"); button.title = "Fill with Peach"; button.innerHTML = peachIconSvg(); // position: fixed is load-bearing — not absolute. Absolute positioning // inherits the nearest positioned ancestor's box, which on sites with // overflow:hidden or sticky parents clips or offsets the icon. Fixed // anchors to the viewport and escapes all parent layout. button.style.cssText = ` position: fixed; z-index: 2147483646; width: 26px; height: 26px; cursor: pointer; pointer-events: auto; border: none; padding: 3px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border-radius: 999px; transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease; opacity: 0; `; button.addEventListener("mouseenter", () => { button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`; button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`; button.style.transform = "scale(1.08)"; }); button.addEventListener("mouseleave", () => { button.style.background = "transparent"; button.style.boxShadow = "none"; button.style.transform = "none"; }); button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); }); button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); }); document.body.appendChild(button); function updatePosition() { const rect = field.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { button.style.opacity = "0"; button.style.pointerEvents = "none"; return; } const sz = 26; const inset = Math.max(5, Math.min(10, rect.height * 0.22)); const top = rect.top + rect.height / 2 - sz / 2; // Clamp left so the icon stays fully inside the field's right edge, never // spilling onto an adjacent sibling button. const minLeft = rect.left + 4; const left = Math.max(minLeft, rect.right - sz - inset); // Hide if the field is too narrow to fit the icon cleanly (< 40px) if (rect.width < 40) { button.style.opacity = "0"; button.style.pointerEvents = "none"; return; } button.style.top = `${top}px`; button.style.left = `${left}px`; button.style.opacity = "1"; button.style.pointerEvents = "auto"; } updatePosition(); requestAnimationFrame(() => { button.style.opacity = "1"; }); const stopObserving = observeAnchorPosition(field, updatePosition); return () => { stopObserving(); button.remove(); }; } // ── Shared selector + new-entry styles ──────────────────────────── const SELECTOR_STYLES = ` :host { all: initial; } *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; } .selector { background: #0f0e0b; border: 1px solid rgba(240,232,216,0.10); border-radius: 13px; overflow: hidden; box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7); font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes sl-in { from { opacity:0; transform:translateY(-6px) scale(0.97) } to { opacity:1; transform:none } } /* Domain header */ .domain-header { padding: 9px 13px 7px; border-bottom: 1px solid rgba(240,232,216,0.055); display: flex; align-items: center; justify-content: space-between; } .domain-eyebrow { font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace; } .domain-name { font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent); font-family: "IBM Plex Mono", monospace; } /* Search */ .search-wrap { padding: 9px 12px 7px; border-bottom: 1px solid rgba(240,232,216,0.04); } .search { width: 100%; background: rgba(240,232,216,0.03); border: 1px solid rgba(240,232,216,0.08); border-radius: 7px; color: #F0E8D8; padding: 7px 10px; font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace; transition: border-color 120ms, box-shadow 120ms; } .search: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); } .search::placeholder { color: rgba(240,232,216,0.2); } /* Results */ .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; } .results::-webkit-scrollbar { display: none; } /* Entry rows */ .entry { display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04); transition: background 70ms; position: relative; outline: none; } .entry:last-child { border-bottom: none; } .entry:hover { background: rgba(240,232,216,0.04); } .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); } .entry.focused::before { content: ""; position: absolute; left: 0; top: 3px; bottom: 3px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent); } .entry-icon { width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0; background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07); display: flex; align-items: center; justify-content: center; overflow: hidden; margin-right: 10px; } .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; } .entry-icon.is-new { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent); color: color-mix(in srgb, var(--peach-accent) 80%, transparent); } .entry-info { flex: 1; min-width: 0; } .entry-title { font-size: 12.5px; color: #F0E8D8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; } .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; } .entry-sub { font-size: 10px; color: rgba(240,232,216,0.32); font-family: "IBM Plex Mono", monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } .totp-badge { font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px; border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 8px; } .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; } .empty-hint { padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace; } /* ── Create form ─────────────────────────────────────── */ .create-form-wrap { animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes cf-in { from { opacity:0; transform:translateX(8px) } to { opacity:1; transform:none } } .create-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055); } .back-btn { background: transparent; border: none; cursor: pointer; color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px; display: flex; align-items: center; line-height: 0; transition: color 100ms, background 100ms; } .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); } .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; } .create-body { padding: 12px; display: grid; gap: 9px; } .f-group { display: grid; gap: 3px; } .f-label { font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace; } .f-input { background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09); border-radius: 7px; color: #F0E8D8; padding: 7px 10px; font-size: 11px; font-family: "IBM Plex Mono", monospace; outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms; } .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); } .f-input::placeholder { color: rgba(240,232,216,0.18); } .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; } .f-row { display: flex; gap: 6px; } .f-row .f-input { flex: 1; min-width: 0; } .gen-btn { padding: 0 10px; border-radius: 7px; border: none; background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: color-mix(in srgb, var(--peach-accent) 80%, transparent); font-size: 10px; font-family: "IBM Plex Mono", monospace; cursor: pointer; white-space: nowrap; flex-shrink: 0; display: flex; align-items: center; gap: 4px; transition: background 100ms; } .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); } .save-btn { width: 100%; padding: 8px; border-radius: 8px; border: none; background: var(--peach-accent); color: #fff; font-size: 12px; font-family: Inter, sans-serif; cursor: pointer; font-weight: 500; transition: background 120ms; letter-spacing: 0.01em; } .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); } .save-btn:disabled { opacity: 0.4; cursor: not-allowed; } .url-hint { font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent); font-family: "IBM Plex Mono", monospace; font-style: italic; } /* ── Success ─────────────────────────────────────────── */ .success-wrap { display: flex; flex-direction: column; align-items: center; padding: 24px 16px 20px; gap: 8px; animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} } .success-ring { width: 40px; height: 40px; border-radius: 50%; background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25); display: flex; align-items: center; justify-content: center; } .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; } .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; } /* ── Locked state ────────────────────────────────────── */ .locked-wrap { display: flex; flex-direction: column; align-items: stretch; padding: 14px 13px 13px; gap: 9px; animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both; } .locked-head { display: flex; align-items: center; gap: 8px; padding-bottom: 2px; } .locked-icon { width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0; background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08); display: flex; align-items: center; justify-content: center; } .locked-title { font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500; } .locked-input { width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09); border-radius: 7px; color: #F0E8D8; padding: 7px 10px; font-size: 11px; font-family: "IBM Plex Mono", monospace; outline: none; transition: border-color 120ms, box-shadow 120ms; } .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); } .locked-input::placeholder { color: rgba(240,232,216,0.2); } .locked-btn { width: 100%; padding: 8px; border-radius: 8px; border: none; background: var(--peach-accent); color: #fff; font-size: 12px; font-family: Inter, sans-serif; cursor: pointer; font-weight: 500; transition: background 120ms; letter-spacing: 0.01em; } .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); } .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; } .locked-error { font-size: 10px; color: rgba(220,80,60,0.85); font-family: "IBM Plex Mono", monospace; } `; export function showAutofillSelector( anchorEl: HTMLInputElement, entries: SelectorEntry[], onSelect: (id: string) => void, options: SelectorOptions = {} ): () => void { const host = document.createElement("div"); host.style.cssText = "position: absolute; z-index: 2147483647;"; host.style.setProperty("--peach-accent", accentHex); const shadow = host.attachShadow({ mode: "closed" }); const style = shadow.appendChild(document.createElement("style")); style.textContent = SELECTOR_STYLES; const rect = anchorEl.getBoundingClientRect(); host.style.top = `${rect.bottom + window.scrollY + 5}px`; host.style.left = `${rect.left + window.scrollX}px`; host.style.width = `${Math.max(rect.width, 260)}px`; const container = shadow.appendChild(document.createElement("div")); container.className = "selector"; const searchableEntries = options.searchableEntries ?? []; const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl(); let query = ""; let focusedIndex = -1; let currentMode: "list" | "create" | "success" = "list"; let searchInput: HTMLInputElement | null = null; // Pre-capture the anchor field value for username prefill const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : ""; const pwInForm = anchorEl.closest("form")?.querySelector("input[type='password']"); const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? ""; function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] { const seen = new Set(); return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; }); } function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] { const q = value.trim().toLowerCase(); const deduped = uniqueEntries(source); if (!q) return deduped.slice(0, 8); return deduped.filter(e => [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q) ).slice(0, 12); } function activeEntries(): SelectorEntry[] { // When user is typing a search, filter across all known entries (domain matches + vault) if (query.trim()) { const merged = uniqueEntries([...entries, ...searchableEntries]); return filterEntries(merged, query); } // Default: show domain matches; fall back to vault search when no matches if (entries.length > 0) return entries; return filterEntries(searchableEntries, "").slice(0, 8); } function makeEntryIcon(url?: string, isNew = false): HTMLDivElement { const icon = document.createElement("div"); icon.className = `entry-icon${isNew ? " is-new" : ""}`; if (isNew) { icon.innerHTML = plusSvgStr(); } else { // Use the generic key glyph for all entries in the page-injected selector. // Avoids disclosing vault domains to any third party and avoids needing // to expose the browser favicon API to page context via web_accessible_resources. void url; icon.innerHTML = keySvgStr(); } return icon; } function renderList() { container.innerHTML = ""; focusedIndex = -1; searchInput = null; const current = activeEntries(); // Domain header (always show) const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const eyebrow = document.createElement("div"); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = options.isSignup ? "Sign up as" : entries.length > 0 ? "Saved for this site" : "No saved login"; const domName = document.createElement("div"); domName.className = "domain-name"; domName.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); domHdr.append(eyebrow, domName); container.appendChild(domHdr); // Search — always show so user can find entries beyond the domain matches const sw = document.createElement("div"); sw.className = "search-wrap"; searchInput = document.createElement("input"); searchInput.className = "search"; searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…"; searchInput.value = query; searchInput.addEventListener("input", () => { query = searchInput?.value ?? ""; renderList(); searchInput?.focus(); searchInput?.setSelectionRange(query.length, query.length); }); sw.appendChild(searchInput); container.appendChild(sw); // Results const results = document.createElement("div"); results.className = "results"; container.appendChild(results); if (current.length === 0 && entries.length > 0) { // Show a hint but don't render empty entries } for (let i = 0; i < current.length; i++) { const e = current[i]!; const entryEl = document.createElement("div"); entryEl.className = "entry"; entryEl.dataset.id = e.id; entryEl.dataset.index = String(i); entryEl.setAttribute("tabindex", "-1"); entryEl.appendChild(makeEntryIcon(e.url)); const info = document.createElement("div"); info.className = "entry-info"; const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title; const subEl = document.createElement("div"); subEl.className = "entry-sub"; const domain = e.url ? getDomainFromUrl(e.url) : ""; subEl.textContent = domain || e.username || ""; info.append(titleEl, subEl); entryEl.appendChild(info); if (e.hasTotp) { const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP"; entryEl.appendChild(badge); } entryEl.addEventListener("click", () => { if (options.isSignup && options.onFillFields) { // Signup mode: clicking a saved identity fills the username field options.onFillFields(e.username ?? "", ""); dismiss(); } else { onSelect(e.id); dismiss(); } }); results.appendChild(entryEl); } // Divider before "New entry" if (current.length > 0) { results.appendChild(Object.assign(document.createElement("div"), { className: "divider" })); } // "New entry" row — always present const newEl = document.createElement("div"); newEl.className = "entry"; newEl.setAttribute("tabindex", "-1"); newEl.appendChild(makeEntryIcon(undefined, true)); const newInfo = document.createElement("div"); newInfo.className = "entry-info"; const newTitle = document.createElement("div"); newTitle.className = "entry-title is-new"; newTitle.textContent = `New entry for ${sTitle}`; const newSub = document.createElement("div"); newSub.className = "entry-sub"; newSub.textContent = sOrigin; newInfo.append(newTitle, newSub); newEl.appendChild(newInfo); newEl.addEventListener("click", showCreateForm); results.appendChild(newEl); // Focus search if present if (searchInput) { requestAnimationFrame(() => searchInput?.focus()); } } function setFocused(idx: number) { const all = container.querySelectorAll(".entry"); all.forEach((el, i) => el.classList.toggle("focused", i === idx)); focusedIndex = idx; if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" }); } async function showCreateForm() { currentMode = "create"; container.innerHTML = ""; // Guard: the service worker may lock the vault between when the selector // opens and when the user clicks "New entry". Show the inline unlock form // instead of silently failing on save. const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null; if (!statusRes?.unlocked) { renderLocked(); return; } const wrap = document.createElement("div"); wrap.className = "create-form-wrap"; // Header const head = document.createElement("div"); head.className = "create-head"; const backBtn = document.createElement("button"); backBtn.className = "back-btn"; backBtn.innerHTML = ``; backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); }); const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry"; head.append(backBtn, lbl); // Body const body = document.createElement("div"); body.className = "create-body"; function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } { const g = document.createElement("div"); g.className = "f-group"; const l = document.createElement("div"); l.className = "f-label"; l.textContent = label; const inp = document.createElement("input"); inp.className = "f-input"; g.append(l, inp); return { g, inp }; } // Title const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google"; body.appendChild(tg.g); // URL const ug = fGroup("website"); ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://"; if (loginSubdomain) { const h = document.createElement("div"); h.className = "url-hint"; h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h); } body.appendChild(ug.g); // Username (pre-fill from anchor field) const xg = fGroup("username / email"); xg.inp.value = preUsername; xg.inp.placeholder = "your@email.com"; xg.inp.setAttribute("autocomplete", "off"); body.appendChild(xg.g); // Password + generate const pg = fGroup("password"); const pwInp = document.createElement("input"); pwInp.className = "f-input"; pwInp.type = "text"; pwInp.value = prePassword || generatePassword(); pwInp.style.fontFamily = '"IBM Plex Mono", monospace'; pwInp.style.letterSpacing = "0.04em"; pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false"); const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button"; genBtn.innerHTML = genSvgStr() + " regen"; genBtn.addEventListener("click", () => { pwInp.value = generatePassword(); }); const pwRow = document.createElement("div"); pwRow.className = "f-row"; pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow); body.appendChild(pg.g); // Save const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button"; saveBtn.textContent = "Save entry"; const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none"; function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; } saveBtn.addEventListener("click", async () => { const title = tg.inp.value.trim(); const pw = pwInp.value.trim(); const username = xg.inp.value.trim(); if (!title) { tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus(); showFormError("Entry needs a title"); return; } if (!pw) { pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus(); showFormError("Entry needs a password"); return; } errEl.style.display = "none"; saveBtn.disabled = true; saveBtn.textContent = "Saving…"; try { const res = await chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { entry: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" } }, }); if ((res as { ok?: boolean }).ok) { // In signup mode, fill the username + password fields on the page if (options.isSignup && options.onFillFields) { options.onFillFields(username, pw); } showSuccess(title); } else { const errMsg = (res as { error?: string }).error === "vault locked" ? "Vault locked — open Peach to save" : "Could not save entry"; showFormError(errMsg); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; } } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; } }); body.appendChild(saveBtn); body.appendChild(errEl); wrap.append(head, body); container.appendChild(wrap); requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); }); } function showSuccess(title: string) { currentMode = "success"; container.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "success-wrap"; const ring = document.createElement("div"); ring.className = "success-ring"; ring.innerHTML = checkSvgStr(); const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`; const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin; wrap.append(ring, t, s); container.appendChild(wrap); setTimeout(() => dismiss(), 1600); } function renderLocked() { container.innerHTML = ""; // Domain header (same as list view) const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const eyebrow = document.createElement("div"); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "vault locked"; const domName = document.createElement("div"); domName.className = "domain-name"; domName.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); domHdr.append(eyebrow, domName); container.appendChild(domHdr); const wrap = document.createElement("div"); wrap.className = "locked-wrap"; // Icon + label row const head = document.createElement("div"); head.className = "locked-head"; const icon = document.createElement("div"); icon.className = "locked-icon"; icon.innerHTML = keySvgStr(); const title = document.createElement("div"); title.className = "locked-title"; title.textContent = "unlock to autofill"; head.append(icon, title); // Password input const pwInp = document.createElement("input"); pwInp.className = "locked-input"; pwInp.type = "password"; pwInp.placeholder = "master password"; pwInp.setAttribute("autocomplete", "current-password"); // Error line (hidden initially) const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none"; // Unlock button const unlockBtn = document.createElement("button"); unlockBtn.className = "locked-btn"; unlockBtn.type = "button"; unlockBtn.textContent = "unlock"; async function tryUnlock() { const pw = pwInp.value.trim(); if (!pw) { pwInp.focus(); return; } unlockBtn.disabled = true; unlockBtn.textContent = "unlocking…"; errEl.style.display = "none"; try { const res = await chrome.runtime.sendMessage({ type: "UNLOCK", payload: { password: pw }, }); if ((res as { ok?: boolean }).ok) { dismiss(); options.onUnlocked?.(); } else { errEl.textContent = "incorrect password"; errEl.style.display = "block"; pwInp.value = ""; pwInp.focus(); unlockBtn.disabled = false; unlockBtn.textContent = "unlock"; } } catch { errEl.textContent = "could not reach Peach"; errEl.style.display = "block"; unlockBtn.disabled = false; unlockBtn.textContent = "unlock"; } } pwInp.addEventListener("keydown", (e) => { if (e.key === "Enter") void tryUnlock(); }); unlockBtn.addEventListener("click", () => void tryUnlock()); wrap.append(head, pwInp, errEl, unlockBtn); container.appendChild(wrap); requestAnimationFrame(() => pwInp.focus()); } function handleKeyDown(e: KeyboardEvent) { if (options.locked) { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } if (currentMode === "create" || currentMode === "success") { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)"); const count = allEntryEls.length; if (document.activeElement === searchInput) { if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; } if (count === 0) return; if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return; } if (count === 0) return; switch (e.key) { case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break; case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break; case "Enter": { if (focusedIndex >= 0) { const el = allEntryEls[focusedIndex] as HTMLElement; const id = el.dataset.id; if (id) { onSelect(id); dismiss(); } else if (el.classList.contains("is-new") || !id) { // "New entry" row (last item, no data-id) showCreateForm(); } } break; } case "Escape": case "Tab": e.preventDefault(); dismiss(true); break; } } document.addEventListener("keydown", handleKeyDown); document.body.appendChild(host); if (options.locked) { renderLocked(); } else { renderList(); } function updatePosition() { const r = anchorEl.getBoundingClientRect(); host.style.top = `${r.bottom + window.scrollY + 5}px`; host.style.left = `${r.left + window.scrollX}px`; host.style.width = `${Math.max(r.width, 260)}px`; } const stopObserving = observeAnchorPosition(anchorEl, updatePosition); function dismiss(userAction = false) { stopObserving(); host.remove(); document.removeEventListener("click", outsideClick, { capture: true }); document.removeEventListener("keydown", handleKeyDown); if (userAction) options.onDismiss?.(); } function outsideClick(ev: MouseEvent) { const path = ev.composedPath(); // Never dismiss when clicking the anchor field itself — that click is // what triggered the selector to open, and the user may click the field // again to re-focus it without intending to close the selector. if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true); } // capture: true so pages that call stopPropagation() on click don't block our dismiss handler setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0); return dismiss; }