/** * 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, type PasswordPolicy } from "../shared/types"; import { generateSmartPassword } from "../shared/generate"; import { detectPasswordPolicy } from "./policy"; let accentHex = "#E8956D"; let popupTheme: "dark" | "light" = "dark"; chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string; theme?: string } } | undefined) => { const data = resp?.data; const name = data?.accentColor ?? "peach"; accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D"; popupTheme = data?.theme === "light" ? "light" : "dark"; }); // 8-digit hex alpha helpers for the accent-derived shadow tokens. // `#E8956D` + alpha `28` → `#E8956D28`. The browser parses the trailing // two hex digits as the alpha channel. function withAlpha(hex: string, alpha: string): string { // Defensive: only 6-digit hex inputs are supported here. ACCENT_COLORS // always exposes 6-digit hex, so this is a guard against future changes // shipping shorthand colors that would silently break the var injection. return /^#[0-9a-f]{6}$/i.test(hex) ? hex + alpha : hex; } 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 has * never matched the current site — offering to add the current origin to the * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s. */ export function showRememberSiteToast( 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 = `Remember ${pageDomain} for this entry?`; const yesBtn = document.createElement("button"); yesBtn.className = "yes"; yesBtn.textContent = "Remember"; yesBtn.addEventListener("click", async () => { await chrome.runtime.sendMessage({ type: "ADD_ADDITIONAL_URL", payload: { id: entryId, url: location.origin }, }); host.remove(); }); const noBtn = document.createElement("button"); noBtn.className = "no"; noBtn.textContent = "Not now"; noBtn.addEventListener("click", () => host.remove()); bar.append(txt, yesBtn, noBtn); shadow.appendChild(bar); document.body.appendChild(host); setTimeout(() => host.remove(), 9000); } function generatePasswordForSite(policy: PasswordPolicy = {}): string { return generateSmartPassword(policy); } 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(); }; } // All theme-dependent values resolve through tokens scoped to // .peach-inline-popup[data-theme="dark|light"]. Accent variables // (--peach-accent, --peach-accent-dim, --peach-glow, --peach-border-accent) // are identical in both themes — only surface tokens differ. See // Projects.md §4.14: hardcoded hex inside this stylesheet breaks the // theme switch — only literal brand-peach in peachIconSvg may stay raw. const SELECTOR_STYLES = ` :host { all: initial; } *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; } .peach-inline-popup[data-theme="dark"] { --peach-bg: #1a1410; --peach-bg-card: #221c16; --peach-text: #f5f0ea; --peach-text-dim: #8a7a6a; --peach-text-muted: #4a3a2a; --peach-border: rgba(255,255,255,0.07); --peach-input-bg: rgba(255,255,255,0.05); --peach-input-border: rgba(255,255,255,0.08); } .peach-inline-popup[data-theme="light"] { --peach-bg: #fdf8f4; --peach-bg-card: #f5ede4; --peach-text: #1c1310; --peach-text-dim: #6b5a4a; --peach-text-muted: #a89080; --peach-border: rgba(0,0,0,0.08); --peach-input-bg: rgba(0,0,0,0.04); --peach-input-border: rgba(0,0,0,0.10); } .peach-inline-popup { background: var(--peach-bg); border: 1px solid var(--peach-border-accent, var(--peach-border)); border-radius: 14px; overflow: hidden; box-shadow: 0 8px 40px var(--peach-glow, transparent), 0 2px 12px rgba(0,0,0,0.5); font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; animation: peach-enter 200ms cubic-bezier(0.16,1,0.3,1) forwards; } /* Light theme: shadow against a light page is too strong otherwise — drop both the accent glow and the dark drop-shadow opacity to keep the popup elegant. */ .peach-inline-popup[data-theme="light"] { box-shadow: 0 8px 40px color-mix(in srgb, var(--peach-accent) 10%, transparent), 0 2px 12px rgba(0,0,0,0.12); } .peach-inline-popup.is-exiting { animation: peach-exit 130ms cubic-bezier(0.4,0,1,1) forwards; pointer-events: none; } @keyframes peach-enter { 0% { opacity: 0; transform: translateY(-8px) scale(0.96); filter: blur(3px); } 65% { opacity: 1; filter: blur(0); } 100% { opacity: 1; transform: translateY(0) scale(1); } } @keyframes peach-exit { 0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } 100% { opacity: 0; transform: translateY(-4px) scale(0.98); filter: blur(2px); } } @keyframes peach-slide-in { from { opacity: 0; transform: translateX(12px); } to { opacity: 1; transform: translateX(0); } } @keyframes peach-slide-back { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } .peach-content[data-dir="forward"] { animation: peach-slide-in 150ms cubic-bezier(0.16,1,0.3,1) both; } .peach-content[data-dir="back"] { animation: peach-slide-back 150ms cubic-bezier(0.16,1,0.3,1) both; } /* Domain header */ .domain-header { padding: 9px 13px 7px; border-bottom: 1px solid var(--peach-border); display: flex; align-items: center; justify-content: space-between; } .domain-eyebrow { font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--peach-text-dim); 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 var(--peach-border); } .search { width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); border-radius: 7px; color: var(--peach-text); 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: var(--peach-text-muted); } /* 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 var(--peach-border); transition: background 70ms; position: relative; outline: none; } .entry:last-child { border-bottom: none; } .entry:hover { background: color-mix(in srgb, var(--peach-text) 4%, transparent); } .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: var(--peach-input-bg); border: 1px solid var(--peach-border); 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: var(--peach-text); 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: var(--peach-text-dim); 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: var(--peach-border); margin: 2px 0; } .empty-hint { padding: 14px 13px; font-size: 11px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; } /* Create form */ .create-form-wrap { animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both; } .create-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--peach-border); } .back-btn { background: transparent; border: none; cursor: pointer; color: var(--peach-text-dim); padding: 3px; border-radius: 5px; display: flex; align-items: center; line-height: 0; transition: color 100ms, background 100ms; } .back-btn:hover { color: var(--peach-text); background: var(--peach-input-bg); } .create-label { font-size: 12px; color: var(--peach-text); 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: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; } .f-input { background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); border-radius: 7px; color: var(--peach-text); 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: var(--peach-text-muted); } .f-input.url-f { font-size: 10px; color: var(--peach-text-dim); 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, box-shadow 120ms, transform 80ms; letter-spacing: 0.01em; } .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); box-shadow: 0 4px 20px var(--peach-glow, transparent); } .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: var(--peach-text); font-weight: 500; } .success-sub { font-size: 10px; color: var(--peach-text-dim); font-family:"IBM Plex Mono",monospace; } /* Silver platter (State 1 — single match) */ .silver-platter { padding: 14px 14px 12px; display: flex; flex-direction: column; gap: 6px; } .sp-title { font-size: 14px; font-weight: 500; color: var(--peach-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.25; } .sp-username { font-size: 12px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; } .sp-fill-btn { width: 100%; padding: 11px; border-radius: 9px; border: none; background: var(--peach-accent); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; font-family: Inter, sans-serif; letter-spacing: 0.01em; display: flex; align-items: center; justify-content: center; min-height: 38px; transition: filter 120ms ease, transform 80ms ease, box-shadow 120ms ease, background 120ms ease; } .sp-fill-btn:hover { filter: brightness(1.1); box-shadow: 0 4px 20px var(--peach-glow, transparent); transform: translateY(-1px); } .sp-fill-btn:active { transform: translateY(0); filter: brightness(0.95); } .sp-fill-btn.filled { background: #16a34a; transform: none; filter: none; box-shadow: 0 4px 16px rgba(22,163,74,0.35); cursor: default; } .sp-fill-btn:disabled { cursor: default; } .sp-actions { display: flex; gap: 4px; justify-content: space-between; align-items: center; margin-top: 4px; } .sp-action-btn { flex: 1; height: 28px; border: none; border-radius: 6px; background: transparent; color: var(--peach-text-dim); font-size: 12px; cursor: pointer; transition: background 120ms ease, color 120ms ease; font-family: Inter, sans-serif; letter-spacing: 0.04em; display: inline-flex; align-items: center; justify-content: center; gap: 4px; } .sp-action-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--peach-text) 5%, transparent); color: var(--peach-text); } .sp-action-btn:disabled { opacity: 0.3; cursor: default; } .sp-action-btn.flashed { color: var(--peach-accent); background: color-mix(in srgb, var(--peach-accent) 12%, transparent); } /* Card silver platter (State 7) — reuses .sp-* primary action classes */ .card-platter { padding: 12px 14px 10px; display: flex; flex-direction: column; gap: 4px; } .cp-network { font-size: 10px; font-weight: 600; color: var(--peach-accent); letter-spacing: 0.08em; text-transform: uppercase; font-family: Inter, sans-serif; } .cp-nickname { font-size: 14px; font-weight: 500; color: var(--peach-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; } .cp-last4 { font-size: 12px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; letter-spacing: 0.04em; margin-top: 2px; } .cp-meta { font-size: 11px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; margin-top: 2px; display: flex; gap: 10px; align-items: center; } .cp-meta-sep { opacity: 0.5; } .cp-fill-wrap { margin-top: 10px; } /* Locked state */ .locked-wrap { display: flex; flex-direction: column; align-items: stretch; padding: 14px 13px 13px; gap: 9px; } .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: var(--peach-input-bg); border: 1px solid var(--peach-input-border); display: flex; align-items: center; justify-content: center; } .locked-title { font-size: 11.5px; color: var(--peach-text); font-weight: 500; } .locked-input { width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); border-radius: 7px; color: var(--peach-text); 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: var(--peach-text-muted); } .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; box-shadow: 0 2px 16px var(--peach-glow, transparent); animation: peach-pulse-glow 2.4s ease-in-out infinite; } .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); } .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; animation: none; } .locked-error { font-size: 10px; color: rgba(220,80,60,0.85); font-family: "IBM Plex Mono", monospace; } @keyframes peach-pulse-glow { 0%, 100% { box-shadow: 0 2px 16px var(--peach-glow, transparent); } 50% { box-shadow: 0 2px 28px var(--peach-glow, transparent), 0 0 48px var(--peach-glow, transparent); } } `; 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); host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28")); host.style.setProperty("--peach-glow", withAlpha(accentHex, "18")); host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30")); 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`; // Spec §1.1: 320px width. The Math.max() floor preserves usability on // wide single-input fields (e.g. centered email input on signup pages). host.style.width = `${Math.max(rect.width, 320)}px`; const container = shadow.appendChild(document.createElement("div")); container.className = "peach-inline-popup"; container.setAttribute("data-theme", popupTheme); const searchableEntries = options.searchableEntries ?? []; const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl(); let query = ""; let focusedIndex = -1; let currentMode: "list" | "create" | "success" | "silver-platter" = "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; } // Detect page password policy once for this form — reused on every // regenerate click so the user always gets a compliant password. const sitePolicy = detectPasswordPolicy(); 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 || generatePasswordForSite(sitePolicy); 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 = generatePasswordForSite(sitePolicy); }); 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: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" }, }); if ((res as { ok?: boolean }).ok) { // Fill the detected form regardless of mode. For login the user // created an entry to use here; for signup the password was just // generated for this site. Either way the credentials land in // the form. options.onFillFields is wired by the caller (index.ts) // and also records the form into filledForms so the popup will // not re-attach to the filled fields. 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 flashActionBtn(btn: HTMLButtonElement) { btn.classList.add("flashed"); setTimeout(() => btn.classList.remove("flashed"), 700); } function renderSilverPlatter(entry: SelectorEntry) { container.innerHTML = ""; searchInput = null; focusedIndex = -1; currentMode = "silver-platter"; const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const eyebrow = document.createElement("div"); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "Saved for this site"; 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 platter = document.createElement("div"); platter.className = "silver-platter"; const titleEl = document.createElement("div"); titleEl.className = "sp-title"; titleEl.textContent = entry.title; platter.appendChild(titleEl); if (entry.username) { const userEl = document.createElement("div"); userEl.className = "sp-username"; userEl.textContent = entry.username; platter.appendChild(userEl); } const fillBtn = document.createElement("button"); fillBtn.className = "sp-fill-btn"; fillBtn.type = "button"; fillBtn.textContent = "Fill credentials"; fillBtn.addEventListener("click", () => { if (fillBtn.disabled) return; fillBtn.disabled = true; fillBtn.classList.add("filled"); fillBtn.innerHTML = checkSvgStr(); // Fire the fill, then exit via the standard exit animation. The fill // itself is async (handled in index.ts) — by 400ms the form is filled // and the popup begins its exit. Total dwell from click to host // removal: 400ms + 130ms (exit animation) = 530ms. onSelect(entry.id); setTimeout(() => dismiss(), 400); }); platter.appendChild(fillBtn); const actions = document.createElement("div"); actions.className = "sp-actions"; const copyU = document.createElement("button"); copyU.className = "sp-action-btn"; copyU.type = "button"; copyU.title = "Copy username"; copyU.textContent = "copy ⓤ"; copyU.disabled = !entry.username; copyU.addEventListener("click", async (e) => { e.stopPropagation(); if (!entry.username) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(entry.username); alreadyWritten = true; } catch {} // Mirror the popup's copy path so the COPY message can clear the // clipboard via the configured timeout. alreadyWritten lets the SW // skip a redundant write when the page-level write succeeded. await chrome.runtime.sendMessage({ type: "COPY", payload: { text: entry.username, label: "Username", alreadyWritten }, }).catch(() => {}); flashActionBtn(copyU); }); actions.appendChild(copyU); const copyP = document.createElement("button"); copyP.className = "sp-action-btn"; copyP.type = "button"; copyP.title = "Copy password"; copyP.textContent = "copy ⓟ"; copyP.addEventListener("click", async (e) => { e.stopPropagation(); const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id }, }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null; if (!resp?.ok || !resp.data?.password) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(resp.data.password); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: resp.data.password, label: "Password", alreadyWritten }, }).catch(() => {}); flashActionBtn(copyP); }); actions.appendChild(copyP); const closeBtn = document.createElement("button"); closeBtn.className = "sp-action-btn"; closeBtn.type = "button"; closeBtn.title = "Dismiss"; closeBtn.textContent = "✕"; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); dismiss(true); }); actions.appendChild(closeBtn); platter.appendChild(actions); container.appendChild(platter); } 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) => { e.stopPropagation(); if (e.key === "Enter") void tryUnlock(); }); pwInp.addEventListener("keyup", (e) => e.stopPropagation()); pwInp.addEventListener("keypress", (e) => e.stopPropagation()); unlockBtn.addEventListener("click", () => void tryUnlock()); wrap.append(head, pwInp, errEl, unlockBtn); container.appendChild(wrap); // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.) // the page reclaims focus from our Shadow DOM input before the user can // type. Fighting over focus with a focusout→refocus loop hangs the tab. // Instead: intercept keystrokes at the document capture phase — this runs // before any page handler regardless of which element technically has // OS focus — and route them into our input. const captureKeydown = (e: KeyboardEvent) => { if (!host.isConnected) { document.removeEventListener("keydown", captureKeydown, true); return; } // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through. if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return; // Intercept before page handlers and give our input focus. e.stopImmediatePropagation(); if (e.key === "Escape") { dismiss(true); return; } if (e.key === "Enter") { void tryUnlock(); return; } // For all other printable/editing keys, ensure our input has focus so // the browser delivers the keystroke there naturally. if (document.activeElement !== host) { pwInp.focus({ preventScroll: true }); } }; captureKeydownRef = captureKeydown; document.addEventListener("keydown", captureKeydown, true); // Best-effort focus on open — may be overridden by page focus trap, // but the capture listener above handles typing regardless. pwInp.focus({ preventScroll: true }); requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); }); } function handleKeyDown(e: KeyboardEvent) { if (options.locked) { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") { 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); 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`; } // These bindings are referenced from renderLocked()/dismiss() — they MUST // be initialized before renderLocked() runs, or the synchronous assignment // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ // and throws ReferenceError. The throw lands after the popup is rendered // but before showAutofillSelector returns its dismiss handle, leaving an // orphan host in the DOM (which is what caused the locked-popup stacking // and missing autofocus on every click). let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null; const stopObserving = observeAnchorPosition(anchorEl, updatePosition); if (options.locked) { renderLocked(); } else if (entries.length === 1 && !options.isSignup) { // Silver platter (State 1): single domain match → focused fill view. // Multi-match goes through renderList instead. Signup mode always uses // the list because the user is choosing which existing identity to // surface — there's no single "fill credentials" interpretation. renderSilverPlatter(entries[0]!); } else { renderList(); } let exitScheduled = false; function dismiss(userAction = false) { // Idempotent: dismiss() can be called from multiple paths (Escape, // outside click, entry select, fill success, host page nav). The exit // animation must only play once and host.remove() must run exactly once. if (exitScheduled) return; exitScheduled = true; // Detach listeners synchronously so nothing can re-trigger during the // 130ms exit window (a focusin firing here would race against the // dismiss-lock work in Pass 2). stopObserving(); document.removeEventListener("click", outsideClick, { capture: true }); document.removeEventListener("keydown", handleKeyDown); if (captureKeydownRef) { document.removeEventListener("keydown", captureKeydownRef, true); captureKeydownRef = null; } if (userAction) options.onDismiss?.(); container.classList.add("is-exiting"); setTimeout(() => host.remove(), 130); } 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; } export interface CardSelectorEntry { id: string; title: string; cardholderName?: string; last4?: string; expMonth?: string; expYear?: string; network?: import("../shared/types").CardNetwork; } interface CardSelectorOptions { domain?: string; /** Caller-provided fill — receives the full CardEntry payload from the SW. */ onFill: (entry: import("../shared/types").VaultEntry) => void; /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */ onDismiss?: () => void; } /** * State 7 — card silver platter. Single-card focused fill view rendered * over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the * theme tokens, and the .sp-fill-btn / .sp-actions primary action classes * so card and login flows render identically. Multi-card list view is a * follow-up; today this picks the first card. */ export function showCardSelector( anchorEl: HTMLInputElement, cards: CardSelectorEntry[], options: CardSelectorOptions ): () => void { if (cards.length === 0) return () => {}; const card = cards[0]!; const host = document.createElement("div"); host.style.cssText = "position: absolute; z-index: 2147483647;"; host.style.setProperty("--peach-accent", accentHex); host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28")); host.style.setProperty("--peach-glow", withAlpha(accentHex, "18")); host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30")); 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, 320)}px`; const container = shadow.appendChild(document.createElement("div")); container.className = "peach-inline-popup"; container.setAttribute("data-theme", popupTheme); // Domain header — eyebrow communicates the card context, not the site const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const eyebrow = document.createElement("div"); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "Pay with Peach"; 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 platter = document.createElement("div"); platter.className = "card-platter"; if (card.network && card.network !== "unknown") { const net = document.createElement("div"); net.className = "cp-network"; net.textContent = card.network; platter.appendChild(net); } const nick = document.createElement("div"); nick.className = "cp-nickname"; nick.textContent = card.title; platter.appendChild(nick); if (card.last4) { const l4 = document.createElement("div"); l4.className = "cp-last4"; l4.textContent = `•••• ${card.last4}`; platter.appendChild(l4); } const meta = document.createElement("div"); meta.className = "cp-meta"; if (card.cardholderName) { const name = document.createElement("span"); name.textContent = card.cardholderName; meta.appendChild(name); } if (card.expMonth && card.expYear) { if (card.cardholderName) { const sep = document.createElement("span"); sep.className = "cp-meta-sep"; sep.textContent = "·"; meta.appendChild(sep); } const exp = document.createElement("span"); const yy = card.expYear.length === 4 ? card.expYear.slice(-2) : card.expYear; exp.textContent = `${card.expMonth.padStart(2, "0")}/${yy}`; meta.appendChild(exp); } if (meta.childElementCount > 0) platter.appendChild(meta); const fillWrap = document.createElement("div"); fillWrap.className = "cp-fill-wrap"; const fillBtn = document.createElement("button"); fillBtn.className = "sp-fill-btn"; fillBtn.type = "button"; fillBtn.textContent = "Fill card details"; fillBtn.addEventListener("click", async () => { if (fillBtn.disabled) return; fillBtn.disabled = true; fillBtn.classList.add("filled"); fillBtn.innerHTML = checkSvgStr(); const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: card.id }, }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null; if (resp?.ok && resp.data) options.onFill(resp.data); setTimeout(() => dismiss(), 400); }); fillWrap.appendChild(fillBtn); platter.appendChild(fillWrap); // Secondary actions — copy number, copy CVC, dismiss const actions = document.createElement("div"); actions.className = "sp-actions"; function flashBtn(b: HTMLButtonElement) { b.classList.add("flashed"); setTimeout(() => b.classList.remove("flashed"), 700); } async function fetchFullCard(): Promise { const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: card.id }, }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null; return resp?.ok && resp.data ? resp.data : null; } const copyNum = document.createElement("button"); copyNum.className = "sp-action-btn"; copyNum.type = "button"; copyNum.title = "Copy card number"; copyNum.textContent = "copy #"; copyNum.addEventListener("click", async (e) => { e.stopPropagation(); const full = await fetchFullCard(); if (!full?.cardNumber) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(full.cardNumber); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cardNumber, label: "Card number", alreadyWritten }, }).catch(() => {}); flashBtn(copyNum); }); actions.appendChild(copyNum); const copyCvc = document.createElement("button"); copyCvc.className = "sp-action-btn"; copyCvc.type = "button"; copyCvc.title = "Copy CVC"; copyCvc.textContent = "copy CVC"; copyCvc.addEventListener("click", async (e) => { e.stopPropagation(); const full = await fetchFullCard(); if (!full?.cvc) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(full.cvc); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cvc, label: "CVC", alreadyWritten }, }).catch(() => {}); flashBtn(copyCvc); }); actions.appendChild(copyCvc); const closeBtn = document.createElement("button"); closeBtn.className = "sp-action-btn"; closeBtn.type = "button"; closeBtn.title = "Dismiss"; closeBtn.textContent = "✕"; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); dismiss(true); }); actions.appendChild(closeBtn); platter.appendChild(actions); container.appendChild(platter); document.body.appendChild(host); 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, 320)}px`; } const stopObserving = observeAnchorPosition(anchorEl, updatePosition); function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } } document.addEventListener("keydown", onKeyDown); let exitScheduled = false; function dismiss(userAction = false) { if (exitScheduled) return; exitScheduled = true; stopObserving(); document.removeEventListener("click", outsideClick, { capture: true }); document.removeEventListener("keydown", onKeyDown); if (userAction) options.onDismiss?.(); container.classList.add("is-exiting"); setTimeout(() => host.remove(), 130); } function outsideClick(ev: MouseEvent) { const path = ev.composedPath(); if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true); } setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0); return dismiss; }