/** * 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 SavedIdentity } from "../shared/types"; import { generatePassword, generateSmartPassword } from "../shared/generate"; import { generateTOTP } from "../crypto/totp"; import { detectPasswordPolicy } from "./policy"; let accentHex = "#E8956D"; let popupTheme: "dark" | "light" = "dark"; chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string; theme?: string } } | undefined) => { const data = resp?.data; const name = data?.accentColor ?? "peach"; accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D"; popupTheme = data?.theme === "light" ? "light" : "dark"; }); // 8-digit hex alpha helpers for the accent-derived shadow tokens. // `#E8956D` + alpha `28` → `#E8956D28`. The browser parses the trailing // two hex digits as the alpha channel. function withAlpha(hex: string, alpha: string): string { // Defensive: only 6-digit hex inputs are supported here. ACCENT_COLORS // always exposes 6-digit hex, so this is a guard against future changes // shipping shorthand colors that would silently break the var injection. return /^#[0-9a-f]{6}$/i.test(hex) ? hex + alpha : hex; } export interface SelectorEntry { id: string; title: string; username?: string; email?: string; url?: string; hasTotp?: boolean; /** True when the entry also has a passkey credential attached. */ hasPasskey?: boolean; } interface SelectorOptions { domain?: string; searchableEntries?: SelectorEntry[]; /** True when shown on a signup form — changes header label and entry-click behaviour */ isSignup?: boolean; /** * 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) => void; /** * When true, show an inline unlock form instead of the entries list. * Used when the vault is locked so the user can unlock without opening * the popup. */ locked?: boolean; /** * Called after a successful inline unlock — the caller should re-query * entries and re-open the selector with real data. */ onUnlocked?: () => void; /** * Called when the user explicitly dismisses the selector (Escape, Tab, or * click outside) — NOT when an entry is selected or on auto-close. * Used by the caller to suppress spurious reopens on the same field. */ onDismiss?: () => void; } function getDomainFromUrl(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; } } function keySvgStr(): string { return ``; } function plusSvgStr(): string { return ``; } function peachIconSvg(): string { // Peach body is always brand-peach (#D96830), not `accentHex` — a sage // or sky accent must not turn the autofill icon green or blue. Accent // color still tints surrounding glow/hover halos below. return ``; } function genSvgStr(): string { return ``; } function checkSvgStr(): string { return ``; } function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } { const { hostname, origin } = location; const parts = hostname.split("."); const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso", "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth", "oidc", "federation", "saml", "cas", "ldap"]; const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase()); const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!; const title = rootName.charAt(0).toUpperCase() + rootName.slice(1); return { origin, title, loginSubdomain }; } /** * Shows a bottom-of-page toast after filling credentials when the entry has * never matched the current site — offering to add the current origin to the * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s. */ export function showRememberSiteToast( pageDomain: string, entryId: string, ): void { const host = document.createElement("div"); host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;"; host.style.setProperty("--peach-accent", accentHex); const shadow = host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = ` .bar { display:flex;align-items:center;gap:10px;padding:10px 14px; background:#131109;border:1px solid rgba(240,232,216,0.09); border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75); font-family:Inter,sans-serif;white-space:nowrap; animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both; } @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} } .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; } .yes { padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px; background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif; } .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); } .no { padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px; background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif; } `; shadow.appendChild(style); const bar = document.createElement("div"); bar.className = "bar"; const txt = document.createElement("span"); txt.className = "txt"; txt.textContent = `Remember ${pageDomain} for this entry?`; const yesBtn = document.createElement("button"); yesBtn.className = "yes"; yesBtn.textContent = "Remember"; yesBtn.addEventListener("click", async () => { await chrome.runtime.sendMessage({ type: "ADD_ADDITIONAL_URL", payload: { id: entryId, url: location.origin }, }); host.remove(); }); const noBtn = document.createElement("button"); noBtn.className = "no"; noBtn.textContent = "Not now"; noBtn.addEventListener("click", () => host.remove()); bar.append(txt, yesBtn, noBtn); shadow.appendChild(bar); document.body.appendChild(host); setTimeout(() => host.remove(), 9000); } function generatePasswordForSite(policy: PasswordPolicy = {}): string { return generateSmartPassword(policy); } 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 ?? {}; let length = 64; if (policy.minLength) length = Math.max(length, policy.minLength); if (policy.maxLength) length = Math.min(length, policy.maxLength); 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 (!isNaN(n)) { length = Math.max(8, Math.min(128, 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-accent-dim, --peach-glow, --peach-border-accent) // are identical in both themes — only surface tokens differ. See // Projects.md §4.14: hardcoded hex inside this stylesheet breaks the // theme switch — only literal brand-peach in peachIconSvg may stay raw. const SELECTOR_STYLES = ` :host { all: initial; } *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; } .peach-inline-popup[data-theme="dark"] { --peach-bg: #1a1410; --peach-bg-card: #221c16; --peach-text: #f5f0ea; --peach-text-dim: #8a7a6a; --peach-text-muted: #4a3a2a; --peach-border: rgba(255,255,255,0.07); --peach-input-bg: rgba(255,255,255,0.05); --peach-input-border: rgba(255,255,255,0.08); } .peach-inline-popup[data-theme="light"] { --peach-bg: #fdf8f4; --peach-bg-card: #f5ede4; --peach-text: #1c1310; --peach-text-dim: #6b5a4a; --peach-text-muted: #a89080; --peach-border: rgba(0,0,0,0.08); --peach-input-bg: rgba(0,0,0,0.04); --peach-input-border: rgba(0,0,0,0.10); } .peach-inline-popup { background: var(--peach-bg); border: 1px solid var(--peach-border-accent, var(--peach-border)); border-radius: 14px; overflow: hidden; box-shadow: 0 8px 40px var(--peach-glow, transparent), 0 2px 12px rgba(0,0,0,0.5); font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; animation: peach-enter 200ms cubic-bezier(0.16,1,0.3,1) forwards; 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-add-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 { 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 { transform: scale(0.96); } /* Search */ .search-wrap { padding: 9px 12px 7px; border-bottom: 1px solid var(--peach-border); } .search { width: 100%; background: var(--peach-input-bg); border: 1px solid var(--peach-input-border); border-radius: 7px; color: var(--peach-text); padding: 7px 10px; font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace; transition: border-color 120ms, box-shadow 120ms; } .search:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); } .search::placeholder { color: var(--peach-text-muted); } /* Results */ .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; } .results::-webkit-scrollbar { display: none; } /* Entry rows */ .entry { display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--peach-border); transition: background 70ms; position: relative; outline: none; } .entry:last-child { border-bottom: none; } .entry:hover { background: color-mix(in srgb, var(--peach-text) 4%, transparent); } .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); } .entry.focused::before { content: ""; position: absolute; left: 0; top: 3px; bottom: 3px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent); } .entry-icon { width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0; background: var(--peach-input-bg); border: 1px solid var(--peach-border); display: flex; align-items: center; justify-content: center; overflow: hidden; margin-right: 10px; } .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; } .entry-icon.is-new { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent); color: color-mix(in srgb, var(--peach-accent) 80%, transparent); } .entry-info { flex: 1; min-width: 0; } .entry-title { font-size: 12.5px; color: var(--peach-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; } .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; } .entry-sub { font-size: 10px; color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } .totp-badge { font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px; border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent); color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 8px; } .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); } .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 { display: flex; gap: 4px; justify-content: space-between; align-items: center; margin-top: 4px; } .sp-action-btn { flex: 1; height: 28px; border: none; border-radius: 6px; background: transparent; color: var(--peach-text-dim); font-size: 12px; cursor: pointer; transition: background 120ms ease, color 120ms ease; font-family: Inter, sans-serif; letter-spacing: 0.04em; display: inline-flex; align-items: center; justify-content: center; gap: 4px; 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-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-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; mask-image: linear-gradient(to bottom, black calc(100% - 18px), transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 18px), transparent 100%); } .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"); host.style.cssText = "position: absolute; z-index: 2147483647;"; host.style.setProperty("--peach-accent", accentHex); host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28")); host.style.setProperty("--peach-glow", withAlpha(accentHex, "18")); host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30")); const shadow = host.attachShadow({ mode: "closed" }); const style = shadow.appendChild(document.createElement("style")); style.textContent = SELECTOR_STYLES; const rect = anchorEl.getBoundingClientRect(); host.style.top = `${rect.bottom + window.scrollY + 5}px`; host.style.left = `${rect.left + window.scrollX}px`; // Spec §1.1: 320px width. The Math.max() floor preserves usability on // wide single-input fields (e.g. centered email input on signup pages). host.style.width = `${Math.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); const searchableEntries = options.searchableEntries ?? []; const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl(); let query = ""; let focusedIndex = -1; let currentMode: "list" | "create" | "success" | "silver-platter" = "list"; let searchInput: HTMLInputElement | null = null; // 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() { container.innerHTML = ""; const identities = options.savedIdentities ?? []; // Header: "NEW ACCOUNT" + domain name const hdr = container.appendChild(document.createElement("div")); hdr.className = "domain-header"; const eyebrow = hdr.appendChild(document.createElement("div")); eyebrow.className = "domain-eyebrow"; eyebrow.textContent = "NEW ACCOUNT"; const domName = hdr.appendChild(document.createElement("div")); domName.className = "domain-name"; domName.textContent = options.domain ?? location.hostname.replace(/^www\./, ""); // Identity rows const list = container.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", () => { options.onFillFields?.(id.value, ""); renderGenerator(id.value); }); } // Divider before "Use a different email…" if (identities.length > 0) { container.appendChild(Object.assign(document.createElement("div"), { className: "divider" })); } // "Use a different email…" expandable row const altRow = container.appendChild(document.createElement("div")); altRow.className = "identity-alt-row"; altRow.textContent = "+ Use a different email…"; 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 = "email"; altInput.placeholder = "your@email.com"; altInput.setAttribute("autocomplete", "off"); const altConfirm = altInputWrap.appendChild(document.createElement("button")); altConfirm.className = "identity-alt-confirm"; altConfirm.type = "button"; altConfirm.textContent = "✓"; function fillCustomEmail() { const v = altInput.value.trim(); if (!v) return; options.onFillFields?.(v, ""); renderGenerator(v); } altRow.addEventListener("click", () => { altRow.style.display = "none"; altInputWrap.classList.add("open"); altInput.focus(); }); altConfirm.addEventListener("click", fillCustomEmail); altInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); fillCustomEmail(); } }); // 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. if ((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"); escapeRow.addEventListener("click", () => renderList()); escapeRow.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); renderList(); } }); } } function renderGenerator(filledEmail: 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 email row 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 = filledEmail; 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", () => { if (!currentPw) return; options.onFillFields?.("", currentPw); renderSaveOffer(filledEmail, currentPw); }); } function renderSaveOffer(filledEmail: string, filledPassword: 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); 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 { const res = await chrome.runtime.sendMessage({ type: "ADD_ENTRY", payload: { title: sTitle, url: sOrigin, username: filledEmail, 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; 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 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(); }); domHdr.appendChild(addBtn); } container.appendChild(domHdr); // Search — always show so user can find entries beyond the domain matches const sw = document.createElement("div"); sw.className = "search-wrap"; searchInput = document.createElement("input"); searchInput.className = "search"; searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…"; searchInput.value = query; searchInput.addEventListener("input", () => { query = searchInput?.value ?? ""; renderList(); searchInput?.focus(); searchInput?.setSelectionRange(query.length, query.length); }); sw.appendChild(searchInput); container.appendChild(sw); // Results const results = document.createElement("div"); results.className = "results"; container.appendChild(results); if (current.length === 0 && entries.length > 0) { // Show a hint but don't render empty entries } for (let i = 0; i < current.length; i++) { const e = current[i]!; const entryEl = document.createElement("div"); entryEl.className = "entry"; entryEl.dataset.id = e.id; entryEl.dataset.index = String(i); entryEl.setAttribute("tabindex", "-1"); entryEl.appendChild(makeEntryIcon(e.url)); const info = document.createElement("div"); info.className = "entry-info"; const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title; const subEl = document.createElement("div"); subEl.className = "entry-sub"; const domain = e.url ? getDomainFromUrl(e.url) : ""; subEl.textContent = domain || e.username || ""; info.append(titleEl, subEl); entryEl.appendChild(info); if (e.hasTotp) { const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP"; entryEl.appendChild(badge); } 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) { // Signup mode: clicking a saved identity fills the username field options.onFillFields(e.username ?? "", ""); dismiss(); } else { onSelect(e.id); dismiss(); } }); results.appendChild(entryEl); } // Divider before "New entry" if (current.length > 0) { results.appendChild(Object.assign(document.createElement("div"), { className: "divider" })); } // "New entry" row — always present const newEl = document.createElement("div"); newEl.className = "entry"; newEl.setAttribute("tabindex", "-1"); newEl.appendChild(makeEntryIcon(undefined, true)); const newInfo = document.createElement("div"); newInfo.className = "entry-info"; const newTitle = document.createElement("div"); newTitle.className = "entry-title is-new"; newTitle.textContent = `New entry for ${sTitle}`; const newSub = document.createElement("div"); newSub.className = "entry-sub"; newSub.textContent = sOrigin; newInfo.append(newTitle, newSub); newEl.appendChild(newInfo); newEl.addEventListener("click", showCreateForm); results.appendChild(newEl); // Focus search if present if (searchInput) { requestAnimationFrame(() => searchInput?.focus()); } } function setFocused(idx: number) { const all = container.querySelectorAll(".entry"); all.forEach((el, i) => el.classList.toggle("focused", i === idx)); focusedIndex = idx; if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" }); } 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"; function pick(value: string) { inp.value = value; list.style.display = "none"; suppressNextFocus = true; inp.focus(); } 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) { list.style.display = "none"; 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(); list.style.display = "none"; suppressNextFocus = true; inp.focus(); } }); row.addEventListener("blur", (e) => { const rt = (e as FocusEvent).relatedTarget as Node | null; if (rt && (rt === inp || list.contains(rt))) return; setTimeout(() => { list.style.display = "none"; }, 150); }); } list.style.display = ""; } 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(); list.style.display = "none"; } }); inp.addEventListener("blur", (e) => { const rt = (e as FocusEvent).relatedTarget as Node | null; if (rt && list.contains(rt)) return; setTimeout(() => { list.style.display = "none"; }, 150); }); } 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 errMsg = (res as { error?: string }).error === "vault locked" ? "Vault locked — open Peach to save" : "Could not save entry"; showFormError(errMsg); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; } } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; } }); body.appendChild(saveBtn); body.appendChild(errEl); wrap.append(head, body); container.appendChild(wrap); requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); }); } function showSuccess(title: string) { currentMode = "success"; container.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "success-wrap"; const ring = document.createElement("div"); ring.className = "success-ring"; ring.innerHTML = checkSvgStr(); const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`; const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin; wrap.append(ring, t, s); container.appendChild(wrap); setTimeout(() => dismiss(), 1600); } function flashActionBtn(btn: HTMLButtonElement) { btn.classList.add("flashed"); setTimeout(() => btn.classList.remove("flashed"), 700); } function renderSilverPlatter(entry: SelectorEntry) { container.innerHTML = ""; searchInput = null; focusedIndex = -1; currentMode = "silver-platter"; const domHdr = document.createElement("div"); domHdr.className = "domain-header"; const 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 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(); }); domHdr.appendChild(addBtn); } 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 actions = document.createElement("div"); actions.className = "sp-actions"; if (entry.email) { const copyEmail = document.createElement("button"); copyEmail.className = "sp-action-btn"; copyEmail.type = "button"; copyEmail.title = "Copy email"; copyEmail.textContent = "Copy email"; copyEmail.addEventListener("click", async (e) => { e.stopPropagation(); let alreadyWritten = false; try { await navigator.clipboard.writeText(entry.email!); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: entry.email, label: "Email", alreadyWritten }, }).catch(() => {}); flashActionBtn(copyEmail); }); actions.appendChild(copyEmail); } const copyU = document.createElement("button"); copyU.className = "sp-action-btn"; copyU.type = "button"; const copyULabel = entry.username?.includes("@") ? "Copy email" : "Copy username"; copyU.title = copyULabel; copyU.textContent = copyULabel; 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 {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: entry.username, label: entry.username.includes("@") ? "Email" : "Username", alreadyWritten }, }).catch(() => {}); flashActionBtn(copyU); }); actions.appendChild(copyU); const copyP = document.createElement("button"); copyP.className = "sp-action-btn"; copyP.type = "button"; copyP.title = "Copy password"; copyP.textContent = "Copy password"; copyP.addEventListener("click", async (e) => { e.stopPropagation(); const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id }, }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null; if (!resp?.ok || !resp.data?.password) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(resp.data.password); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: resp.data.password, label: "Password", alreadyWritten }, }).catch(() => {}); flashActionBtn(copyP); }); actions.appendChild(copyP); 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 entry to get the TOTP secret. The selector summary doesn't // carry it (and shouldn't — passing secrets only at fill/copy time keeps // the message-passing surface minimal per Projects.md §5.2). const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id }, }).catch(() => null) as { ok: boolean; data?: { totp?: string } } | null; const secret = resp?.ok ? resp.data?.totp : undefined; if (!secret) 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 refresh() { const { code, remaining } = generateTOTP(secret!); // 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)); } refresh(); trayInterval = setInterval(refresh, 1000); copyBtn.addEventListener("click", async (e) => { e.stopPropagation(); const { code } = generateTOTP(secret!); let alreadyWritten = false; try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: code, label: "TOTP", alreadyWritten }, }).catch(() => {}); copyBtn.textContent = "Copied"; 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(actions); 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) { 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([ chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", 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 } } | 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({ 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); 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); // Password input const pwInp = document.createElement("input"); pwInp.className = "locked-input"; pwInp.type = "password"; pwInp.placeholder = "master password"; pwInp.setAttribute("autocomplete", "current-password"); // Error line (hidden initially) const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none"; // Unlock button const unlockBtn = document.createElement("button"); unlockBtn.className = "locked-btn"; unlockBtn.type = "button"; unlockBtn.textContent = "unlock"; async function tryUnlock() { const pw = pwInp.value.trim(); if (!pw) { pwInp.focus(); return; } unlockBtn.disabled = true; unlockBtn.textContent = "unlocking…"; errEl.style.display = "none"; try { const res = await chrome.runtime.sendMessage({ type: "UNLOCK", payload: { password: pw }, }); if ((res as { ok?: boolean }).ok) { dismiss(); options.onUnlocked?.(); } else { errEl.textContent = "incorrect password"; errEl.style.display = "block"; pwInp.value = ""; pwInp.focus(); unlockBtn.disabled = false; unlockBtn.textContent = "unlock"; } } catch { errEl.textContent = "could not reach Peach"; errEl.style.display = "block"; unlockBtn.disabled = false; unlockBtn.textContent = "unlock"; } } pwInp.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") void tryUnlock(); }); pwInp.addEventListener("keyup", (e) => e.stopPropagation()); pwInp.addEventListener("keypress", (e) => e.stopPropagation()); unlockBtn.addEventListener("click", () => void tryUnlock()); wrap.append(head, pwInp, errEl, unlockBtn); container.appendChild(wrap); // On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.) // the page reclaims focus from our Shadow DOM input before the user can // type. Fighting over focus with a focusout→refocus loop hangs the tab. // Instead: intercept keystrokes at the document capture phase — this runs // before any page handler regardless of which element technically has // OS focus — and route them into our input. const captureKeydown = (e: KeyboardEvent) => { if (!host.isConnected) { document.removeEventListener("keydown", captureKeydown, true); return; } // Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through. if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return; // Intercept before page handlers and give our input focus. e.stopImmediatePropagation(); if (e.key === "Escape") { dismiss(true); return; } if (e.key === "Enter") { void tryUnlock(); return; } // For all other printable/editing keys, ensure our input has focus so // the browser delivers the keystroke there naturally. if (document.activeElement !== host) { pwInp.focus({ preventScroll: true }); } }; captureKeydownRef = captureKeydown; document.addEventListener("keydown", captureKeydown, true); // Best-effort focus on open — may be overridden by page focus trap, // but the capture listener above handles typing regardless. pwInp.focus({ preventScroll: true }); requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); }); } function handleKeyDown(e: KeyboardEvent) { if (options.locked) { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") { if (e.key === "Escape") { e.preventDefault(); dismiss(true); } return; } const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)"); const count = allEntryEls.length; if (document.activeElement === searchInput) { if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; } if (count === 0) return; if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return; } if (count === 0) return; switch (e.key) { case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break; case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break; case "Enter": { if (focusedIndex >= 0) { const el = allEntryEls[focusedIndex] as HTMLElement; const id = el.dataset.id; if (id) { onSelect(id); dismiss(); } else if (el.classList.contains("is-new") || !id) { // "New entry" row (last item, no data-id) showCreateForm(); } } break; } case "Escape": case "Tab": e.preventDefault(); dismiss(true); break; } } document.addEventListener("keydown", handleKeyDown); document.body.appendChild(host); function updatePosition() { const r = anchorEl.getBoundingClientRect(); host.style.top = `${r.bottom + window.scrollY + 5}px`; host.style.left = `${r.left + window.scrollX}px`; } // These bindings are referenced from renderLocked()/dismiss() — they MUST // be initialized before renderLocked() runs, or the synchronous assignment // `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ // and throws ReferenceError. The throw lands after the popup is rendered // but before showAutofillSelector returns its dismiss handle, leaving an // orphan host in the DOM (which is what caused the locked-popup stacking // and missing autofocus on every click). let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null; const stopObserving = observeAnchorPosition(anchorEl, updatePosition); if (options.locked) { renderLocked(); } else if (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); if (captureKeydownRef) { document.removeEventListener("keydown", captureKeydownRef, true); captureKeydownRef = null; } 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 interface CardSelectorEntry { id: string; title: string; cardholderName?: string; last4?: string; expMonth?: string; expYear?: string; network?: import("../shared/types").CardNetwork; } interface CardSelectorOptions { domain?: string; /** Caller-provided fill — receives the full CardEntry payload from the SW. */ onFill: (entry: import("../shared/types").VaultEntry) => void; /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */ onDismiss?: () => void; } /** * State 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-accent-dim", withAlpha(accentHex, "28")); host.style.setProperty("--peach-glow", withAlpha(accentHex, "18")); host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30")); const shadow = host.attachShadow({ mode: "closed" }); const style = shadow.appendChild(document.createElement("style")); style.textContent = SELECTOR_STYLES; const rect = anchorEl.getBoundingClientRect(); host.style.top = `${rect.bottom + window.scrollY + 5}px`; host.style.left = `${rect.left + window.scrollX}px`; host.style.width = `${Math.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", payload: { id: card.id }, }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null; if (resp?.ok && resp.data) options.onFill(resp.data); setTimeout(() => dismiss(), 400); }); fillWrap.appendChild(fillBtn); platter.appendChild(fillWrap); // Secondary actions — copy number, copy CVC, dismiss const actions = document.createElement("div"); actions.className = "sp-actions"; function flashBtn(b: HTMLButtonElement) { b.classList.add("flashed"); setTimeout(() => b.classList.remove("flashed"), 700); } async function fetchFullCard(): Promise { const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: card.id }, }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null; return resp?.ok && resp.data ? resp.data : null; } const copyNum = document.createElement("button"); copyNum.className = "sp-action-btn"; copyNum.type = "button"; copyNum.title = "Copy card number"; copyNum.textContent = "Copy 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 {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cardNumber, label: "Card number", alreadyWritten }, }).catch(() => {}); flashBtn(copyNum); }); actions.appendChild(copyNum); const copyCvc = document.createElement("button"); copyCvc.className = "sp-action-btn"; copyCvc.type = "button"; copyCvc.title = "Copy CVC"; copyCvc.textContent = "Copy CVC"; copyCvc.addEventListener("click", async (e) => { e.stopPropagation(); const full = await fetchFullCard(); if (!full?.cvc) return; let alreadyWritten = false; try { await navigator.clipboard.writeText(full.cvc); alreadyWritten = true; } catch {} await chrome.runtime.sendMessage({ type: "COPY", payload: { text: full.cvc, label: "CVC", alreadyWritten }, }).catch(() => {}); flashBtn(copyCvc); }); actions.appendChild(copyCvc); const closeBtn = document.createElement("button"); closeBtn.className = "sp-action-btn"; closeBtn.type = "button"; closeBtn.title = "Dismiss"; closeBtn.textContent = "✕"; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); dismiss(true); }); actions.appendChild(closeBtn); platter.appendChild(actions); container.appendChild(platter); document.body.appendChild(host); function updatePosition() { const r = anchorEl.getBoundingClientRect(); host.style.top = `${r.bottom + window.scrollY + 5}px`; host.style.left = `${r.left + window.scrollX}px`; } 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; }