/** * 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, type PasswordHistoryItem, type SavedIdentity } from "../shared/types"; import { generatePassword } from "../shared/generate"; import { detectPasswordPolicy } from "./policy"; import { filterSavedIdentities, identityFilterLabel, resolveCustomIdentityKind, type IdentityFilterMode, type IdentityKind, } from "./identity-helper"; // Coarse "time since" label for the inline password-history list. Mirrors the // main popup's helper — "roughly when", not a precise duration. function timeSince(when: number): string { const secs = Math.max(0, Math.floor((Date.now() - when) / 1000)); if (secs < 60) return "just now"; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; return `${Math.floor(months / 12)}y ago`; } 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; email?: string; url?: string; hasTotp?: boolean; /** True when the entry also has a passkey credential attached. */ hasPasskey?: boolean; } interface SelectorOptions { domain?: string; searchableEntries?: SelectorEntry[]; /** * MV-2: lazy loader for the vault-wide searchable list. Invoked only on an * explicit user search gesture, never on passive popup open, so the full * account inventory never ships into the page world unless the user asks. * Result is cached for the popup's lifetime. */ loadSearchableEntries?: () => Promise; /** True when shown on a signup form — changes header label and entry-click behaviour */ isSignup?: boolean; /** * Identity kind the current signup field is expecting. Used to avoid * assuming email when the user is actually filling a username. */ identityKind?: IdentityKind; /** * Derived identity list (emails then usernames) for signup mode (State 10). * When present, renderIdentityHelper() is used instead of renderList(). */ savedIdentities?: SavedIdentity[]; /** * 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, email?: string) => void; /** * True when the form has both a distinct username field AND a distinct email * field (e.g. Name / Username / Email / Password signup forms). Enables the * two-step identity wizard: first pick the field type the user clicked on, * then pick the other, then generate a password. */ hasEmailField?: boolean; /** * 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 searchSvgStr(): string { return ``; } function backSvgStr(): string { return ``; } function peachIconSvg(): string { // Peach body stays brand-peach regardless of `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 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 () => { // The write itself is popup-confirmed: growing an entry's domain set is // the one autofill decision a page must not make for itself, because the // domain set is what gates future fills (PB-26-106). await chrome.runtime.sendMessage({ type: "REQUEST_REMEMBER_SITE", 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); } // Module handle so a second auto-copy resets the dismiss timer rather than // letting the first toast's teardown race the replacement. let totpToastDismissTimer: ReturnType | null = null; /** * Shows a brief top-right toast when Peach automatically copies a TOTP code, * telling the user how long until the clipboard self-clears. Auto-dismisses * after 3s; a second call while visible replaces the toast and resets the timer. * Rendered in the light DOM (not a closed shadow) so the copy is announced to * assistive tech via aria-live — the code itself is never placed in the DOM. */ export function showTotpCopyToast(secondsUntilClear: number): void { document.getElementById("peach-totp-toast")?.remove(); if (totpToastDismissTimer) { clearTimeout(totpToastDismissTimer); totpToastDismissTimer = null; } const toast = document.createElement("div"); toast.id = "peach-totp-toast"; toast.className = "peach-totp-toast"; toast.setAttribute("data-peach-toast", "totp-copy"); toast.setAttribute("aria-live", "polite"); // Inline styles — matches the passkey toast family (warm, minimal, top right). Object.assign(toast.style, { position: "fixed", top: "16px", right: "16px", zIndex: "2147483647", padding: "10px 14px", borderRadius: "8px", background: "rgba(30, 20, 10, 0.92)", border: "1px solid rgba(210, 140, 60, 0.3)", color: "#e8d5b0", fontFamily: '"IBM Plex Mono", monospace', fontSize: "11px", lineHeight: "1.5", boxShadow: "0 4px 24px rgba(0,0,0,0.4)", pointerEvents: "none", }); // secondsUntilClear is a typed number — safe to interpolate; no page-derived // string ever reaches this markup. toast.innerHTML = `
2FA code copied
clears in ${secondsUntilClear}s
`; document.body.appendChild(toast); totpToastDismissTimer = setTimeout(() => { totpToastDismissTimer = null; toast.style.transition = "opacity 0.3s ease"; toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000); } interface SmartGenerator { root: HTMLElement; getPassword: () => string; /** Copy the current password to the clipboard with the standard pulse UX. */ copy: () => Promise; } interface SmartGeneratorOptions { /** Site-detected policy — clamps length, sets symbol toggle, exposes hint. */ policy: PasswordPolicy; /** * Seed value: used as the displayed password without regenerating, e.g. the * password the user already typed into the page. The generator still * regenerates the moment any control changes. */ initialPassword?: string; /** Fires every time the password regenerates (initial + on changes). */ onChange?: (pw: string) => void; } /** * Build the inline-popup smart-password generator. Mirrors the main popup's * GeneratorDrawer (compact mode): readout row + length slider + 4 charset * pills + a policy hint that names the constraints Peach detected on the * page. Writing this in pure DOM rather than reusing the Solid component * keeps the content-script bundle small. */ function createSmartGenerator(opts: SmartGeneratorOptions): SmartGenerator { const policy = opts.policy ?? {}; // Start at site max (strongest password the site accepts), fall back to 64 let length = policy.maxLength ?? 64; if (policy.minLength) length = Math.max(length, policy.minLength); if (length < 8) length = 8; if (length > 128) length = 128; let upper = true; let lower = true; let numbers = true; let symbols = !policy.forbidsSymbols; if (policy.requiresSymbols) symbols = true; const forbiddenChars = policy.forbiddenChars ?? ""; let password = ""; const root = document.createElement("div"); root.className = "pwgen-block"; const displayRow = root.appendChild(document.createElement("div")); displayRow.className = "pwgen-display-row"; const output = displayRow.appendChild(document.createElement("input")) as HTMLInputElement; output.type = "text"; output.className = "pwgen-output"; output.setAttribute("autocomplete", "off"); output.setAttribute("spellcheck", "false"); output.setAttribute("autocorrect", "off"); output.setAttribute("autocapitalize", "off"); const regenBtn = displayRow.appendChild(document.createElement("button")); regenBtn.className = "pwgen-icon-btn"; regenBtn.type = "button"; regenBtn.setAttribute("aria-label", "Regenerate password"); regenBtn.title = "Regenerate"; regenBtn.innerHTML = ``; const copyBtn = displayRow.appendChild(document.createElement("button")); copyBtn.className = "pwgen-icon-btn"; copyBtn.type = "button"; copyBtn.setAttribute("aria-label", "Copy password"); copyBtn.title = "Copy"; copyBtn.innerHTML = ``; const controls = root.appendChild(document.createElement("div")); controls.className = "pwgen-controls-row"; const slider = controls.appendChild(document.createElement("input")); slider.type = "range"; slider.className = "pwgen-slider"; slider.min = "8"; slider.max = "128"; slider.value = String(length); if (policy.minLength) slider.min = String(Math.max(8, policy.minLength)); if (policy.maxLength) slider.max = String(Math.min(128, policy.maxLength)); const lenNum = controls.appendChild(document.createElement("span")); lenNum.className = "pwgen-len-num pwgen-len-num--edit"; lenNum.textContent = String(length); lenNum.addEventListener("click", () => { const li = document.createElement("input"); li.type = "text"; li.inputMode = "numeric"; li.className = "pwgen-len-input"; li.value = String(length); lenNum.replaceWith(li); requestAnimationFrame(() => li.select()); function commitLenEdit() { const n = parseInt(li.value, 10); if (!Number.isNaN(n)) { length = Math.max(parseInt(slider.min, 10), Math.min(parseInt(slider.max, 10), n)); slider.value = String(length); regenerate(); } lenNum.textContent = String(length); li.replaceWith(lenNum); } li.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commitLenEdit(); } else if (e.key === "Escape") { li.replaceWith(lenNum); } }); li.addEventListener("blur", commitLenEdit); }); const pills = root.appendChild(document.createElement("div")); pills.className = "pwgen-pills"; function makePill(label: string, on: boolean, onToggle: (next: boolean) => void): HTMLButtonElement { const b = document.createElement("button"); b.type = "button"; b.className = `pwgen-pill ${on ? "is-on" : ""}`; b.textContent = label; b.addEventListener("click", () => { const next = !b.classList.contains("is-on"); b.classList.toggle("is-on", next); onToggle(next); }); return b; } pills.appendChild(makePill("A-Z", upper, (next) => { upper = next; regenerate(); })); pills.appendChild(makePill("a-z", lower, (next) => { lower = next; regenerate(); })); pills.appendChild(makePill("0-9", numbers, (next) => { numbers = next; regenerate(); })); pills.appendChild(makePill("!@#", symbols, (next) => { // The site forbids symbols — UI lets the user override but still warn. symbols = next; regenerate(); })); // Policy hint — surfaces what Peach read off the site so the user knows the // generator is already tuned to it. Only shows when something interesting // was detected. const hintParts: string[] = []; if (policy.minLength) hintParts.push(`min ${policy.minLength}`); if (policy.maxLength) hintParts.push(`max ${policy.maxLength}`); if (policy.requiresSymbols) hintParts.push("requires symbols"); if (policy.forbidsSymbols) hintParts.push("no symbols"); if (policy.forbiddenChars) hintParts.push(`excludes ${policy.forbiddenChars.slice(0, 6)}${policy.forbiddenChars.length > 6 ? "…" : ""}`); if (hintParts.length > 0) { const hint = root.appendChild(document.createElement("div")); hint.className = "pwgen-policy-hint"; const accent = document.createElement("span"); accent.className = "pwgen-hint-accent"; accent.textContent = "site rules · "; const text = document.createTextNode(hintParts.join(" · ")); hint.appendChild(accent); hint.appendChild(text); } function regenerate() { // Falls back gracefully when the user toggles every charset off — the // shared generator forces lowercase in that case rather than throwing. password = generatePassword({ length, includeUppercase: upper, includeLowercase: lower, includeNumbers: numbers, includeSymbols: symbols, forbiddenChars, }); output.value = password; opts.onChange?.(password); } output.addEventListener("input", () => { password = output.value; length = password.length; slider.value = String(Math.max(8, Math.min(128, length))); lenNum.textContent = String(length); opts.onChange?.(password); }); slider.addEventListener("input", () => { length = parseInt(slider.value, 10); lenNum.textContent = String(length); regenerate(); }); regenBtn.addEventListener("click", regenerate); let copyResetTimer: ReturnType | null = null; async function copy(): Promise { try { await navigator.clipboard.writeText(password); copyBtn.classList.add("is-copied"); if (copyResetTimer) clearTimeout(copyResetTimer); copyResetTimer = setTimeout(() => copyBtn.classList.remove("is-copied"), 1400); } catch { // Clipboard API may be denied — silently no-op; user still has regen UI. } } copyBtn.addEventListener("click", () => { void copy(); }); if (opts.initialPassword) { password = opts.initialPassword; output.value = password; opts.onChange?.(password); } else { regenerate(); } return { root, getPassword: () => password, copy, }; } 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-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; transition: height 220ms cubic-bezier(0.16,1,0.3,1); } /* 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; gap: 8px; } .domain-header-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .domain-header-actions { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; } .domain-add-btn, .domain-search-btn { flex-shrink: 0; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--peach-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--peach-accent) 28%, transparent); border-radius: 6px; color: var(--peach-accent); cursor: pointer; padding: 0; transition: background 120ms, border-color 120ms, transform 80ms; font-family: "IBM Plex Mono", monospace; font-size: 14px; line-height: 1; } .domain-add-btn:hover, .domain-search-btn:hover { background: color-mix(in srgb, var(--peach-accent) 22%, transparent); border-color: color-mix(in srgb, var(--peach-accent) 48%, transparent); transform: scale(1.06); } .domain-add-btn:active, .domain-search-btn:active { transform: scale(0.96); } /* Search */ .search-wrap { padding: 9px 12px 7px; border-bottom: 1px solid var(--peach-border); } /* UI-2 / MV-2: brief loading hint while the vault-wide list lazy-loads. */ .search-loading { display: block; margin-top: 5px; font-size: 10px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; animation: search-loading-pulse 1s ease-in-out infinite; } @keyframes search-loading-pulse { 0%,100% { opacity: 0.45; } 50% { opacity: 0.9; } } .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; } .passkey-badge { font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px; border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 14%, transparent); color: color-mix(in srgb, var(--peach-accent) 85%, transparent); border: 1px solid color-mix(in srgb, var(--peach-accent) 24%, transparent); font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 6px; } .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; } /* minmax(0, 1fr) caps the grid track at the popup width — without it, long monospace content (e.g. a 60-char generated password) would auto-size the track wider than the popup and overflow the right edge. */ .create-body { padding: 12px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 9px; } .f-group { display: grid; grid-template-columns: minmax(0, 1fr); 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); } .gen-toggle-link { background: none; border: none; padding: 2px 0; color: color-mix(in srgb, var(--peach-accent) 60%, transparent); font-size: 10px; font-family: "IBM Plex Mono", monospace; cursor: pointer; text-align: left; transition: color 100ms; } .gen-toggle-link:hover { color: var(--peach-accent); } .pw-history { display: grid; gap: 4px; margin-top: 4px; max-height: 150px; overflow-y: auto; scrollbar-width: thin; } .pw-history-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 7px; border-radius: 7px; background: color-mix(in srgb, var(--peach-accent) 6%, transparent); border: 1px solid color-mix(in srgb, var(--peach-accent) 10%, transparent); } .pw-history-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; } .pw-history-pw { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: var(--peach-t1, #eee); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pw-history-age { font-family: "IBM Plex Mono", monospace; font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 55%, transparent); letter-spacing: 0.03em; } .pw-history-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .pw-history-btn { background: none; border: none; padding: 2px 5px; border-radius: 5px; color: color-mix(in srgb, var(--peach-accent) 65%, transparent); font-size: 9px; font-family: "IBM Plex Mono", monospace; cursor: pointer; transition: color 100ms, background 100ms; } .pw-history-btn:hover { color: var(--peach-accent); background: color-mix(in srgb, var(--peach-accent) 12%, transparent); } .pw-history-empty { font-family: "IBM Plex Mono", monospace; font-size: 10px; line-height: 1.45; color: color-mix(in srgb, var(--peach-accent) 55%, transparent); padding: 6px 8px; border-radius: 7px; background: color-mix(in srgb, var(--peach-accent) 5%, transparent); border: 1px solid color-mix(in srgb, var(--peach-accent) 9%, 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-identity-row { display: flex; align-items: baseline; gap: 5px; margin-bottom: 3px; } .sp-identity-label { font-size: 9px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--peach-text-muted); flex-shrink: 0; } .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: 0; } .sp-identity-row + .sp-fill-btn, .sp-username + .sp-fill-btn { margin-top: 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-group { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; } .sp-actions-label { font-size: 8px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace; text-align: center; } .sp-actions { display: flex; gap: 4px; justify-content: space-between; align-items: center; } .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; white-space: nowrap; overflow: hidden; } .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); } /* TOTP tray — shown below the silver-platter secondary actions */ .sp-tray { margin-top: 8px; padding: 10px 12px; border-radius: 9px; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); display: flex; align-items: center; gap: 12px; animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both; } .sp-tray-label { font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; flex-shrink: 0; } .sp-totp-code { font-size: 18px; font-weight: 500; color: var(--peach-text); font-family: "IBM Plex Mono", monospace; letter-spacing: 0.12em; flex: 1; text-align: center; } .sp-totp-ring { width: 22px; height: 22px; flex-shrink: 0; } .sp-totp-ring .ring-track { stroke: var(--peach-input-border); } .sp-totp-ring .ring-fill { stroke: var(--peach-accent); transition: stroke-dashoffset 1s linear; transform: rotate(-90deg); transform-origin: center; } .sp-totp-copy { background: transparent; border: none; cursor: pointer; padding: 4px 8px; border-radius: 5px; color: var(--peach-text-dim); font-size: 11px; font-family: Inter, sans-serif; transition: background 120ms, color 120ms; } .sp-totp-copy:hover { background: color-mix(in srgb, var(--peach-text) 5%, transparent); color: var(--peach-text); } /* 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-sub { font-size: 10.5px; line-height: 1.4; color: var(--peach-text-muted); text-align: center; margin: 2px 0 2px; } .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); } } /* ── State 10: identity helper ─────────────────────────────── */ .identity-list { max-height: 192px; overflow-y: auto; padding: 4px 0; scrollbar-width: none; } .identity-list::-webkit-scrollbar { display: none; } .peach-identity-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; cursor: pointer; border-radius: 8px; transition: background 100ms ease; } .peach-identity-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); } .peach-identity-value { font-family: "IBM Plex Mono", monospace; font-size: 13px; color: var(--peach-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .peach-identity-meta { font-size: 10px; color: var(--peach-text-muted); flex-shrink: 0; } .identity-search-wrap { display: flex; flex-direction: column; gap: 6px; } .identity-mode-toggle { display: inline-flex; gap: 4px; flex-wrap: wrap; } .identity-mode-btn { border: 1px solid var(--peach-input-border); background: var(--peach-input-bg); color: var(--peach-text-dim); border-radius: 999px; padding: 4px 9px; cursor: pointer; font-family: "IBM Plex Mono", monospace; font-size: 9px; letter-spacing: 0.03em; transition: background 100ms ease, border-color 100ms ease, color 100ms ease, transform 80ms ease; } .identity-mode-btn:hover { background: color-mix(in srgb, var(--peach-text) 6%, var(--peach-input-bg)); color: var(--peach-text); } .identity-mode-btn.is-active { color: var(--peach-accent); border-color: color-mix(in srgb, var(--peach-accent) 30%, var(--peach-input-border)); background: color-mix(in srgb, var(--peach-accent) 10%, var(--peach-input-bg)); } .identity-mode-btn:active { transform: translateY(1px); } .identity-custom-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; cursor: pointer; border-radius: 8px; transition: background 100ms ease; } .identity-custom-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); } .identity-custom-row-title { color: var(--peach-text); font-size: 12px; font-family: Inter, sans-serif; } .identity-custom-row-sub { color: var(--peach-text-muted); font-size: 10px; font-family: "IBM Plex Mono", monospace; } .identity-empty { padding: 10px 12px 2px; color: var(--peach-text-dim); font-size: 10px; font-family: "IBM Plex Mono", monospace; } .identity-alt-row { display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-radius: 8px; transition: background 100ms ease; color: var(--peach-text-dim); font-size: 12px; font-family: Inter, sans-serif; } .identity-alt-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); } .identity-escape-row { display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-radius: 8px; transition: background 100ms ease; outline: none; color: var(--peach-accent); font-size: 11px; font-family: Inter, sans-serif; margin-top: 2px; } .identity-escape-row:hover, .identity-escape-row:focus { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); } .identity-alt-input-wrap { display: none; padding: 0 12px 8px; gap: 6px; align-items: center; } .identity-alt-input-wrap.open { display: flex; } .identity-alt-input { flex: 1; height: 32px; padding: 0 10px; border-radius: 7px; border: 1px solid var(--peach-input-border); background: var(--peach-input-bg); color: var(--peach-text); font-family: "IBM Plex Mono", monospace; font-size: 12px; outline: none; transition: border-color 120ms; } .identity-alt-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); } .identity-alt-confirm { height: 32px; padding: 0 12px; border-radius: 7px; border: none; background: var(--peach-accent); color: #fff; font-size: 12px; cursor: pointer; font-family: Inter, sans-serif; flex-shrink: 0; } /* ── Generator view (after identity picked) ────────────────── */ .gen-view { padding: 12px; display: flex; flex-direction: column; gap: 8px; } .gen-confirmed { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent); border-radius: 8px; } .gen-confirmed-check { color: var(--peach-accent); font-size: 13px; font-weight: 600; } .gen-confirmed-val { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .gen-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace; } .gen-pw-row { display: flex; gap: 6px; align-items: center; } .gen-pw-val { flex: 1; padding: 8px 10px; border-radius: 7px; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .gen-regen-btn { height: 34px; padding: 0 10px; border-radius: 7px; border: none; background: color-mix(in srgb, var(--peach-text) 8%, transparent); color: var(--peach-text-dim); font-size: 11px; cursor: pointer; font-family: "IBM Plex Mono", monospace; transition: background 100ms ease; } .gen-regen-btn:hover { background: color-mix(in srgb, var(--peach-text) 14%, transparent); } .gen-fill-btn { padding: 10px; border-radius: 8px; 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; transition: filter 120ms ease, transform 80ms ease; } .gen-fill-btn:hover { filter: brightness(1.08); transform: translateY(-1px); } .gen-fill-btn:active { transform: translateY(0); } /* ── Smart password generator (parity with main UI compact mode) ─── */ .pwgen-block { display: flex; flex-direction: column; gap: 8px; } .pwgen-display-row { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-radius: 7px; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); } .pwgen-output { flex: 1; min-width: 0; width: 0; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text); background: transparent; border: none; outline: none; padding: 0; } .pwgen-icon-btn { flex-shrink: 0; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--peach-input-border); border-radius: 6px; color: var(--peach-text-dim); cursor: pointer; padding: 0; transition: background 100ms, color 100ms, border-color 100ms; } .pwgen-icon-btn:hover { background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: var(--peach-accent); border-color: color-mix(in srgb, var(--peach-accent) 30%, transparent); } .pwgen-icon-btn.is-copied { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); color: var(--peach-accent); border-color: color-mix(in srgb, var(--peach-accent) 45%, transparent); } .pwgen-controls-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .pwgen-slider { flex: 1; min-width: 110px; accent-color: var(--peach-accent); cursor: pointer; height: 2px; background: transparent; } .pwgen-len-num { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: var(--peach-accent); min-width: 22px; text-align: right; } .pwgen-len-num--edit { cursor: text; border-bottom: 1px dotted color-mix(in srgb, var(--peach-accent) 50%, transparent); } .pwgen-len-input { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: var(--peach-accent); width: 28px; text-align: right; background: transparent; border: none; border-bottom: 1px solid var(--peach-accent); outline: none; padding: 0; min-width: 22px; } @keyframes identity-suggest-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } .identity-suggest-list { display: flex; flex-direction: column; gap: 1px; margin-top: 3px; animation: identity-suggest-in 180ms cubic-bezier(0.16,1,0.3,1) both; } .identity-filter-row { display: flex; align-items: center; gap: 4px; padding: 2px 0 4px; } .identity-collapse-btn { margin-left: auto; background: transparent; border: none; cursor: pointer; color: var(--peach-text-muted); padding: 2px 4px; display: flex; align-items: center; line-height: 0; border-radius: 3px; transition: color 100ms ease, transform 220ms cubic-bezier(0.16,1,0.3,1); } .identity-collapse-btn:hover { color: var(--peach-text); } .identity-collapse-btn.is-collapsed { transform: rotate(-90deg); } .identity-rows { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 220ms cubic-bezier(0.16,1,0.3,1); } .identity-rows.is-collapsed { grid-template-rows: 0fr; } .identity-rows-inner { overflow-y: auto; min-height: 0; max-height: 148px; scrollbar-width: thin; scrollbar-color: var(--peach-text-muted) transparent; } .identity-rows-inner::-webkit-scrollbar { width: 4px; } .identity-rows-inner::-webkit-scrollbar-track { background: transparent; } .identity-rows-inner::-webkit-scrollbar-thumb { background: var(--peach-text-muted); border-radius: 2px; } .identity-filter-pill { padding: 2px 7px; border-radius: 999px; border: 1px solid var(--peach-input-border); background: transparent; color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace; font-size: 9px; cursor: pointer; transition: border-color 80ms, background 80ms, color 80ms; } .identity-filter-pill.is-on { border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent); background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: var(--peach-accent); } .identity-suggest-row { display: flex; align-items: center; gap: 5px; width: 100%; padding: 4px 6px; border-radius: 5px; border: none; background: transparent; cursor: pointer; text-align: left; transition: background 80ms; } .identity-suggest-row:hover, .identity-suggest-row:focus { background: color-mix(in srgb, var(--peach-accent) 10%, transparent); outline: none; } .identity-suggest-val { font-family: "IBM Plex Mono", monospace; font-size: 10px; color: var(--peach-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .identity-suggest-type { font-family: "IBM Plex Mono", monospace; font-size: 8px; font-weight: 600; flex-shrink: 0; padding: 1px 3px; border-radius: 3px; } .identity-suggest-type--email { color: color-mix(in srgb, var(--peach-accent) 70%, transparent); background: color-mix(in srgb, var(--peach-accent) 12%, transparent); } .identity-suggest-type--username { color: var(--peach-text-muted); background: color-mix(in srgb, var(--peach-text) 8%, transparent); } .identity-suggest-meta { font-family: "IBM Plex Mono", monospace; font-size: 9px; color: var(--peach-text-muted); flex-shrink: 0; } .pwgen-pills { display: flex; gap: 5px; flex-wrap: wrap; } .pwgen-pill { padding: 3px 8px; border-radius: 999px; border: 1px solid var(--peach-input-border); background: transparent; color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace; font-size: 9px; letter-spacing: 0.06em; cursor: pointer; transition: all 130ms ease; } .pwgen-pill.is-on { border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent); background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: var(--peach-accent); } .pwgen-pill:not(.is-on):hover { color: var(--peach-text-dim); border-color: var(--peach-text-muted); } .pwgen-policy-hint { font-size: 10px; color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace; letter-spacing: 0.04em; line-height: 1.3; } .pwgen-policy-hint .pwgen-hint-accent { color: var(--peach-accent); } /* ── Save offer (after password filled) ───────────────────── */ .save-offer { padding: 12px; display: flex; flex-direction: column; gap: 8px; } .save-offer-title { font-size: 12px; font-weight: 600; color: var(--peach-text); font-family: Inter, sans-serif; } .save-offer-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 7px; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); } .save-offer-lbl { font-size: 10px; color: var(--peach-text-muted); width: 54px; flex-shrink: 0; font-family: "IBM Plex Mono", monospace; } .save-offer-val { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .save-offer-btns { display: flex; gap: 8px; margin-top: 4px; } .save-offer-save { flex: 1; padding: 9px; border-radius: 8px; border: none; background: var(--peach-accent); color: #fff; font-size: 13px; cursor: pointer; font-family: Inter, sans-serif; transition: filter 120ms ease; } .save-offer-save:hover { filter: brightness(1.08); } .save-offer-skip { flex: 1; padding: 9px; border-radius: 8px; border: none; background: color-mix(in srgb, var(--peach-text) 8%, transparent); color: var(--peach-text-dim); font-size: 13px; cursor: pointer; font-family: Inter, sans-serif; transition: background 100ms ease; } .save-offer-skip:hover { background: color-mix(in srgb, var(--peach-text) 14%, transparent); } `; export function showAutofillSelector( anchorEl: HTMLInputElement, entries: SelectorEntry[], onSelect: (id: string) => void, options: SelectorOptions = {} ): () => void { const host = document.createElement("div"); // When anchorEl is inside a focus-trapping modal the modal's JS focus trap fires // when Peach's internal inputs gain focus, immediately blurring them and closing // the suggest list. Mounting the host INSIDE the modal makes the trap treat Peach // as in-bounds (v3.6.131). // // We deliberately do NOT treat every
as a modal (the v3.6.132 broadening). // A plain login page whose card uses transform/filter/backdrop-filter establishes // a containing block for position:fixed, so a host mounted inside it lands offset // and is clipped by the card's overflow. juniesdream.com/admin/login is the // canonical case: section.admin-login has backdrop-filter:blur(14px), which pushed // the popup ~400px off the field and clipped it behind overflow:clip — the user // saw nothing. Real modals are detected by ARIA or a fixed-position overlay; // everything else mounts at the document root, where fixed == viewport. const modalAncestor = ( anchorEl.closest('dialog, [aria-modal="true"], [role="dialog"]') ?? (() => { let cur: HTMLElement | null = anchorEl.parentElement; while (cur && cur !== document.body) { if (window.getComputedStyle(cur).position === 'fixed') return cur; cur = cur.parentElement; } return null; })() ); host.style.cssText = `position: fixed; z-index: 2147483647;`; host.style.setProperty("--peach-accent", accentHex); host.style.setProperty("--peach-glow", withAlpha(accentHex, "18")); host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30")); // Hint the compositor that top/left animate during scroll repositioning so the // popup stays on the GPU layer and doesn't flicker as observeAnchorPosition // re-asserts its coordinates. host.style.willChange = "top, left"; // delegatesFocus: focus on an internal input (search, inline unlock) makes the // HOST appear focused to the outside DOM. Modal focus traps that monitor // focusin then see the host (mounted inside the modal) as the focused element // rather than an opaque closed-shadow node, so they don't fire their // "focus escaped" handler and yank focus back. Harmless outside modals. const shadow = host.attachShadow({ mode: "closed", delegatesFocus: true }); const style = shadow.appendChild(document.createElement("style")); style.textContent = SELECTOR_STYLES; const rect = anchorEl.getBoundingClientRect(); // Viewport-relative placeholder; updatePosition() re-asserts this after mount, // applying containing-block compensation once host.parentElement is known. host.style.top = `${rect.bottom + 5}px`; host.style.left = `${rect.left}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). // Frozen at open time — updatePosition re-asserts this so page CSS cannot // expand the host after the initial measurement. const popupWidth = Math.min(Math.max(rect.width, 320), 400); host.style.width = `${popupWidth}px`; const container = shadow.appendChild(document.createElement("div")); container.className = "peach-inline-popup"; container.setAttribute("data-theme", popupTheme); // MV-2: the vault-wide list is loaded lazily on the first search gesture, // never eagerly on passive popup open. Cached for the popup's lifetime. let searchableEntries: SelectorEntry[] = options.searchableEntries ?? []; let searchableLoading = false; let searchableLoaded = !options.loadSearchableEntries; async function ensureSearchableLoaded(rerender: () => void): Promise { if (searchableLoaded || searchableLoading || !options.loadSearchableEntries) { return; } searchableLoading = true; try { searchableEntries = await options.loadSearchableEntries(); } catch { searchableEntries = []; } finally { searchableLoaded = true; searchableLoading = false; rerender(); } } const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl(); let query = ""; let focusedIndex = -1; let currentMode: "list" | "create" | "success" | "silver-platter" | "identity-helper" | "edit" = "list"; let searchInput: HTMLInputElement | null = null; let identityMode: IdentityFilterMode = "both"; // Tracks the silver-platter entry the user pivoted away from, so the // search/list header can offer a one-click way back to it. null when the // popup never showed a single-match platter (e.g. multi-match list view). let silverPlatterOrigin: SelectorEntry | null = null; // Hooks any sub-state owns for cleanup at dismiss time. Today: TOTP tray // setInterval. Add to this list — don't fan out the dismiss function. const cleanupHooks: Array<() => void> = []; // Smooth height transitions whenever content changes height. // ResizeObserver fires on natural height changes; we freeze the old height, // then animate to the new one so the surface never jumps. { let prevH = 0; let blocked = false; const heightRo = new ResizeObserver(([entry]) => { if (blocked) return; const h = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; if (prevH > 0 && Math.abs(h - prevH) > 1) { blocked = true; container.style.height = `${prevH}px`; requestAnimationFrame(() => requestAnimationFrame(() => { container.style.height = `${h}px`; prevH = h; const onEnd = (e: TransitionEvent) => { if (e.propertyName !== "height") return; container.style.height = ""; blocked = false; container.removeEventListener("transitionend", onEnd); }; container.addEventListener("transitionend", onEnd); })); } else { prevH = h; } }); heightRo.observe(container); cleanupHooks.push(() => heightRo.disconnect()); } // 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 renderIdentityHelper() { currentMode = "identity-helper"; container.innerHTML = ""; focusedIndex = -1; searchInput = null; const fallbackKind: IdentityKind = options.identityKind ?? "username"; const identities = filterSavedIdentities(options.savedIdentities ?? [], query, identityMode); const typedQuery = query.trim(); const customKind = resolveCustomIdentityKind(typedQuery, identityMode, fallbackKind); function applyIdentity(identity: string) { if (options.hasEmailField) { if (options.identityKind === "email") { options.onFillFields?.("", "", identity); // fill email field now renderSecondaryIdentityStep("username", identity); } else { options.onFillFields?.(identity, "", ""); // fill username field now renderSecondaryIdentityStep("email", identity); } } else { options.onFillFields?.(identity, ""); renderGenerator(identity); } } function applyCustomIdentity(identity: string) { const trimmed = identity.trim(); if (!trimmed) return; applyIdentity(trimmed); } // Header: "NEW ACCOUNT" + domain name const hdr = container.appendChild(document.createElement("div")); hdr.className = "domain-header"; const headText = hdr.appendChild(document.createElement("div")); headText.className = "domain-header-text"; const eyebrow = headText.appendChild(document.createElement("div")); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "NEW ACCOUNT"; const domName = headText.appendChild(document.createElement("div")); domName.className = "domain-name"; domName.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); const actions = hdr.appendChild(document.createElement("div")); actions.className = "identity-mode-toggle"; for (const mode of ["both", "email", "username"] as const) { const btn = actions.appendChild(document.createElement("button")); btn.type = "button"; btn.className = `identity-mode-btn${identityMode === mode ? " is-active" : ""}`; btn.textContent = identityFilterLabel(mode); btn.setAttribute("aria-pressed", String(identityMode === mode)); btn.addEventListener("click", () => { identityMode = mode; renderIdentityHelper(); }); } const searchWrap = container.appendChild(document.createElement("div")); searchWrap.className = "search-wrap identity-search-wrap"; const search = searchWrap.appendChild(document.createElement("input")); search.className = "search"; search.placeholder = "search identities…"; search.value = query; search.addEventListener("input", () => { query = search.value; renderIdentityHelper(); searchInput?.focus(); searchInput?.setSelectionRange(query.length, query.length); }); search.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); dismiss(true); return; } if (e.key !== "Enter") return; const exact = identities.find((id) => id.value.toLowerCase() === typedQuery.toLowerCase()); if (exact) { e.preventDefault(); applyIdentity(exact.value); return; } if (!typedQuery || identities.length > 0) return; e.preventDefault(); applyCustomIdentity(typedQuery); }); const searchTools = searchWrap.appendChild(document.createElement("div")); searchTools.className = "identity-mode-toggle"; const sortHint = searchTools.appendChild(document.createElement("div")); sortHint.className = "identity-empty"; sortHint.style.padding = "0"; sortHint.textContent = "sorted by recent use"; searchInput = search; const customLabel = identityMode === "both" ? customKind : identityMode; // Identity rows const list = container.appendChild(document.createElement("div")); list.className = "identity-list"; // Bottom fade only when the list overflows its max-height and isn't scrolled // to the end. A short list (1-2 identities) shows no fade, so it never looks // prematurely cut off; a long list gets the affordance, which clears on // reaching the bottom. const updateListFade = () => { const overflowing = list.scrollHeight > list.clientHeight + 1; const atBottom = list.scrollTop + list.clientHeight >= list.scrollHeight - 2; const mask = overflowing && !atBottom ? "linear-gradient(to bottom, black calc(100% - 24px), transparent 100%)" : "none"; list.style.setProperty("mask-image", mask); list.style.setProperty("-webkit-mask-image", mask); }; list.addEventListener("scroll", updateListFade); if (typedQuery && identities.length === 0) { const empty = list.appendChild(document.createElement("div")); empty.className = "identity-empty"; empty.textContent = identityMode === "both" ? `No match for “${typedQuery}”.` : `No ${identityFilterLabel(identityMode).toLowerCase()} match for “${typedQuery}”.`; const custom = list.appendChild(document.createElement("div")); custom.className = "identity-custom-row"; const customTitle = custom.appendChild(document.createElement("div")); customTitle.className = "identity-custom-row-title"; customTitle.textContent = `Use “${typedQuery}” as a new ${customLabel}`; const customSub = custom.appendChild(document.createElement("div")); customSub.className = "identity-custom-row-sub"; customSub.textContent = "Press Enter or click to continue."; custom.addEventListener("click", () => applyCustomIdentity(typedQuery)); } for (const id of identities) { const row = list.appendChild(document.createElement("div")); row.className = "peach-identity-row"; row.setAttribute("tabindex", "-1"); const val = row.appendChild(document.createElement("div")); val.className = "peach-identity-value"; val.textContent = id.value; const meta = row.appendChild(document.createElement("div")); meta.className = "peach-identity-meta"; meta.textContent = `used ${id.useCount}×`; row.addEventListener("click", () => { applyIdentity(id.value); }); } updateListFade(); // Divider before "Use a different email…" if (identities.length > 0) { container.appendChild(Object.assign(document.createElement("div"), { className: "divider" })); } // "Use a different email/username…" expandable row const altRow = container.appendChild(document.createElement("div")); altRow.className = "identity-alt-row"; altRow.textContent = `+ Use a different ${customLabel}…`; const altInputWrap = container.appendChild(document.createElement("div")); altInputWrap.className = "identity-alt-input-wrap"; const altInput = altInputWrap.appendChild(document.createElement("input")); altInput.className = "identity-alt-input"; altInput.type = "text"; altInput.placeholder = customLabel === "email" ? "your@email.com" : "your username"; altInput.setAttribute("autocomplete", "off"); const altConfirm = altInputWrap.appendChild(document.createElement("button")); altConfirm.className = "identity-alt-confirm"; altConfirm.type = "button"; altConfirm.textContent = "✓"; function fillCustomIdentity() { const v = altInput.value.trim(); if (!v) return; applyCustomIdentity(v); } altRow.addEventListener("click", () => { altRow.style.display = "none"; altInputWrap.classList.add("open"); altInput.value = typedQuery || altInput.value; altInput.focus(); altInput.setSelectionRange(altInput.value.length, altInput.value.length); }); altConfirm.addEventListener("click", fillCustomIdentity); altInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); fillCustomIdentity(); } }); // Escape hatch: user already has a vault entry for this site but it // wasn't detected (different domain, old entry, etc.). Tapping this // switches to the full searchable list so they can find and fill it; // selecting an entry there will trigger the remember-site toast if // the domain isn't already associated. // MV-2: with lazy loading we no longer know the vault-wide count up front, // so offer the escape hatch whenever a loader (or an eager array) exists. if (options.loadSearchableEntries || (options.searchableEntries?.length ?? 0) > 0) { const escapeRow = container.appendChild(document.createElement("div")); escapeRow.className = "identity-escape-row"; escapeRow.textContent = "I already have an account →"; escapeRow.setAttribute("tabindex", "0"); const toSearch = () => { void ensureSearchableLoaded(renderList); renderList(); }; escapeRow.addEventListener("click", toSearch); escapeRow.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toSearch(); } }); } requestAnimationFrame(() => { search.focus(); search.setSelectionRange(query.length, query.length); }); } /** * Step 2 of the contextual signup wizard. * `stepKind` is the identity type to collect in this step ("email" or "username"). * `firstValue` is what was picked in step 1 (the other type). */ function renderSecondaryIdentityStep(stepKind: "email" | "username", firstValue: string) { currentMode = "identity-helper"; container.innerHTML = ""; const wrap = container.appendChild(document.createElement("div")); wrap.className = "gen-view"; // Header const hdrRow = wrap.appendChild(document.createElement("div")); hdrRow.className = "domain-header"; const eyebrow2 = hdrRow.appendChild(document.createElement("div")); eyebrow2.className = "domain-eyebrow"; eyebrow2.textContent = "NEW ACCOUNT"; const domName2 = hdrRow.appendChild(document.createElement("div")); domName2.className = "domain-name"; domName2.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); // Confirmed first step const conf = wrap.appendChild(document.createElement("div")); conf.className = "gen-confirmed"; const confCheck = conf.appendChild(document.createElement("span")); confCheck.className = "gen-confirmed-check"; confCheck.textContent = "✓"; const confVal = conf.appendChild(document.createElement("div")); confVal.className = "gen-confirmed-val"; confVal.textContent = firstValue; const stepLbl = wrap.appendChild(document.createElement("div")); stepLbl.className = "gen-label"; stepLbl.textContent = stepKind === "email" ? "Now choose your email" : "Now choose your username"; // Filter saved identities to the correct type for this step const identities = (options.savedIdentities ?? []) .filter(s => s.type === stepKind) .sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0)) .slice(0, 6); const list = wrap.appendChild(document.createElement("div")); list.className = "identity-list"; for (const id of identities) { const row = list.appendChild(document.createElement("div")); row.className = "peach-identity-row"; row.setAttribute("tabindex", "-1"); const val = row.appendChild(document.createElement("div")); val.className = "peach-identity-value"; val.textContent = id.value; const meta = row.appendChild(document.createElement("div")); meta.className = "peach-identity-meta"; meta.textContent = `used ${id.useCount}×`; row.addEventListener("click", () => applySecondIdentity(id.value)); } // Text input for a value not in the list const altInputWrap = wrap.appendChild(document.createElement("div")); altInputWrap.className = "identity-alt-input-wrap open"; const altInput = altInputWrap.appendChild(document.createElement("input")); altInput.className = "identity-alt-input"; altInput.type = stepKind === "email" ? "email" : "text"; altInput.placeholder = stepKind === "email" ? "your@email.com" : "your username"; altInput.setAttribute("autocomplete", "off"); const altConfirm = altInputWrap.appendChild(document.createElement("button")); altConfirm.className = "identity-alt-confirm"; altConfirm.type = "button"; altConfirm.textContent = "→"; function applySecondIdentity(value: string) { const trimmed = value.trim(); if (!trimmed) return; // Determine final email + username order before passing to the generator const [email, username] = stepKind === "email" ? [trimmed, firstValue] : [firstValue, trimmed]; if (stepKind === "email") { options.onFillFields?.("", "", trimmed); // fill email field } else { options.onFillFields?.(trimmed, "", ""); // fill username field } renderGenerator(email, username); } altConfirm.addEventListener("click", () => applySecondIdentity(altInput.value)); altInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); applySecondIdentity(altInput.value); } }); // Skip: proceed without this field const skipRow = wrap.appendChild(document.createElement("div")); skipRow.className = "identity-escape-row"; skipRow.textContent = `Skip ${stepKind} →`; skipRow.setAttribute("tabindex", "0"); skipRow.addEventListener("click", () => { const [email, username] = stepKind === "email" ? ["", firstValue] : [firstValue, ""]; renderGenerator(email, username); }); skipRow.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); const [email, username] = stepKind === "email" ? ["", firstValue] : [firstValue, ""]; renderGenerator(email, username); } }); requestAnimationFrame(() => { altInput.focus(); }); } function renderGenerator(filledEmail: string, filledUsername?: string) { container.innerHTML = ""; const sitePolicy = detectPasswordPolicy(); const wrap = container.appendChild(document.createElement("div")); wrap.className = "gen-view"; // Header: domain + NEW ACCOUNT const hdrRow = wrap.appendChild(document.createElement("div")); hdrRow.className = "domain-header"; const eyebrow2 = hdrRow.appendChild(document.createElement("div")); eyebrow2.className = "domain-eyebrow"; eyebrow2.textContent = "NEW ACCOUNT"; const domName2 = hdrRow.appendChild(document.createElement("div")); domName2.className = "domain-name"; domName2.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); // Confirmed identity rows — show both username and email when both were collected function confirmedRow(value: string) { const conf = wrap.appendChild(document.createElement("div")); conf.className = "gen-confirmed"; const confCheck = conf.appendChild(document.createElement("span")); confCheck.className = "gen-confirmed-check"; confCheck.textContent = "✓"; const confVal = conf.appendChild(document.createElement("div")); confVal.className = "gen-confirmed-val"; confVal.textContent = value; } if (filledEmail) confirmedRow(filledEmail); if (filledUsername && filledUsername !== filledEmail) confirmedRow(filledUsername); const lbl = wrap.appendChild(document.createElement("div")); lbl.className = "gen-label"; lbl.textContent = "Generate a password"; let currentPw = ""; const smartGen = createSmartGenerator({ policy: sitePolicy, onChange: (pw) => { currentPw = pw; }, }); wrap.appendChild(smartGen.root); const fillBtn = wrap.appendChild(document.createElement("button")); fillBtn.className = "gen-fill-btn"; fillBtn.type = "button"; fillBtn.textContent = "Copy & fill"; fillBtn.addEventListener("click", async () => { if (!currentPw) return; fillBtn.disabled = true; fillBtn.textContent = "Saving…"; // Fill the page fields first — password lands immediately. options.onFillFields?.("", currentPw, ""); // Auto-save to vault. Fall back to the save-offer form only if the save fails // (vault locked, quota exceeded, etc.) so the user sees the error and can retry. const saveUsername = filledUsername || filledEmail; const saveEmail = filledUsername ? (filledEmail || undefined) : undefined; try { const res = await chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { title: sTitle, url: sOrigin, username: saveUsername, email: saveEmail, password: currentPw, type: "login" }, }); if ((res as { ok?: boolean }).ok) { showSuccess(sTitle); } else { renderSaveOffer(filledEmail, currentPw, filledUsername); } } catch { renderSaveOffer(filledEmail, currentPw, filledUsername); } }); } function renderSaveOffer(filledEmail: string, filledPassword: string, filledUsername?: string) { container.innerHTML = ""; const domain = options.domain ?? location.hostname.replace(/^www\./, ""); const wrap = container.appendChild(document.createElement("div")); wrap.className = "save-offer"; const title = wrap.appendChild(document.createElement("div")); title.className = "save-offer-title"; title.textContent = "Save this login to Peach?"; function detailRow(label: string, value: string) { const row = wrap.appendChild(document.createElement("div")); row.className = "save-offer-row"; const lbl = row.appendChild(document.createElement("div")); lbl.className = "save-offer-lbl"; lbl.textContent = label; const val = row.appendChild(document.createElement("div")); val.className = "save-offer-val"; val.textContent = value; } detailRow("site", domain); // When both username and email were collected, show both; otherwise show the identity as "email" if (filledUsername) { detailRow("username", filledUsername); if (filledEmail) detailRow("email", filledEmail); } else { if (filledEmail) detailRow("email", filledEmail); } detailRow("password", "••••••••••••••••"); const btns = wrap.appendChild(document.createElement("div")); btns.className = "save-offer-btns"; const saveBtn = btns.appendChild(document.createElement("button")); saveBtn.className = "save-offer-save"; saveBtn.type = "button"; saveBtn.textContent = "Save"; saveBtn.addEventListener("click", async () => { saveBtn.disabled = true; saveBtn.textContent = "Saving…"; try { // When both username and email were set, save them in the correct fields. // Otherwise fall back to storing the identity as the username (current behaviour). const saveUsername = filledUsername || filledEmail; const saveEmail = filledUsername ? (filledEmail || undefined) : undefined; const res = await chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { title: sTitle, url: sOrigin, username: saveUsername, email: saveEmail, password: filledPassword, type: "login" }, }); if ((res as { ok?: boolean }).ok) { showSuccess(sTitle); } else { saveBtn.disabled = false; saveBtn.textContent = "Save"; } } catch { saveBtn.disabled = false; saveBtn.textContent = "Save"; } }); const skipBtn = btns.appendChild(document.createElement("button")); skipBtn.className = "save-offer-skip"; skipBtn.type = "button"; skipBtn.textContent = "Not now"; skipBtn.addEventListener("click", () => { dismiss(true); }); } function renderList() { container.innerHTML = ""; focusedIndex = -1; searchInput = null; currentMode = "list"; const current = activeEntries(); // Domain header (always show) — eyebrow + domain on the left, // a "+" shortcut to the create form on the right so the user is never // trapped in the search list when they want to make a new entry. const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const headText = document.createElement("div"); headText.className = "domain-header-text"; 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\./, ""); headText.append(eyebrow, domName); domHdr.appendChild(headText); if (!options.isSignup) { const actions = document.createElement("div"); actions.className = "domain-header-actions"; // Back arrow takes the place of the silver-platter's search icon when // the user has pivoted away from a single-match platter — gives them // a one-click way back to the original recommendation. if (silverPlatterOrigin) { const backBtn = document.createElement("button"); backBtn.className = "domain-search-btn"; backBtn.type = "button"; backBtn.setAttribute("aria-label", "Back to suggested entry"); backBtn.title = "Back to suggested entry"; backBtn.innerHTML = backSvgStr(); backBtn.addEventListener("click", (e) => { e.stopPropagation(); if (silverPlatterOrigin) renderSilverPlatter(silverPlatterOrigin); }); actions.appendChild(backBtn); } const addBtn = document.createElement("button"); addBtn.className = "domain-add-btn"; addBtn.type = "button"; addBtn.setAttribute("aria-label", "Create new entry"); addBtn.title = "Create new entry"; addBtn.textContent = "+"; addBtn.addEventListener("click", (e) => { e.stopPropagation(); void showCreateForm(); }); actions.appendChild(addBtn); domHdr.appendChild(actions); } 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 ?? ""; // MV-2: typing in the search box is the explicit gesture that loads the // vault-wide list (once, cached); it re-renders when it resolves. void ensureSearchableLoaded(renderList); renderList(); searchInput?.focus(); searchInput?.setSelectionRange(query.length, query.length); }); sw.appendChild(searchInput); // UI-2 / MV-2: show a brief loading hint while the vault-wide list loads // on the first search gesture (cached afterwards, so only the first time). if (searchableLoading) { const loadingHint = document.createElement("span"); loadingHint.className = "search-loading"; loadingHint.textContent = "searching…"; sw.appendChild(loadingHint); } 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); } if (e.hasPasskey) { const badge = document.createElement("span"); badge.className = "passkey-badge"; badge.textContent = "passkey"; entryEl.appendChild(badge); } entryEl.addEventListener("click", () => { if (options.isSignup && options.onFillFields) { // Fill username, email, and leave password for the user to confirm options.onFillFields(e.username ?? "", "", e.email); 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(); // Seed query (from type-to-search or domain-search-btn) lands at the // end so the next keystroke extends it, not overwrites. searchInput?.setSelectionRange(query.length, query.length); }); } } 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" }); } function attachIdentitySuggestions(g: HTMLDivElement, inp: HTMLInputElement, identities: SavedIdentity[], fieldType: "username" | "email"): void { const typed = identities.filter(s => s.type === fieldType); if (!typed.length) return; let collapsed = false; let suppressNextFocus = false; const list = g.appendChild(document.createElement("div")); list.className = "identity-suggest-list"; list.style.display = "none"; // Collapse toggle — sits alone now that filter pills are gone const collapseRow = list.appendChild(document.createElement("div")); collapseRow.className = "identity-filter-row"; collapseRow.style.justifyContent = "flex-end"; const collapseBtn = collapseRow.appendChild(document.createElement("button")); collapseBtn.type = "button"; collapseBtn.className = "identity-collapse-btn"; collapseBtn.innerHTML = ``; collapseBtn.addEventListener("mousedown", (e) => { e.preventDefault(); collapsed = !collapsed; collapseBtn.classList.toggle("is-collapsed", collapsed); rowsWrapper.classList.toggle("is-collapsed", collapsed); }); const rowsWrapper = list.appendChild(document.createElement("div")); rowsWrapper.className = "identity-rows"; const rows = rowsWrapper.appendChild(document.createElement("div")); rows.className = "identity-rows-inner"; // Bottom fade only when the list actually overflows and isn't scrolled to // the end — a static mask-image fades the last row even on a 1-2 item list // that fits entirely, making it look cut off. Driven off live scroll state. const updateRowsFade = () => { const overflowing = rows.scrollHeight > rows.clientHeight + 1; const atBottom = rows.scrollTop + rows.clientHeight >= rows.scrollHeight - 2; const mask = overflowing && !atBottom ? "linear-gradient(to bottom, black calc(100% - 18px), transparent 100%)" : "none"; rows.style.setProperty("mask-image", mask); rows.style.setProperty("-webkit-mask-image", mask); }; rows.addEventListener("scroll", updateRowsFade); // Hides the list and removes the document-level click watcher. function hideList() { list.style.display = "none"; document.removeEventListener("mousedown", onDocMousedown, { capture: true }); } // Dismiss list on any mousedown that originates outside the popup's shadow // DOM. This is the ONLY close trigger for the list — blur is intentionally // NOT used, because modal focus traps fire blur without a mousedown (they // call element.focus() programmatically in response to focusin/focusout), // which caused the list to vanish before the user could pick from it. function onDocMousedown(e: MouseEvent) { if (e.composedPath().includes(host)) return; hideList(); } function pick(value: string) { inp.value = value; hideList(); suppressNextFocus = true; inp.focus(); // If a focus trap intercepts inp.focus() and redirects to anchorEl before // inp ever fires its focus event, suppressNextFocus would strand the user. // Clear after a short window so subsequent clicks still work. setTimeout(() => { suppressNextFocus = false; }, 120); } function render() { const q = inp.value.toLowerCase(); const filtered = typed.filter(s => !q || s.value.toLowerCase().includes(q)).slice(0, 8); rows.innerHTML = ""; if (!filtered.length) { hideList(); return; } for (const s of filtered) { const row = rows.appendChild(document.createElement("button")); row.type = "button"; row.className = "identity-suggest-row"; const val = row.appendChild(document.createElement("span")); val.className = "identity-suggest-val"; val.textContent = s.value; const meta = row.appendChild(document.createElement("span")); meta.className = "identity-suggest-meta"; meta.textContent = `${s.useCount}×`; row.addEventListener("mousedown", (e) => { e.preventDefault(); pick(s.value); }); row.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); (row.nextElementSibling as HTMLElement | null)?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = row.previousElementSibling as HTMLElement | null; if (prev) prev.focus(); else inp.focus(); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(s.value); } else if (e.key === "Escape") { e.preventDefault(); hideList(); suppressNextFocus = true; inp.focus(); } }); // Row blur only fires during keyboard navigation (arrow keys / Tab). // Focus traps don't produce row blur events since rows aren't focused // when the trap fires; this handler is therefore trap-safe. row.addEventListener("blur", (e) => { const rt = (e as FocusEvent).relatedTarget as Node | null; if (rt && (rt === inp || list.contains(rt))) return; setTimeout(() => { hideList(); }, 150); }); } list.style.display = ""; updateRowsFade(); // Register the close-on-outside-click watcher each time the list shows. // addEventListener deduplicates identical (fn, options) pairs, so // re-rendering on input events does not stack up listeners. document.addEventListener("mousedown", onDocMousedown, { capture: true }); } inp.addEventListener("focus", () => { if (suppressNextFocus) { suppressNextFocus = false; return; } render(); }); inp.addEventListener("input", render); inp.addEventListener("keydown", (e) => { if (list.style.display === "none") return; if (e.key === "ArrowDown" && !collapsed) { e.preventDefault(); (rows.firstElementChild as HTMLElement | null)?.focus(); } else if (e.key === "Escape") { e.preventDefault(); hideList(); } else if (e.key === "Tab") { // Tab moves focus away intentionally — close the list. hideList(); } }); // No blur handler on inp. Blur fires for ANY focus movement including // programmatic focus() calls from modal focus traps, which caused the list // to close before the user could interact. The document mousedown listener // (registered in render()) handles intentional click-outside dismissal, // and the keydown handler above covers keyboard Tab/Escape. // Ensure the watcher is removed if the main popup is dismissed while the // list is visible. cleanupHooks.push(hideList); } 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(); // Fetch identities on-demand — they're passed in signup context but not // in login/search context where the user still clicks "New entry". let formIdentities: SavedIdentity[] = options.savedIdentities ?? []; if (!formIdentities.length) { try { const idRes = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as { ok: boolean; data?: SavedIdentity[] } | null; if (idRes?.ok && Array.isArray(idRes.data)) formIdentities = idRes.data; } catch { /* silent fallback */ } } 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 if it doesn't look like an email const xg = fGroup("username"); xg.inp.value = preUsername && !preUsername.includes("@") ? preUsername : ""; xg.inp.placeholder = "johndoe"; xg.inp.setAttribute("autocomplete", "off"); if (formIdentities.length) attachIdentitySuggestions(xg.g, xg.inp, formIdentities, "username"); body.appendChild(xg.g); // Email — pre-fill from anchor if it looks like an email const eg = fGroup("email"); (eg.inp as HTMLInputElement).type = "email"; eg.inp.value = preUsername && preUsername.includes("@") ? preUsername : ""; eg.inp.placeholder = "you@example.com"; eg.inp.setAttribute("autocomplete", "off"); if (formIdentities.length) attachIdentitySuggestions(eg.g, eg.inp, formIdentities, "email"); body.appendChild(eg.g); // Password — full smart generator with charset pills + length slider + // policy hint. Mirrors the main UI's compact generator so users get the // same controls inline as in the popup. const pg = fGroup("password"); pg.inp.remove(); // smartGen provides its own display — no separate input needed let currentPassword = prePassword || ""; const smartGen = createSmartGenerator({ policy: sitePolicy, initialPassword: prePassword || undefined, onChange: (pw) => { currentPassword = pw; }, }); pg.g.appendChild(smartGen.root); 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 = currentPassword.trim(); const username = xg.inp.value.trim(); const email = eg.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) { showFormError("Entry needs a password — tap regen to make one"); 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, email: email || undefined, password: pw, type: "login" }, }); if ((res as { ok?: boolean }).ok) { // Fill the detected form. onFillFields uses whichever identity the // page anchored on — the vault captures both username and email. options.onFillFields?.(username || email, pw); showSuccess(title); } else { const err = (res as { error?: string })?.error; const errMsg = err === "vault_locked" ? "Vault locked — open Peach to save" : err === "STORAGE_QUOTA_EXCEEDED" ? "Out of storage — open Peach → Settings to free space" : err ? `Could not save: ${err}` : "Could not save entry"; showFormError(errMsg); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; } } catch (e) { showFormError(e instanceof Error && e.message ? `Could not reach Peach: ${e.message}` : "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); } // M3/QUA-005: when both the direct clipboard write and the background COPY // fallback fail, briefly show the user that the copy did not happen. function flashCopyError(btn: HTMLButtonElement) { const original = btn.textContent ?? ""; btn.textContent = "Blocked"; btn.classList.add("flashed"); setTimeout(() => { btn.classList.remove("flashed"); if (btn.isConnected) btn.textContent = original; }, 1200); } function renderSilverPlatter(entry: SelectorEntry) { container.innerHTML = ""; searchInput = null; focusedIndex = -1; currentMode = "silver-platter"; silverPlatterOrigin = entry; const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const headText = document.createElement("div"); headText.className = "domain-header-text"; 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\./, ""); headText.append(eyebrow, domName); domHdr.appendChild(headText); if (!options.isSignup) { const actions = document.createElement("div"); actions.className = "domain-header-actions"; // Search shortcut — same idea as type-to-search below, but visible so // the user can discover that another entry is reachable from a // single-match platter. Sits to the left of the create button. const searchBtn = document.createElement("button"); searchBtn.className = "domain-search-btn"; searchBtn.type = "button"; searchBtn.setAttribute("aria-label", "Search for a different entry"); searchBtn.title = "Search for a different entry"; searchBtn.innerHTML = searchSvgStr(); searchBtn.addEventListener("click", (e) => { e.stopPropagation(); query = ""; renderList(); }); actions.appendChild(searchBtn); const addBtn = document.createElement("button"); addBtn.className = "domain-add-btn"; addBtn.type = "button"; addBtn.setAttribute("aria-label", "Create new entry"); addBtn.title = "Create new entry"; addBtn.textContent = "+"; addBtn.addEventListener("click", (e) => { e.stopPropagation(); void showCreateForm(); }); actions.appendChild(addBtn); domHdr.appendChild(actions); } 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.email) { const emailEl = document.createElement("div"); emailEl.className = "sp-identity-row"; const emailLbl = document.createElement("span"); emailLbl.className = "sp-identity-label"; emailLbl.textContent = "email"; const emailVal = document.createElement("span"); emailVal.className = "sp-username"; emailVal.textContent = entry.email; emailEl.append(emailLbl, emailVal); platter.appendChild(emailEl); } if (entry.username) { const userEl = document.createElement("div"); userEl.className = "sp-identity-row"; const userLbl = document.createElement("span"); userLbl.className = "sp-identity-label"; userLbl.textContent = entry.username.includes("@") ? "email" : "username"; const userVal = document.createElement("span"); userVal.className = "sp-username"; userVal.textContent = entry.username; userEl.append(userLbl, userVal); 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 actionsGroup = document.createElement("div"); actionsGroup.className = "sp-actions-group"; const actionsLabel = document.createElement("div"); actionsLabel.className = "sp-actions-label"; actionsLabel.textContent = "Copy"; actionsGroup.appendChild(actionsLabel); const actions = document.createElement("div"); actions.className = "sp-actions"; actionsGroup.appendChild(actions); if (entry.email) { const copyEmail = document.createElement("button"); copyEmail.className = "sp-action-btn"; copyEmail.type = "button"; copyEmail.title = "Copy email"; copyEmail.textContent = "Email"; copyEmail.addEventListener("click", async (e) => { e.stopPropagation(); let alreadyWritten = false; try { await navigator.clipboard.writeText(entry.email!); alreadyWritten = true; } catch (err) { console.warn("[Peach] clipboard write denied:", err); } const resp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: entry.email, label: "Email", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; if (alreadyWritten || resp?.ok) flashActionBtn(copyEmail); else flashCopyError(copyEmail); }); actions.appendChild(copyEmail); } const copyU = document.createElement("button"); copyU.className = "sp-action-btn"; copyU.type = "button"; const usernameIsEmail = entry.username?.includes("@") ?? false; copyU.title = usernameIsEmail ? "Copy email" : "Copy username"; copyU.textContent = usernameIsEmail ? "Email" : "Username"; 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 (err) { console.warn("[Peach] clipboard write denied:", err); } const resp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: entry.username, label: entry.username.includes("@") ? "Email" : "Username", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; if (alreadyWritten || resp?.ok) flashActionBtn(copyU); else flashCopyError(copyU); }); actions.appendChild(copyU); const copyP = document.createElement("button"); copyP.className = "sp-action-btn"; copyP.type = "button"; copyP.title = "Copy password"; copyP.textContent = "Password"; copyP.addEventListener("click", async (e) => { e.stopPropagation(); const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", // MV-1: user explicitly picked this entry from Peach's inline summon UI. payload: { id: entry.id, explicitSummon: true }, }).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 (err) { console.warn("[Peach] clipboard write denied:", err); } const copyResp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: resp.data.password, label: "Password", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; if (alreadyWritten || copyResp?.ok) flashActionBtn(copyP); else flashCopyError(copyP); }); actions.appendChild(copyP); let trayEl: HTMLDivElement | null = null; let trayInterval: ReturnType | null = null; function closeTray() { if (trayInterval) { clearInterval(trayInterval); trayInterval = null; } trayEl?.remove(); trayEl = null; } // Register so dismiss() will close the tray and clear the interval // even if the user dismisses without explicitly closing the tray. cleanupHooks.push(closeTray); async function openTOTPTray() { // Fetch the current TOTP code from the background. The raw base32 seed // never enters the content script (SEV-3-C): we receive the live 6-digit // code plus the seconds remaining, count down locally, and re-fetch a // fresh code only when the 30s window rolls over. async function fetchCode(): Promise<{ code: string; remaining: number } | null> { const r = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", // MV-1: explicit user summon of this entry's live TOTP code. payload: { id: entry.id, explicitSummon: true }, }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null; if (!r?.ok || !r.data?.totpCode) return null; return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 }; } let current = await fetchCode(); if (!current) return; trayEl = document.createElement("div"); trayEl.className = "sp-tray"; const label = document.createElement("span"); label.className = "sp-tray-label"; label.textContent = "2FA"; trayEl.appendChild(label); const codeEl = document.createElement("div"); codeEl.className = "sp-totp-code"; trayEl.appendChild(codeEl); const ringNs = "http://www.w3.org/2000/svg"; const ring = document.createElementNS(ringNs, "svg"); ring.setAttribute("class", "sp-totp-ring"); ring.setAttribute("viewBox", "0 0 24 24"); const trackC = document.createElementNS(ringNs, "circle"); trackC.setAttribute("class", "ring-track"); trackC.setAttribute("cx", "12"); trackC.setAttribute("cy", "12"); trackC.setAttribute("r", "10"); trackC.setAttribute("fill", "none"); trackC.setAttribute("stroke-width", "2"); const fillC = document.createElementNS(ringNs, "circle"); fillC.setAttribute("class", "ring-fill"); fillC.setAttribute("cx", "12"); fillC.setAttribute("cy", "12"); fillC.setAttribute("r", "10"); fillC.setAttribute("fill", "none"); fillC.setAttribute("stroke-width", "2"); fillC.setAttribute("stroke-linecap", "round"); fillC.setAttribute("stroke-dasharray", "62.83"); ring.append(trackC, fillC); trayEl.appendChild(ring); const copyBtn = document.createElement("button"); copyBtn.className = "sp-totp-copy"; copyBtn.type = "button"; copyBtn.textContent = "Copy"; trayEl.appendChild(copyBtn); function render() { const { code, remaining } = current!; // Group as "847 291" — easier to read than "847291" codeEl.textContent = code.length === 6 ? `${code.slice(0, 3)} ${code.slice(3)}` : code; // Ring fills counter-clockwise as time elapses: dashoffset goes from // 0 (full ring) to 62.83 (empty) over the 30s window. const fraction = (30 - remaining) / 30; fillC.setAttribute("stroke-dashoffset", String(62.83 * fraction)); } render(); trayInterval = setInterval(async () => { if (!current) return; current = { code: current.code, remaining: current.remaining - 1 }; // Window rolled over — pull a fresh code from the background. if (current.remaining <= 0) { const next = await fetchCode(); current = next ?? { code: current.code, remaining: 30 }; } render(); }, 1000); copyBtn.addEventListener("click", async (e) => { e.stopPropagation(); const code = current?.code; if (!code) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch (err) { console.warn("[Peach] clipboard write denied:", err); } const resp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; copyBtn.textContent = alreadyWritten || resp?.ok ? "Copied" : "Blocked"; setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = "Copy"; }, 1200); }); platter.appendChild(trayEl); } const editBtn = document.createElement("button"); editBtn.className = "sp-action-btn sp-action-edit"; editBtn.type = "button"; editBtn.title = "Edit entry"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", (e) => { e.stopPropagation(); closeTray(); void renderInlineEdit(entry); }); actions.appendChild(editBtn); // Always show the 2FA tray when the entry has TOTP — hiding it behind a // "···" toggle was zero-savings (the toggle button took the same space // the "2FA" label needed) and made the code one extra click away. if (entry.hasTotp) void openTOTPTray(); platter.appendChild(actionsGroup); container.appendChild(platter); } /** * Inline-popup edit mode. Swaps the silver platter for an editable form * over the same shadow-DOM surface so the user never leaves the page. * Saves go through UPDATE_ENTRY; on success we refresh the silver platter * with the new fields. Cancel returns without writing. */ async function renderInlineEdit(summary: SelectorEntry) { currentMode = "edit"; container.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "create-form-wrap"; const head = document.createElement("div"); head.className = "create-head"; const back = document.createElement("button"); back.className = "back-btn"; back.type = "button"; back.title = "Cancel"; back.innerHTML = ``; back.addEventListener("click", () => renderSilverPlatter(summary)); head.appendChild(back); const headLbl = document.createElement("div"); headLbl.className = "create-label"; headLbl.textContent = "Edit entry"; head.appendChild(headLbl); wrap.appendChild(head); const body = document.createElement("div"); body.className = "create-body"; // Loading shimmer while we fetch the full entry const loading = document.createElement("div"); loading.className = "empty-hint"; loading.textContent = "loading…"; body.appendChild(loading); wrap.appendChild(body); container.appendChild(wrap); const [res, idRes] = await Promise.all([ // The edit form needs the raw TOTP seed so a save doesn't wipe it — use // the trusted edit channel, not the seed-stripped GET_ENTRY_FOR_FILL. chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_EDIT", payload: { id: summary.id }, }).catch(() => null), chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null), ]); const entryRes = res as { ok: boolean; data?: { title?: string; username?: string; email?: string; password?: string; url?: string; totp?: string; notes?: string; passwordHistory?: PasswordHistoryItem[] } } | null; const editIdentities: SavedIdentity[] = (idRes as { ok?: boolean; data?: SavedIdentity[] } | null)?.data ?? []; if (!entryRes || !entryRes.ok || !entryRes.data) { loading.textContent = "couldn't load this entry. try again from the popup."; return; } body.innerHTML = ""; function field(label: string, value: string, type: "text" | "password" = "text"): { g: HTMLDivElement; inp: HTMLInputElement } { const group = document.createElement("div"); group.className = "f-group"; const lbl = document.createElement("div"); lbl.className = "f-label"; lbl.textContent = label; const input = document.createElement("input"); input.className = "f-input"; input.type = type; input.value = value; input.autocomplete = "off"; group.append(lbl, input); body.appendChild(group); return { g: group, inp: input }; } const titleInput = field("title", entryRes.data.title ?? "").inp; const urlInput = field("url", entryRes.data.url ?? "").inp; urlInput.classList.add("url-f"); const usernameLabel = (entryRes.data.username ?? "").includes("@") ? "email" : "username"; const { g: usernameGroup, inp: usernameInput } = field(usernameLabel, entryRes.data.username ?? ""); if (editIdentities.length) attachIdentitySuggestions(usernameGroup, usernameInput, editIdentities, "username"); const { g: emailGroup, inp: emailInput } = field("email", entryRes.data.email ?? ""); if (editIdentities.length) attachIdentitySuggestions(emailGroup, emailInput, editIdentities, "email"); // Password row with reveal toggle + generator const pwGroup = document.createElement("div"); pwGroup.className = "f-group"; const pwLbl = document.createElement("div"); pwLbl.className = "f-label"; pwLbl.textContent = "password"; const pwRow = document.createElement("div"); pwRow.className = "f-row"; const pwInput = document.createElement("input"); pwInput.className = "f-input"; pwInput.type = "password"; pwInput.value = entryRes.data.password ?? ""; pwInput.autocomplete = "new-password"; const revealBtn = document.createElement("button"); revealBtn.className = "gen-btn"; revealBtn.type = "button"; revealBtn.textContent = "show"; revealBtn.addEventListener("click", () => { const reveal = pwInput.type === "password"; pwInput.type = reveal ? "text" : "password"; revealBtn.textContent = reveal ? "hide" : "show"; }); pwRow.append(pwInput, revealBtn); const editSmartGen = createSmartGenerator({ policy: detectPasswordPolicy(), onChange: (pw) => { pwInput.value = pw; } }); editSmartGen.root.style.display = "none"; let editGenVisible = false; const genToggleBtn = document.createElement("button"); genToggleBtn.className = "gen-toggle-link"; genToggleBtn.type = "button"; genToggleBtn.textContent = "⚙ generate"; genToggleBtn.addEventListener("click", () => { editGenVisible = !editGenVisible; pwRow.style.display = editGenVisible ? "none" : ""; editSmartGen.root.style.display = editGenVisible ? "" : "none"; genToggleBtn.textContent = editGenVisible ? "☰ hide generator" : "⚙ generate"; }); pwGroup.append(pwLbl, pwRow, genToggleBtn, editSmartGen.root); // Past-password history: reveal, copy, or restore a previously-used value. // Rows are masked by default; "use" loads the old value into the editor so // the standard save flow records the swap (no separate write path). The // toggle is always present (matching the main popup) so the affordance is // discoverable — even before any history exists it explains how it fills. const history = entryRes.data.passwordHistory ?? []; { const histList = document.createElement("div"); histList.className = "pw-history"; histList.style.display = "none"; let histVisible = false; const restLabel = history.length ? `↺ history (${history.length})` : "↺ history"; const histToggle = document.createElement("button"); histToggle.className = "gen-toggle-link"; histToggle.type = "button"; histToggle.textContent = restLabel; histToggle.addEventListener("click", () => { histVisible = !histVisible; histList.style.display = histVisible ? "" : "none"; histToggle.textContent = histVisible ? "↺ hide history" : restLabel; }); if (history.length === 0) { const empty = document.createElement("div"); empty.className = "pw-history-empty"; empty.textContent = "No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it."; histList.appendChild(empty); } for (const item of history) { const row = document.createElement("div"); row.className = "pw-history-row"; const main = document.createElement("div"); main.className = "pw-history-main"; const pwText = document.createElement("div"); pwText.className = "pw-history-pw"; let revealed = false; const mask = "••••••••••••"; pwText.textContent = mask; const age = document.createElement("div"); age.className = "pw-history-age"; age.textContent = timeSince(item.changedAt); main.append(pwText, age); const acts = document.createElement("div"); acts.className = "pw-history-actions"; const revealBtn2 = document.createElement("button"); revealBtn2.className = "pw-history-btn"; revealBtn2.type = "button"; revealBtn2.textContent = "show"; revealBtn2.addEventListener("click", () => { revealed = !revealed; pwText.textContent = revealed ? item.password : mask; revealBtn2.textContent = revealed ? "hide" : "show"; }); const copyBtn2 = document.createElement("button"); copyBtn2.className = "pw-history-btn"; copyBtn2.type = "button"; copyBtn2.textContent = "copy"; copyBtn2.addEventListener("click", async () => { let alreadyWritten = false; try { await navigator.clipboard.writeText(item.password); alreadyWritten = true; } catch (err) { console.warn("[Peach] clipboard write denied:", err); } await chrome.runtime.sendMessage({ type: "COPY", payload: { text: item.password, label: "Password", alreadyWritten }, }).catch(() => null); copyBtn2.textContent = "copied"; setTimeout(() => { if (copyBtn2.isConnected) copyBtn2.textContent = "copy"; }, 1200); }); const useBtn = document.createElement("button"); useBtn.className = "pw-history-btn"; useBtn.type = "button"; useBtn.textContent = "use"; useBtn.title = "Use this password again"; useBtn.addEventListener("click", () => { pwInput.value = item.password; pwInput.type = "text"; revealBtn.textContent = "hide"; if (editGenVisible) { editGenVisible = false; pwRow.style.display = ""; editSmartGen.root.style.display = "none"; genToggleBtn.textContent = "⚙ generate"; } histVisible = false; histList.style.display = "none"; histToggle.textContent = `↺ history (${history.length})`; }); acts.append(revealBtn2, copyBtn2, useBtn); row.append(main, acts); histList.appendChild(row); } pwGroup.append(histToggle, histList); } body.appendChild(pwGroup); const totpInput = field("2fa secret", entryRes.data.totp ?? "").inp; // Notes const notesGroup = document.createElement("div"); notesGroup.className = "f-group"; const notesLbl = document.createElement("div"); notesLbl.className = "f-label"; notesLbl.textContent = "notes"; const notesInput = document.createElement("textarea"); notesInput.className = "f-input"; notesInput.rows = 3; notesInput.value = entryRes.data.notes ?? ""; notesInput.style.fontFamily = "Inter, sans-serif"; notesInput.style.resize = "vertical"; notesGroup.append(notesLbl, notesInput); body.appendChild(notesGroup); // Save / Cancel row const actionsRow = document.createElement("div"); actionsRow.className = "f-row"; const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button"; saveBtn.textContent = "Save changes"; saveBtn.style.flex = "1"; const cancelBtn = document.createElement("button"); cancelBtn.className = "gen-btn"; cancelBtn.type = "button"; cancelBtn.textContent = "cancel"; cancelBtn.addEventListener("click", () => renderSilverPlatter(summary)); actionsRow.append(saveBtn, cancelBtn); body.appendChild(actionsRow); saveBtn.addEventListener("click", async () => { saveBtn.disabled = true; const originalLabel = saveBtn.textContent; saveBtn.textContent = "Saving…"; const patch: Record = { title: titleInput.value.trim(), url: urlInput.value.trim(), username: usernameInput.value.trim(), email: emailInput.value.trim() || undefined, password: pwInput.value, totp: totpInput.value.trim(), notes: notesInput.value, }; const updateRes = (await chrome.runtime.sendMessage({ type: "UPDATE_ENTRY", payload: { id: summary.id, patch }, }).catch(() => null)) as { ok: boolean; error?: string } | null; if (!updateRes?.ok) { saveBtn.disabled = false; saveBtn.textContent = originalLabel; const err = document.createElement("div"); err.className = "empty-hint"; err.style.color = "color-mix(in srgb, #e55 70%, transparent)"; err.textContent = updateRes?.error ?? "Couldn't save. Try the popup."; body.appendChild(err); setTimeout(() => err.remove(), 3000); return; } // Refresh the platter with updated summary fields const refreshed: SelectorEntry = { id: summary.id, title: patch.title as string, username: patch.username as string, email: patch.email as string | undefined, url: patch.url as string, hasTotp: Boolean(patch.totp), hasPasskey: summary.hasPasskey, }; renderSilverPlatter(refreshed); }); queueMicrotask(() => titleInput.focus()); } 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); // The master password is NEVER collected in page-hosted DOM. A malicious // page can observe keystrokes on the shared DOM event stream (a capture- // phase listener registered before Peach injects sees every key before any // stopImmediatePropagation), which would leak the vault's root secret // (audit HIGH). Instead, route unlocking to the trusted extension popup, // where keystrokes are isolated from the page. After the user unlocks // there, the background's VAULT_UNLOCKED broadcast re-triggers autofill // automatically (see content/index.ts), so the flow resumes on its own. const sub = document.createElement("div"); sub.className = "locked-sub"; sub.textContent = "Unlock in Peach — your master password is never typed on this page."; const openBtn = document.createElement("button"); openBtn.className = "locked-btn"; openBtn.type = "button"; openBtn.textContent = "Open Peach to unlock"; openBtn.addEventListener("click", () => { chrome.runtime.sendMessage({ type: "OPEN_POPUP" }).catch(() => {}); openBtn.textContent = "opening Peach…"; openBtn.disabled = true; // openPopup() can't always open programmatically (browser/version/gesture // limits); reset so the user can retry or click the toolbar icon. setTimeout(() => { if (openBtn.isConnected) { openBtn.textContent = "Open Peach to unlock"; openBtn.disabled = false; } }, 2500); }); wrap.append(head, sub, openBtn); container.appendChild(wrap); } function handleKeyDown(e: KeyboardEvent) { // PCH-01-007: reject page-synthesised KeyboardEvents — only genuine // keystrokes may drive selection/fill. A page hosting an open selector could // otherwise dispatch ArrowDown/Enter at `document` to fill without user intent. if (!e.isTrusted) return; if (options.locked) { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter" || currentMode === "identity-helper" || currentMode === "edit") { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)"); const count = allEntryEls.length; const activeElement = shadow.activeElement ?? document.activeElement; if (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); // Type-to-search from the silver platter. A printable single-character // keystroke (no modifier) seeds the search query and switches into the // list view, so the user can pivot to a different entry without first // clicking the search icon. Capture phase so the anchor input does not // receive the character before we hijack it. const silverPlatterTypeHandler = (e: KeyboardEvent) => { if (!e.isTrusted) return; // PCH-01-007: ignore page-synthesised keys if (currentMode !== "silver-platter") return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key.length !== 1) return; e.preventDefault(); e.stopImmediatePropagation(); query = e.key; // MV-2: type-to-search is an explicit gesture — load the vault-wide list. void ensureSearchableLoaded(renderList); renderList(); }; document.addEventListener("keydown", silverPlatterTypeHandler, true); cleanupHooks.push(() => document.removeEventListener("keydown", silverPlatterTypeHandler, true)); const identityHelperTypeHandler = (e: KeyboardEvent) => { if (!e.isTrusted) return; // PCH-01-007: ignore page-synthesised keys if (currentMode !== "identity-helper") return; if (e.ctrlKey || e.metaKey || e.altKey) return; const active = (shadow.activeElement ?? document.activeElement) as Element | null; if (active === searchInput) return; if ( active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active instanceof HTMLElement && active.isContentEditable) ) { return; } if (e.key.length !== 1) return; e.preventDefault(); e.stopImmediatePropagation(); query = e.key; renderIdentityHelper(); }; document.addEventListener("keydown", identityHelperTypeHandler, true); cleanupHooks.push(() => document.removeEventListener("keydown", identityHelperTypeHandler, true)); (modalAncestor ?? document.body).appendChild(host); // position:fixed normally resolves to the viewport, but an ancestor with // transform / filter / backdrop-filter / perspective / contain / will-change // establishes a containing block, so "fixed" then resolves to that ancestor's // padding box instead. When the host is mounted inside such an ancestor (a // modal) we subtract its border-box origin so the popup still lands at the // field's true viewport position. At the document root there is no such // ancestor and the offset is zero. function establishesFixedContainingBlock(s: CSSStyleDeclaration): boolean { const has = (p: string) => { const v = s.getPropertyValue(p); return v !== "" && v !== "none"; }; return ( has("transform") || has("perspective") || has("translate") || has("rotate") || has("scale") || has("filter") || has("backdrop-filter") || has("-webkit-backdrop-filter") || /\b(transform|perspective|filter)\b/.test(s.getPropertyValue("will-change")) || /\b(paint|layout|strict|content)\b/.test(s.getPropertyValue("contain")) ); } function containingBlockOffset(): { dx: number; dy: number } { let el: HTMLElement | null = host.parentElement; while (el && el !== document.documentElement) { const s = getComputedStyle(el); if (establishesFixedContainingBlock(s)) { const r = el.getBoundingClientRect(); return { dx: r.left + (parseFloat(s.borderLeftWidth) || 0), dy: r.top + (parseFloat(s.borderTopWidth) || 0), }; } el = el.parentElement; } return { dx: 0, dy: 0 }; } function updatePosition() { const r = anchorEl.getBoundingClientRect(); const { dx, dy } = containingBlockOffset(); host.style.top = `${r.bottom - dy + 5}px`; host.style.left = `${r.left - dx}px`; host.style.width = `${popupWidth}px`; } const stopObserving = observeAnchorPosition(anchorEl, updatePosition); updatePosition(); // observeAnchorPosition listens on window scroll, but a modal with its own // internal scroll container doesn't move the window — track the modal's scroll // too so the popup follows the field inside it. if (modalAncestor) { modalAncestor.addEventListener("scroll", updatePosition, { passive: true }); cleanupHooks.push(() => modalAncestor.removeEventListener("scroll", updatePosition)); } // Dismiss when the anchor leaves the DOM. A modal closing (or an SPA route // swap) removes the field without firing blur/click, so none of the normal // dismiss triggers fire and the popup would otherwise linger over nothing. const removalObserver = new MutationObserver(() => { if (!document.contains(anchorEl)) dismiss(); }); removalObserver.observe(document.body, { childList: true, subtree: true }); cleanupHooks.push(() => removalObserver.disconnect()); if (options.locked) { renderLocked(); } else if (options.isSignup && options.savedIdentities) { // State 10: identity helper — signup form detected, show derived // identity list (emails then usernames) instead of vault entry list. renderIdentityHelper(); } else if (entries.length === 1 && !options.isSignup) { // Silver platter (State 1): single domain match → focused fill view. // Multi-match goes through renderList instead. 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); for (const cleanup of cleanupHooks) { try { cleanup(); } catch { /* defensive — one hook must not block others */ } } 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 function showTOTPSelector( anchorEl: HTMLInputElement, entries: SelectorEntry[], onFill: (id: string) => void, options: { domain?: string; onDismiss?: () => void } = {} ): () => void { const host = document.createElement("div"); host.style.cssText = "position: absolute; z-index: 2147483647;"; host.style.setProperty("--peach-accent", accentHex); 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`; const popupWidth = Math.min(Math.max(rect.width, 300), 360); host.style.width = `${popupWidth}px`; const container = shadow.appendChild(document.createElement("div")); container.className = "peach-inline-popup"; container.setAttribute("data-theme", popupTheme); const domain = options.domain ?? location.hostname.replace(/^www\./, ""); const current = entries.filter((entry) => entry.hasTotp); let focusedIndex = current.length === 1 ? 0 : -1; 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 = `${popupWidth}px`; } const stopObserving = observeAnchorPosition(anchorEl, updatePosition); function choose(entry: SelectorEntry) { onFill(entry.id); dismiss(); } function render() { container.innerHTML = ""; const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const headText = document.createElement("div"); headText.className = "domain-header-text"; const eyebrow = document.createElement("div"); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "2FA code"; const domName = document.createElement("div"); domName.className = "domain-name"; domName.textContent = domain; headText.append(eyebrow, domName); domHdr.appendChild(headText); container.appendChild(domHdr); if (current.length === 1) { const entry = current[0]!; const platter = document.createElement("div"); platter.className = "silver-platter"; const title = document.createElement("div"); title.className = "sp-title"; title.textContent = entry.title; platter.appendChild(title); const sub = document.createElement("div"); sub.className = "sp-username"; sub.textContent = entry.username ?? entry.email ?? entry.url ?? ""; platter.appendChild(sub); const fill = document.createElement("button"); fill.className = "sp-fill-btn"; fill.type = "button"; fill.textContent = "Fill 2FA code"; fill.addEventListener("click", () => choose(entry)); platter.appendChild(fill); container.appendChild(platter); requestAnimationFrame(() => fill.focus({ preventScroll: true })); return; } const results = document.createElement("div"); results.className = "results"; container.appendChild(results); for (let i = 0; i < current.length; i++) { const entry = current[i]!; const row = document.createElement("div"); row.className = `entry${i === focusedIndex ? " focused" : ""}`; row.dataset.index = String(i); row.setAttribute("tabindex", "-1"); const icon = document.createElement("div"); icon.className = "entry-icon"; icon.innerHTML = keySvgStr(); row.appendChild(icon); const info = document.createElement("div"); info.className = "entry-info"; const title = document.createElement("div"); title.className = "entry-title"; title.textContent = entry.title; const sub = document.createElement("div"); sub.className = "entry-sub"; sub.textContent = entry.username ?? entry.email ?? entry.url ?? ""; info.append(title, sub); row.appendChild(info); const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "Fill"; row.appendChild(badge); row.addEventListener("click", () => choose(entry)); results.appendChild(row); } } function setFocused(index: number) { focusedIndex = index; container.querySelectorAll(".entry").forEach((el, i) => { el.classList.toggle("focused", i === focusedIndex); if (i === focusedIndex) (el as HTMLElement).scrollIntoView({ block: "nearest" }); }); } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; } if (current.length === 0) return; if (e.key === "Enter") { e.preventDefault(); choose(current[Math.max(0, focusedIndex)]!); return; } if (current.length === 1) return; if (e.key === "ArrowDown") { e.preventDefault(); setFocused(focusedIndex < current.length - 1 ? focusedIndex + 1 : 0); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : current.length - 1); } } let exitScheduled = false; function dismiss(userAction = false) { if (exitScheduled) return; exitScheduled = true; stopObserving(); document.removeEventListener("click", outsideClick, { capture: true }); document.removeEventListener("keydown", handleKeyDown); 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); } document.addEventListener("keydown", handleKeyDown); document.body.appendChild(host); render(); 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 card payload from the SW. */ onFill: (entry: import("../shared/types").VaultEntry) => void; /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */ onDismiss?: () => void; } /** * State 11 — passkey Pro gate. Shown when a free user triggers a WebAuthn * passkey create or get. Renders as a fixed-position floating popup (no * field anchor needed — passkeys don't require a focused input). Uses the * same theme tokens and entrance animation as all other inline popup states. */ export function showPasskeyProGate(): void { const domain = location.hostname.replace(/^www\./, ""); const host = document.createElement("div"); host.setAttribute("data-peach-passkey-gate", "1"); host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;"; const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` :host { all: initial; } .gate-wrap { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 280px; pointer-events: auto; animation: g-in 220ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes g-in { from { opacity:0; transform: translate(-50%,-52%); } to { opacity:1; transform: translate(-50%,-50%); } } .gate-wrap.is-exiting { animation: g-out 130ms ease-in forwards; } @keyframes g-out { to { opacity:0; transform: translate(-50%,-52%); } } .gate { background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"}; border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"}; border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.75); padding: 16px; font-family: Inter, sans-serif; } .gate-header { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"}; } .gate-domain { font-size: 12px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"}; font-family: "IBM Plex Mono", monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gate-title { font-size: 14px; font-weight: 500; color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"}; margin-bottom: 6px; } .gate-subtitle { font-size: 12px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.45)"}; line-height: 1.5; margin-bottom: 14px; } .gate-btn { display: block; width: 100%; padding: 9px 0; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-family: Inter, sans-serif; font-weight: 500; transition: opacity 100ms; margin-bottom: 7px; text-align: center; } .gate-btn:last-child { margin-bottom: 0; } .gate-btn--accent { background: ${accentHex}; color: #fff; } .gate-btn--accent:hover { opacity: 0.88; } .gate-btn--ghost { background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"}; color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.45)"}; } .gate-btn--ghost:hover { opacity: 0.75; } .backdrop { position: fixed; inset: 0; pointer-events: auto; } `; shadow.appendChild(style); const backdrop = document.createElement("div"); backdrop.className = "backdrop"; shadow.appendChild(backdrop); const wrap = document.createElement("div"); wrap.className = "gate-wrap"; shadow.appendChild(wrap); const gate = document.createElement("div"); gate.className = "gate"; wrap.appendChild(gate); // Header: peach icon + domain const header = document.createElement("div"); header.className = "gate-header"; const icon = document.createElement("span"); icon.innerHTML = peachIconSvg(); const domainEl = document.createElement("span"); domainEl.className = "gate-domain"; domainEl.textContent = domain; header.append(icon, domainEl); gate.appendChild(header); const title = document.createElement("div"); title.className = "gate-title"; title.textContent = "Passkeys are part of Peach Pro."; gate.appendChild(title); const subtitle = document.createElement("div"); subtitle.className = "gate-subtitle"; subtitle.textContent = "Store and use passkeys across all your devices, securely."; gate.appendChild(subtitle); const upgradeBtn = document.createElement("button"); upgradeBtn.className = "gate-btn gate-btn--accent"; upgradeBtn.type = "button"; upgradeBtn.textContent = "Upgrade to Pro"; gate.appendChild(upgradeBtn); const laterBtn = document.createElement("button"); laterBtn.className = "gate-btn gate-btn--ghost"; laterBtn.type = "button"; laterBtn.textContent = "Not right now"; gate.appendChild(laterBtn); function dismiss() { wrap.classList.add("is-exiting"); setTimeout(() => host.remove(), 130); } upgradeBtn.addEventListener("click", () => { chrome.runtime.sendMessage({ type: "OPEN_PRO_GATE", payload: { feature: "passkeys" } }).catch(() => {}); dismiss(); }); laterBtn.addEventListener("click", dismiss); backdrop.addEventListener("click", dismiss); document.addEventListener("keydown", function onEsc(e: KeyboardEvent) { if (e.key === "Escape") { dismiss(); document.removeEventListener("keydown", onEsc); } }); document.body.appendChild(host); } /** * State 12 — passkey registration offer. Shown on Firefox when the MAIN-world * detector sees the page invoke navigator.credentials.create(); content/index.ts * checks vault + decline state then calls this. "Save passkey to Peach" * initiates the popup-context credential flow; "Not now" records a 30-day * decline for the domain so we don't re-show on every page reload. */ export function showPasskeyRegistrationOffer(args: { rpId: string; rpName?: string; userName?: string; userDisplayName?: string; }): void { const domain = location.hostname.replace(/^www\./, ""); const displayName = args.rpName || domain; const host = document.createElement("div"); host.setAttribute("data-peach-passkey-offer", "1"); host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483645;pointer-events:none;"; const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` :host { all: initial; } .offer-wrap { position: fixed; top: 16px; right: 16px; width: 320px; pointer-events: auto; animation: o-in 220ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes o-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } .offer-wrap.is-exiting { animation: o-out 130ms ease-in forwards; } @keyframes o-out { to { opacity: 0; transform: translateY(-6px); } } .offer { background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"}; border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"}; border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.55); padding: 14px 14px 12px; font-family: Inter, sans-serif; position: relative; } .offer-close { position: absolute; top: 8px; right: 8px; width: 18px; height: 18px; border: none; background: transparent; color: ${popupTheme === "light" ? "rgba(0,0,0,0.4)" : "rgba(240,232,216,0.4)"}; cursor: pointer; font-size: 14px; line-height: 1; padding: 0; } .offer-close:hover { color: ${popupTheme === "light" ? "rgba(0,0,0,0.7)" : "rgba(240,232,216,0.7)"}; } .offer-header { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"}; } .offer-domain { font-size: 12px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"}; font-family: "IBM Plex Mono", monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .offer-title { font-size: 13px; font-weight: 500; color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"}; margin-bottom: 4px; } .offer-subtitle { font-size: 11px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.50)"}; line-height: 1.45; margin-bottom: 12px; } .offer-btn { display: block; width: 100%; padding: 8px 0; border-radius: 7px; border: none; cursor: pointer; font-size: 12px; font-family: Inter, sans-serif; font-weight: 500; transition: opacity 100ms; margin-bottom: 6px; text-align: center; } .offer-btn:last-child { margin-bottom: 0; } .offer-btn--accent { background: ${accentHex}; color: #fff; } .offer-btn--accent:hover { opacity: 0.88; } .offer-btn--ghost { background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"}; color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.50)"}; } .offer-btn--ghost:hover { opacity: 0.75; } `; shadow.appendChild(style); const wrap = document.createElement("div"); wrap.className = "offer-wrap"; shadow.appendChild(wrap); const offer = document.createElement("div"); offer.className = "offer"; wrap.appendChild(offer); const closeBtn = document.createElement("button"); closeBtn.className = "offer-close"; closeBtn.type = "button"; closeBtn.setAttribute("aria-label", "Dismiss"); closeBtn.textContent = "✕"; offer.appendChild(closeBtn); const header = document.createElement("div"); header.className = "offer-header"; const icon = document.createElement("span"); icon.innerHTML = peachIconSvg(); const domainEl = document.createElement("span"); domainEl.className = "offer-domain"; domainEl.textContent = domain; header.append(icon, domainEl); offer.appendChild(header); const title = document.createElement("div"); title.className = "offer-title"; title.textContent = "This site supports passkeys."; offer.appendChild(title); const subtitle = document.createElement("div"); subtitle.className = "offer-subtitle"; subtitle.textContent = "Save one to Peach for faster, passwordless sign-in."; offer.appendChild(subtitle); const saveBtn = document.createElement("button"); saveBtn.className = "offer-btn offer-btn--accent"; saveBtn.type = "button"; saveBtn.textContent = "Save passkey to Peach"; offer.appendChild(saveBtn); const laterBtn = document.createElement("button"); laterBtn.className = "offer-btn offer-btn--ghost"; laterBtn.type = "button"; laterBtn.textContent = "Not now"; offer.appendChild(laterBtn); function dismiss(decline: boolean) { if (decline) { chrome.runtime.sendMessage({ type: "PASSKEY_OFFER_DECLINE", payload: { domain }, }).catch(() => {}); } wrap.classList.add("is-exiting"); setTimeout(() => host.remove(), 130); } saveBtn.addEventListener("click", () => { chrome.runtime.sendMessage({ type: "INITIATE_FIREFOX_PASSKEY_CREATE", payload: { rpId: args.rpId, rpName: args.rpName ?? displayName, userName: args.userName ?? "user", userDisplayName: args.userDisplayName, }, }).catch(() => {}); dismiss(false); }); laterBtn.addEventListener("click", () => dismiss(true)); closeBtn.addEventListener("click", () => dismiss(true)); document.body.appendChild(host); } /** * State 11 — passkey available indicator. Shown when the user lands on a * domain for which they already have a stored passkey. Tells them where to * find it (the entry already shows in the vault list); the "Sign in with * passkey" button opens the popup so they can use the platform authenticator * UI directly. On Chrome, the existing webAuthenticationProxy flow handles * the actual sign-in transparently, so this banner is informational only. */ export function showPasskeyAvailable(args: { rpId: string; userName: string; fromBrowser?: "chrome" | "firefox"; currentBrowser?: "chrome" | "firefox" | "unknown"; }): void { const domain = location.hostname.replace(/^www\./, ""); const crossBrowser = args.fromBrowser && args.currentBrowser && args.currentBrowser !== "unknown" && args.fromBrowser !== args.currentBrowser; const host = document.createElement("div"); host.setAttribute("data-peach-passkey-available", "1"); host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483644;pointer-events:none;"; const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` :host { all: initial; } .avail-wrap { position: fixed; top: 16px; right: 16px; width: 320px; pointer-events: auto; animation: a-in 220ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes a-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } .avail-wrap.is-exiting { animation: a-out 130ms ease-in forwards; } @keyframes a-out { to { opacity: 0; transform: translateY(-6px); } } .avail { background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"}; border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"}; border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.55); padding: 14px 14px 12px; font-family: Inter, sans-serif; position: relative; } .avail-close { position: absolute; top: 8px; right: 8px; width: 18px; height: 18px; border: none; background: transparent; color: ${popupTheme === "light" ? "rgba(0,0,0,0.4)" : "rgba(240,232,216,0.4)"}; cursor: pointer; font-size: 14px; line-height: 1; padding: 0; } .avail-close:hover { color: ${popupTheme === "light" ? "rgba(0,0,0,0.7)" : "rgba(240,232,216,0.7)"}; } .avail-header { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"}; } .avail-domain { font-size: 12px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"}; font-family: "IBM Plex Mono", monospace; } .avail-label { font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: ${accentHex}; margin-bottom: 4px; } .avail-user { font-size: 13px; font-weight: 500; color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"}; margin-bottom: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .avail-cross { font-size: 11px; color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.50)"}; line-height: 1.45; margin-bottom: 12px; padding: 8px; background: ${popupTheme === "light" ? "rgba(0,0,0,0.04)" : "rgba(240,232,216,0.04)"}; border-radius: 6px; } .avail-btn { display: block; width: 100%; padding: 8px 0; border-radius: 7px; border: none; cursor: pointer; font-size: 12px; font-family: Inter, sans-serif; font-weight: 500; transition: opacity 100ms; margin-bottom: 6px; text-align: center; } .avail-btn:last-child { margin-bottom: 0; } .avail-btn--accent { background: ${accentHex}; color: #fff; } .avail-btn--accent:hover { opacity: 0.88; } .avail-btn--ghost { background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"}; color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.50)"}; } .avail-btn--ghost:hover { opacity: 0.75; } `; shadow.appendChild(style); const wrap = document.createElement("div"); wrap.className = "avail-wrap"; shadow.appendChild(wrap); const avail = document.createElement("div"); avail.className = "avail"; wrap.appendChild(avail); const closeBtn = document.createElement("button"); closeBtn.className = "avail-close"; closeBtn.type = "button"; closeBtn.setAttribute("aria-label", "Dismiss"); closeBtn.textContent = "✕"; avail.appendChild(closeBtn); const header = document.createElement("div"); header.className = "avail-header"; const icon = document.createElement("span"); icon.innerHTML = peachIconSvg(); const domainEl = document.createElement("span"); domainEl.className = "avail-domain"; domainEl.textContent = domain; header.append(icon, domainEl); avail.appendChild(header); const label = document.createElement("div"); label.className = "avail-label"; label.textContent = "PASSKEY"; avail.appendChild(label); const user = document.createElement("div"); user.className = "avail-user"; user.textContent = args.userName; avail.appendChild(user); if (crossBrowser) { const cross = document.createElement("div"); cross.className = "avail-cross"; cross.textContent = args.fromBrowser === "chrome" ? "This passkey was created on Chrome. To use it on Firefox, re-register it on this device." : "This passkey was created on Firefox. To use it on Chrome, re-register it on this device."; avail.appendChild(cross); } const signInBtn = document.createElement("button"); signInBtn.className = "avail-btn avail-btn--accent"; signInBtn.type = "button"; signInBtn.textContent = "Sign in with passkey"; avail.appendChild(signInBtn); const passwordBtn = document.createElement("button"); passwordBtn.className = "avail-btn avail-btn--ghost"; passwordBtn.type = "button"; passwordBtn.textContent = "Use password instead"; avail.appendChild(passwordBtn); function dismiss() { wrap.classList.add("is-exiting"); setTimeout(() => host.remove(), 130); } signInBtn.addEventListener("click", () => { // On Chrome: the user clicking the page's own Sign-in button is what // triggers webAuthenticationProxy → existing background flow handles it. // Our button just dismisses since the page itself drives auth. // On Firefox: cross-browser passkeys can't be used, so we just dismiss // and surface the entry for manual reference (popup view). dismiss(); chrome.runtime.sendMessage({ type: "OPEN_PRO_GATE", payload: {} }).catch(() => {}); }); passwordBtn.addEventListener("click", dismiss); closeBtn.addEventListener("click", dismiss); document.body.appendChild(host); } /** * 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-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.min(Math.max(rect.width, 320), 400)}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", // MV-1: explicit user click to fill this card's details. payload: { id: card.id, explicitSummon: true }, }).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); } // M3/QUA-005: clipboard + background COPY both failed — show user. function flashCopyError(b: HTMLButtonElement) { const original = b.textContent ?? ""; b.textContent = "Blocked"; b.classList.add("flashed"); setTimeout(() => { b.classList.remove("flashed"); if (b.isConnected) b.textContent = original; }, 1200); } async function fetchFullCard(): Promise { const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", // MV-1: explicit user action on a card the user opened in Peach's UI. payload: { id: card.id, explicitSummon: true }, }).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 number"; 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 (err) { console.warn("[Peach] clipboard write denied:", err); } const resp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cardNumber, label: "Card number", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; if (alreadyWritten || resp?.ok) flashBtn(copyNum); else flashCopyError(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 (err) { console.warn("[Peach] clipboard write denied:", err); } const resp = await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cvc, label: "CVC", alreadyWritten }, }).catch(() => null) as { ok?: boolean } | null; if (alreadyWritten || resp?.ok) flashBtn(copyCvc); else flashCopyError(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`; } 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; }