/**
* 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 { 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;
url?: string;
hasTotp?: boolean;
}
interface SelectorOptions {
domain?: string;
searchableEntries?: SelectorEntry[];
/** True when shown on a signup form — changes header label and entry-click behaviour */
isSignup?: boolean;
/**
* 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);
}
export function observeAnchorPosition(
anchorEl: HTMLElement,
onUpdate: () => void
): () => void {
let rafId = 0;
let burstUntil = 0;
const runUpdate = () => {
rafId = 0;
onUpdate();
if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);
};
const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };
const scheduleBurst = (ms = 220) => {
burstUntil = Math.max(burstUntil, performance.now() + ms);
scheduleUpdate();
};
const resizeObserver = new ResizeObserver(() => scheduleBurst());
resizeObserver.observe(anchorEl);
if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);
const mutationObserver = new MutationObserver(() => scheduleBurst());
mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });
if (anchorEl.parentElement) {
mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });
}
const onScroll = () => scheduleUpdate();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
window.visualViewport?.addEventListener("resize", onScroll);
window.visualViewport?.addEventListener("scroll", onScroll);
anchorEl.addEventListener("focus", () => scheduleBurst());
anchorEl.addEventListener("input", () => scheduleBurst());
scheduleBurst(260);
return () => {
if (rafId) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
window.visualViewport?.removeEventListener("resize", onScroll);
window.visualViewport?.removeEventListener("scroll", onScroll);
};
}
export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-label", "Peach autofill");
button.title = "Fill with Peach";
button.innerHTML = peachIconSvg();
// position: fixed is load-bearing — not absolute. Absolute positioning
// inherits the nearest positioned ancestor's box, which on sites with
// overflow:hidden or sticky parents clips or offsets the icon. Fixed
// anchors to the viewport and escapes all parent layout.
button.style.cssText = `
position: fixed; z-index: 2147483646;
width: 26px; height: 26px; cursor: pointer;
pointer-events: auto; border: none; padding: 3px;
display: inline-flex; align-items: center; justify-content: center;
background: transparent; border-radius: 999px;
transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;
opacity: 0;
`;
button.addEventListener("mouseenter", () => {
button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;
button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;
button.style.transform = "scale(1.08)";
});
button.addEventListener("mouseleave", () => {
button.style.background = "transparent";
button.style.boxShadow = "none";
button.style.transform = "none";
});
button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });
button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });
document.body.appendChild(button);
function updatePosition() {
const rect = field.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
button.style.opacity = "0"; button.style.pointerEvents = "none";
return;
}
const sz = 26;
const inset = Math.max(5, Math.min(10, rect.height * 0.22));
const top = rect.top + rect.height / 2 - sz / 2;
// Clamp left so the icon stays fully inside the field's right edge, never
// spilling onto an adjacent sibling button.
const minLeft = rect.left + 4;
const left = Math.max(minLeft, rect.right - sz - inset);
// Hide if the field is too narrow to fit the icon cleanly (< 40px)
if (rect.width < 40) {
button.style.opacity = "0"; button.style.pointerEvents = "none";
return;
}
button.style.top = `${top}px`;
button.style.left = `${left}px`;
button.style.opacity = "1"; button.style.pointerEvents = "auto";
}
updatePosition();
requestAnimationFrame(() => { button.style.opacity = "1"; });
const stopObserving = observeAnchorPosition(field, updatePosition);
return () => { stopObserving(); button.remove(); };
}
// All theme-dependent values resolve through tokens scoped to
// .peach-inline-popup[data-theme="dark|light"]. Accent variables
// (--peach-accent, --peach-accent-dim, --peach-glow, --peach-border-accent)
// are identical in both themes — only surface tokens differ. See
// Projects.md §4.14: hardcoded hex inside this stylesheet breaks the
// theme switch — only literal brand-peach in peachIconSvg may stay raw.
const SELECTOR_STYLES = `
:host { all: initial; }
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
.peach-inline-popup[data-theme="dark"] {
--peach-bg: #1a1410;
--peach-bg-card: #221c16;
--peach-text: #f5f0ea;
--peach-text-dim: #8a7a6a;
--peach-text-muted: #4a3a2a;
--peach-border: rgba(255,255,255,0.07);
--peach-input-bg: rgba(255,255,255,0.05);
--peach-input-border: rgba(255,255,255,0.08);
}
.peach-inline-popup[data-theme="light"] {
--peach-bg: #fdf8f4;
--peach-bg-card: #f5ede4;
--peach-text: #1c1310;
--peach-text-dim: #6b5a4a;
--peach-text-muted: #a89080;
--peach-border: rgba(0,0,0,0.08);
--peach-input-bg: rgba(0,0,0,0.04);
--peach-input-border: rgba(0,0,0,0.10);
}
.peach-inline-popup {
background: var(--peach-bg);
border: 1px solid var(--peach-border-accent, var(--peach-border));
border-radius: 14px;
overflow: hidden;
box-shadow:
0 8px 40px var(--peach-glow, transparent),
0 2px 12px rgba(0,0,0,0.5);
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
animation: peach-enter 200ms cubic-bezier(0.16,1,0.3,1) forwards;
}
/* Light theme: shadow against a light page is too strong otherwise — drop both
the accent glow and the dark drop-shadow opacity to keep the popup elegant. */
.peach-inline-popup[data-theme="light"] {
box-shadow:
0 8px 40px color-mix(in srgb, var(--peach-accent) 10%, transparent),
0 2px 12px rgba(0,0,0,0.12);
}
.peach-inline-popup.is-exiting {
animation: peach-exit 130ms cubic-bezier(0.4,0,1,1) forwards;
pointer-events: none;
}
@keyframes peach-enter {
0% { opacity: 0; transform: translateY(-8px) scale(0.96); filter: blur(3px); }
65% { opacity: 1; filter: blur(0); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes peach-exit {
0% { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
100% { opacity: 0; transform: translateY(-4px) scale(0.98); filter: blur(2px); }
}
@keyframes peach-slide-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes peach-slide-back {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
.peach-content[data-dir="forward"] { animation: peach-slide-in 150ms cubic-bezier(0.16,1,0.3,1) both; }
.peach-content[data-dir="back"] { animation: peach-slide-back 150ms cubic-bezier(0.16,1,0.3,1) both; }
/* Domain header */
.domain-header {
padding: 9px 13px 7px;
border-bottom: 1px solid var(--peach-border);
display: flex; align-items: center; justify-content: space-between;
}
.domain-eyebrow {
font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;
}
.domain-name {
font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);
font-family: "IBM Plex Mono", monospace;
}
/* Search */
.search-wrap {
padding: 9px 12px 7px;
border-bottom: 1px solid var(--peach-border);
}
.search {
width: 100%; background: var(--peach-input-bg);
border: 1px solid var(--peach-input-border);
border-radius: 7px; color: var(--peach-text); padding: 7px 10px;
font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;
transition: border-color 120ms, box-shadow 120ms;
}
.search:focus {
border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);
}
.search::placeholder { color: var(--peach-text-muted); }
/* Results */
.results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }
.results::-webkit-scrollbar { display: none; }
/* Entry rows */
.entry {
display: flex; align-items: center; padding: 8px 12px;
cursor: pointer; border-bottom: 1px solid var(--peach-border);
transition: background 70ms; position: relative; outline: none;
}
.entry:last-child { border-bottom: none; }
.entry:hover { background: color-mix(in srgb, var(--peach-text) 4%, transparent); }
.entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }
.entry.focused::before {
content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;
width: 2px; border-radius: 2px;
background: color-mix(in srgb, var(--peach-accent) 55%, transparent);
}
.entry-icon {
width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;
background: var(--peach-input-bg); border: 1px solid var(--peach-border);
display: flex; align-items: center; justify-content: center;
overflow: hidden; margin-right: 10px;
}
.entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }
.entry-icon.is-new {
background: color-mix(in srgb, var(--peach-accent) 8%, transparent);
border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);
color: color-mix(in srgb, var(--peach-accent) 80%, transparent);
}
.entry-info { flex: 1; min-width: 0; }
.entry-title {
font-size: 12.5px; color: var(--peach-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
line-height: 1.3;
}
.entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }
.entry-sub {
font-size: 10px; color: var(--peach-text-dim);
font-family: "IBM Plex Mono", monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 1px;
}
.totp-badge {
font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;
border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);
color: color-mix(in srgb, var(--peach-accent) 75%, transparent);
font-family: "IBM Plex Mono", monospace; flex-shrink: 0; margin-left: 8px;
}
.divider { height: 1px; background: var(--peach-border); margin: 2px 0; }
.empty-hint {
padding: 14px 13px; font-size: 11px; color: var(--peach-text-dim);
font-family: "IBM Plex Mono", monospace;
}
/* Create form */
.create-form-wrap {
animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both;
}
.create-head {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid var(--peach-border);
}
.back-btn {
background: transparent; border: none; cursor: pointer;
color: var(--peach-text-dim); padding: 3px; border-radius: 5px;
display: flex; align-items: center; line-height: 0;
transition: color 100ms, background 100ms;
}
.back-btn:hover { color: var(--peach-text); background: var(--peach-input-bg); }
.create-label { font-size: 12px; color: var(--peach-text); font-weight: 500; flex: 1; }
.create-body { padding: 12px; display: grid; gap: 9px; }
.f-group { display: grid; gap: 3px; }
.f-label {
font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;
}
.f-input {
background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);
border-radius: 7px; color: var(--peach-text); padding: 7px 10px;
font-size: 11px; font-family: "IBM Plex Mono", monospace;
outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;
}
.f-input:focus { border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent); }
.f-input::placeholder { color: var(--peach-text-muted); }
.f-input.url-f { font-size: 10px; color: var(--peach-text-dim); letter-spacing: 0.01em; }
.f-row { display: flex; gap: 6px; }
.f-row .f-input { flex: 1; min-width: 0; }
.gen-btn {
padding: 0 10px; border-radius: 7px; border: none;
background: color-mix(in srgb, var(--peach-accent) 10%, transparent);
color: color-mix(in srgb, var(--peach-accent) 80%, transparent);
font-size: 10px; font-family: "IBM Plex Mono", monospace;
cursor: pointer; white-space: nowrap; flex-shrink: 0;
display: flex; align-items: center; gap: 4px;
transition: background 100ms;
}
.gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }
.save-btn {
width: 100%; padding: 8px; border-radius: 8px; border: none;
background: var(--peach-accent); color: #fff; font-size: 12px;
font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;
transition: background 120ms, box-shadow 120ms, transform 80ms;
letter-spacing: 0.01em;
}
.save-btn:hover {
background: color-mix(in srgb, var(--peach-accent) 82%, #000);
box-shadow: 0 4px 20px var(--peach-glow, transparent);
}
.save-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.url-hint {
font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);
font-family: "IBM Plex Mono", monospace; font-style: italic;
}
/* Success */
.success-wrap {
display: flex; flex-direction: column; align-items: center;
padding: 24px 16px 20px; gap: 8px;
animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }
.success-ring {
width: 40px; height: 40px; border-radius: 50%;
background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);
display: flex; align-items: center; justify-content: center;
}
.success-text { font-size: 12.5px; color: var(--peach-text); font-weight: 500; }
.success-sub { font-size: 10px; color: var(--peach-text-dim); font-family:"IBM Plex Mono",monospace; }
/* Silver platter (State 1 — single match) */
.silver-platter {
padding: 14px 14px 12px;
display: flex; flex-direction: column; gap: 6px;
}
.sp-title {
font-size: 14px; font-weight: 500; color: var(--peach-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
line-height: 1.25;
}
.sp-username {
font-size: 12px; color: var(--peach-text-dim);
font-family: "IBM Plex Mono", monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 6px;
}
.sp-fill-btn {
width: 100%; padding: 11px; border-radius: 9px; border: none;
background: var(--peach-accent); color: #fff;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: Inter, sans-serif; letter-spacing: 0.01em;
display: flex; align-items: center; justify-content: center; min-height: 38px;
transition: filter 120ms ease, transform 80ms ease, box-shadow 120ms ease, background 120ms ease;
}
.sp-fill-btn:hover {
filter: brightness(1.1);
box-shadow: 0 4px 20px var(--peach-glow, transparent);
transform: translateY(-1px);
}
.sp-fill-btn:active { transform: translateY(0); filter: brightness(0.95); }
.sp-fill-btn.filled {
background: #16a34a; transform: none; filter: none;
box-shadow: 0 4px 16px rgba(22,163,74,0.35);
cursor: default;
}
.sp-fill-btn:disabled { cursor: default; }
.sp-actions {
display: flex; gap: 4px; justify-content: space-between; align-items: center;
margin-top: 4px;
}
.sp-action-btn {
flex: 1; height: 28px; border: none; border-radius: 6px;
background: transparent; color: var(--peach-text-dim);
font-size: 12px; cursor: pointer;
transition: background 120ms ease, color 120ms ease;
font-family: Inter, sans-serif; letter-spacing: 0.04em;
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
}
.sp-action-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--peach-text) 5%, transparent);
color: var(--peach-text);
}
.sp-action-btn:disabled { opacity: 0.3; cursor: default; }
.sp-action-btn.flashed {
color: var(--peach-accent);
background: color-mix(in srgb, var(--peach-accent) 12%, transparent);
}
/* 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; }
.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-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); }
/* ── 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.max(rect.width, 320)}px`;
const container = shadow.appendChild(document.createElement("div"));
container.className = "peach-inline-popup";
container.setAttribute("data-theme", popupTheme);
const searchableEntries = options.searchableEntries ?? [];
const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();
let query = "";
let focusedIndex = -1;
let currentMode: "list" | "create" | "success" | "silver-platter" = "list";
let searchInput: HTMLInputElement | null = null;
// 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> = [];
// 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(); }
});
}
function renderGenerator(filledEmail: string) {
container.innerHTML = "";
const sitePolicy = detectPasswordPolicy();
let pw = generatePasswordForSite(sitePolicy);
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;
// "Generate a password?" label
const lbl = wrap.appendChild(document.createElement("div"));
lbl.className = "gen-label";
lbl.textContent = "Generate a password";
// Password display + regen
const pwRow = wrap.appendChild(document.createElement("div"));
pwRow.className = "gen-pw-row";
const pwVal = pwRow.appendChild(document.createElement("div"));
pwVal.className = "gen-pw-val";
pwVal.textContent = pw;
const regenBtn = pwRow.appendChild(document.createElement("button"));
regenBtn.className = "gen-regen-btn";
regenBtn.type = "button";
regenBtn.textContent = "⟳ regen";
regenBtn.addEventListener("click", () => {
pw = generatePasswordForSite(sitePolicy);
pwVal.textContent = pw;
});
// "Copy & fill" button
const fillBtn = wrap.appendChild(document.createElement("button"));
fillBtn.className = "gen-fill-btn";
fillBtn.type = "button";
fillBtn.textContent = "Copy & fill";
fillBtn.addEventListener("click", () => {
options.onFillFields?.("", pw);
renderSaveOffer(filledEmail, pw);
});
}
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)
const domHdr = document.createElement("div");
domHdr.className = "domain-header";
const eyebrow = document.createElement("div");
eyebrow.className = "domain-eyebrow";
eyebrow.textContent = options.isSignup
? "Sign up as"
: entries.length > 0 ? "Saved for this site" : "No saved login";
const domName = document.createElement("div");
domName.className = "domain-name";
domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");
domHdr.append(eyebrow, domName);
container.appendChild(domHdr);
// Search — always show so user can find entries beyond the domain matches
const sw = document.createElement("div"); sw.className = "search-wrap";
searchInput = document.createElement("input");
searchInput.className = "search";
searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";
searchInput.value = query;
searchInput.addEventListener("input", () => {
query = searchInput?.value ?? "";
renderList();
searchInput?.focus();
searchInput?.setSelectionRange(query.length, query.length);
});
sw.appendChild(searchInput);
container.appendChild(sw);
// Results
const results = document.createElement("div");
results.className = "results";
container.appendChild(results);
if (current.length === 0 && entries.length > 0) {
// Show a hint but don't render empty entries
}
for (let i = 0; i < current.length; i++) {
const e = current[i]!;
const entryEl = document.createElement("div");
entryEl.className = "entry";
entryEl.dataset.id = e.id;
entryEl.dataset.index = String(i);
entryEl.setAttribute("tabindex", "-1");
entryEl.appendChild(makeEntryIcon(e.url));
const info = document.createElement("div"); info.className = "entry-info";
const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;
const subEl = document.createElement("div"); subEl.className = "entry-sub";
const domain = e.url ? getDomainFromUrl(e.url) : "";
subEl.textContent = domain || e.username || "";
info.append(titleEl, subEl);
entryEl.appendChild(info);
if (e.hasTotp) {
const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";
entryEl.appendChild(badge);
}
entryEl.addEventListener("click", () => {
if (options.isSignup && options.onFillFields) {
// Signup mode: clicking a saved identity fills the username field
options.onFillFields(e.username ?? "", "");
dismiss();
} else {
onSelect(e.id);
dismiss();
}
});
results.appendChild(entryEl);
}
// Divider before "New entry"
if (current.length > 0) {
results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));
}
// "New entry" row — always present
const newEl = document.createElement("div");
newEl.className = "entry";
newEl.setAttribute("tabindex", "-1");
newEl.appendChild(makeEntryIcon(undefined, true));
const newInfo = document.createElement("div"); newInfo.className = "entry-info";
const newTitle = document.createElement("div");
newTitle.className = "entry-title is-new";
newTitle.textContent = `New entry for ${sTitle}`;
const newSub = document.createElement("div");
newSub.className = "entry-sub";
newSub.textContent = sOrigin;
newInfo.append(newTitle, newSub);
newEl.appendChild(newInfo);
newEl.addEventListener("click", showCreateForm);
results.appendChild(newEl);
// Focus search if present
if (searchInput) {
requestAnimationFrame(() => searchInput?.focus());
}
}
function setFocused(idx: number) {
const all = container.querySelectorAll(".entry");
all.forEach((el, i) => el.classList.toggle("focused", i === idx));
focusedIndex = idx;
if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });
}
async function showCreateForm() {
currentMode = "create";
container.innerHTML = "";
// Guard: the service worker may lock the vault between when the selector
// opens and when the user clicks "New entry". Show the inline unlock form
// instead of silently failing on save.
const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;
if (!statusRes?.unlocked) {
renderLocked();
return;
}
// Detect page password policy once for this form — reused on every
// regenerate click so the user always gets a compliant password.
const sitePolicy = detectPasswordPolicy();
const wrap = document.createElement("div");
wrap.className = "create-form-wrap";
// Header
const head = document.createElement("div"); head.className = "create-head";
const backBtn = document.createElement("button"); backBtn.className = "back-btn";
backBtn.innerHTML = ``;
backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });
const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";
head.append(backBtn, lbl);
// Body
const body = document.createElement("div"); body.className = "create-body";
function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {
const g = document.createElement("div"); g.className = "f-group";
const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;
const inp = document.createElement("input"); inp.className = "f-input";
g.append(l, inp); return { g, inp };
}
// Title
const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";
body.appendChild(tg.g);
// URL
const ug = fGroup("website");
ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";
if (loginSubdomain) {
const h = document.createElement("div"); h.className = "url-hint";
h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);
}
body.appendChild(ug.g);
// Username (pre-fill from anchor field)
const xg = fGroup("username / email");
xg.inp.value = preUsername; xg.inp.placeholder = "your@email.com";
xg.inp.setAttribute("autocomplete", "off");
body.appendChild(xg.g);
// Password + generate
const pg = fGroup("password");
const pwInp = document.createElement("input"); pwInp.className = "f-input";
pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);
pwInp.style.fontFamily = '"IBM Plex Mono", monospace';
pwInp.style.letterSpacing = "0.04em";
pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");
const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";
genBtn.innerHTML = genSvgStr() + " regen";
genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });
const pwRow = document.createElement("div"); pwRow.className = "f-row";
pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);
body.appendChild(pg.g);
// Save
const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";
saveBtn.textContent = "Save entry";
const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";
function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }
saveBtn.addEventListener("click", async () => {
const title = tg.inp.value.trim();
const pw = pwInp.value.trim();
const username = xg.inp.value.trim();
if (!title) {
tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();
showFormError("Entry needs a title");
return;
}
if (!pw) {
pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();
showFormError("Entry needs a password");
return;
}
errEl.style.display = "none";
saveBtn.disabled = true; saveBtn.textContent = "Saving…";
try {
const res = await chrome.runtime.sendMessage({
type: "ADD_ENTRY",
payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },
});
if ((res as { ok?: boolean }).ok) {
// Fill the detected form regardless of mode. For login the user
// created an entry to use here; for signup the password was just
// generated for this site. Either way the credentials land in
// the form. options.onFillFields is wired by the caller (index.ts)
// and also records the form into filledForms so the popup will
// not re-attach to the filled fields.
options.onFillFields?.(username, pw);
showSuccess(title);
} else {
const errMsg = (res as { error?: string }).error === "vault locked"
? "Vault locked — open Peach to save"
: "Could not save entry";
showFormError(errMsg);
saveBtn.disabled = false; saveBtn.textContent = "Save entry";
}
} catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }
});
body.appendChild(saveBtn);
body.appendChild(errEl);
wrap.append(head, body);
container.appendChild(wrap);
requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });
}
function showSuccess(title: string) {
currentMode = "success";
container.innerHTML = "";
const wrap = document.createElement("div"); wrap.className = "success-wrap";
const ring = document.createElement("div"); ring.className = "success-ring";
ring.innerHTML = checkSvgStr();
const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;
const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;
wrap.append(ring, t, s);
container.appendChild(wrap);
setTimeout(() => dismiss(), 1600);
}
function flashActionBtn(btn: HTMLButtonElement) {
btn.classList.add("flashed");
setTimeout(() => btn.classList.remove("flashed"), 700);
}
function renderSilverPlatter(entry: SelectorEntry) {
container.innerHTML = "";
searchInput = null;
focusedIndex = -1;
currentMode = "silver-platter";
const domHdr = document.createElement("div");
domHdr.className = "domain-header";
const eyebrow = document.createElement("div");
eyebrow.className = "domain-eyebrow";
eyebrow.textContent = "Saved for this site";
const domName = document.createElement("div");
domName.className = "domain-name";
domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");
domHdr.append(eyebrow, domName);
container.appendChild(domHdr);
const platter = document.createElement("div");
platter.className = "silver-platter";
const titleEl = document.createElement("div");
titleEl.className = "sp-title";
titleEl.textContent = entry.title;
platter.appendChild(titleEl);
if (entry.username) {
const userEl = document.createElement("div");
userEl.className = "sp-username";
userEl.textContent = entry.username;
platter.appendChild(userEl);
}
const fillBtn = document.createElement("button");
fillBtn.className = "sp-fill-btn";
fillBtn.type = "button";
fillBtn.textContent = "Fill credentials";
fillBtn.addEventListener("click", () => {
if (fillBtn.disabled) return;
fillBtn.disabled = true;
fillBtn.classList.add("filled");
fillBtn.innerHTML = checkSvgStr();
// Fire the fill, then exit via the standard exit animation. The fill
// itself is async (handled in index.ts) — by 400ms the form is filled
// and the popup begins its exit. Total dwell from click to host
// removal: 400ms + 130ms (exit animation) = 530ms.
onSelect(entry.id);
setTimeout(() => dismiss(), 400);
});
platter.appendChild(fillBtn);
const actions = document.createElement("div");
actions.className = "sp-actions";
const copyU = document.createElement("button");
copyU.className = "sp-action-btn";
copyU.type = "button";
copyU.title = "Copy username";
copyU.textContent = "Copy username";
copyU.disabled = !entry.username;
copyU.addEventListener("click", async (e) => {
e.stopPropagation();
if (!entry.username) return;
let alreadyWritten = false;
try { await navigator.clipboard.writeText(entry.username); alreadyWritten = true; } catch {}
// Mirror the popup's copy path so the COPY message can clear the
// clipboard via the configured timeout. alreadyWritten lets the SW
// skip a redundant write when the page-level write succeeded.
await chrome.runtime.sendMessage({
type: "COPY",
payload: { text: entry.username, label: "Username", alreadyWritten },
}).catch(() => {});
flashActionBtn(copyU);
});
actions.appendChild(copyU);
const copyP = document.createElement("button");
copyP.className = "sp-action-btn";
copyP.type = "button";
copyP.title = "Copy password";
copyP.textContent = "Copy 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);
}
if (entry.hasTotp) {
const moreBtn = document.createElement("button");
moreBtn.className = "sp-action-btn";
moreBtn.type = "button";
moreBtn.title = "Show 2FA code";
moreBtn.textContent = "···";
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (trayEl) {
closeTray();
moreBtn.classList.remove("flashed");
} else {
moreBtn.classList.add("flashed");
void openTOTPTray();
}
});
actions.appendChild(moreBtn);
}
const closeBtn = document.createElement("button");
closeBtn.className = "sp-action-btn";
closeBtn.type = "button";
closeBtn.title = "Dismiss";
closeBtn.textContent = "✕";
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
closeTray();
dismiss(true);
});
actions.appendChild(closeBtn);
platter.appendChild(actions);
container.appendChild(platter);
}
function renderLocked() {
container.innerHTML = "";
// Domain header (same as list view)
const domHdr = document.createElement("div");
domHdr.className = "domain-header";
const eyebrow = document.createElement("div");
eyebrow.className = "domain-eyebrow";
eyebrow.textContent = "vault locked";
const domName = document.createElement("div");
domName.className = "domain-name";
domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");
domHdr.append(eyebrow, domName);
container.appendChild(domHdr);
const wrap = document.createElement("div");
wrap.className = "locked-wrap";
// Icon + label row
const head = document.createElement("div"); head.className = "locked-head";
const icon = document.createElement("div"); icon.className = "locked-icon";
icon.innerHTML = keySvgStr();
const title = document.createElement("div"); title.className = "locked-title";
title.textContent = "unlock to autofill";
head.append(icon, title);
// Password input
const pwInp = document.createElement("input");
pwInp.className = "locked-input";
pwInp.type = "password";
pwInp.placeholder = "master password";
pwInp.setAttribute("autocomplete", "current-password");
// Error line (hidden initially)
const errEl = document.createElement("div");
errEl.className = "locked-error";
errEl.style.display = "none";
// Unlock button
const unlockBtn = document.createElement("button");
unlockBtn.className = "locked-btn";
unlockBtn.type = "button";
unlockBtn.textContent = "unlock";
async function tryUnlock() {
const pw = pwInp.value.trim();
if (!pw) { pwInp.focus(); return; }
unlockBtn.disabled = true;
unlockBtn.textContent = "unlocking…";
errEl.style.display = "none";
try {
const res = await chrome.runtime.sendMessage({
type: "UNLOCK",
payload: { password: pw },
});
if ((res as { ok?: boolean }).ok) {
dismiss();
options.onUnlocked?.();
} else {
errEl.textContent = "incorrect password";
errEl.style.display = "block";
pwInp.value = "";
pwInp.focus();
unlockBtn.disabled = false;
unlockBtn.textContent = "unlock";
}
} catch {
errEl.textContent = "could not reach Peach";
errEl.style.display = "block";
unlockBtn.disabled = false;
unlockBtn.textContent = "unlock";
}
}
pwInp.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") void tryUnlock();
});
pwInp.addEventListener("keyup", (e) => e.stopPropagation());
pwInp.addEventListener("keypress", (e) => e.stopPropagation());
unlockBtn.addEventListener("click", () => void tryUnlock());
wrap.append(head, pwInp, errEl, unlockBtn);
container.appendChild(wrap);
// On pages with aggressive focus traps (Etsy modal, Google sign-in, etc.)
// the page reclaims focus from our Shadow DOM input before the user can
// type. Fighting over focus with a focusout→refocus loop hangs the tab.
// Instead: intercept keystrokes at the document capture phase — this runs
// before any page handler regardless of which element technically has
// OS focus — and route them into our input.
const captureKeydown = (e: KeyboardEvent) => {
if (!host.isConnected) {
document.removeEventListener("keydown", captureKeydown, true);
return;
}
// Let browser shortcuts (Ctrl/Cmd/Alt combos) and Tab pass through.
if (e.ctrlKey || e.metaKey || e.altKey || e.key === "Tab") return;
// Intercept before page handlers and give our input focus.
e.stopImmediatePropagation();
if (e.key === "Escape") { dismiss(true); return; }
if (e.key === "Enter") { void tryUnlock(); return; }
// For all other printable/editing keys, ensure our input has focus so
// the browser delivers the keystroke there naturally.
if (document.activeElement !== host) {
pwInp.focus({ preventScroll: true });
}
};
captureKeydownRef = captureKeydown;
document.addEventListener("keydown", captureKeydown, true);
// Best-effort focus on open — may be overridden by page focus trap,
// but the capture listener above handles typing regardless.
pwInp.focus({ preventScroll: true });
requestAnimationFrame(() => { if (host.isConnected) pwInp.focus({ preventScroll: true }); });
}
function handleKeyDown(e: KeyboardEvent) {
if (options.locked) {
if (e.key === "Escape") { e.preventDefault(); dismiss(true); }
return;
}
if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") {
if (e.key === "Escape") { e.preventDefault(); dismiss(true); }
return;
}
const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");
const count = allEntryEls.length;
if (document.activeElement === searchInput) {
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }
if (count === 0) return;
if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;
}
if (count === 0) return;
switch (e.key) {
case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;
case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;
case "Enter": {
if (focusedIndex >= 0) {
const el = allEntryEls[focusedIndex] as HTMLElement;
const id = el.dataset.id;
if (id) { onSelect(id); dismiss(); }
else if (el.classList.contains("is-new") || !id) {
// "New entry" row (last item, no data-id)
showCreateForm();
}
}
break;
}
case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;
}
}
document.addEventListener("keydown", handleKeyDown);
document.body.appendChild(host);
function updatePosition() {
const r = anchorEl.getBoundingClientRect();
host.style.top = `${r.bottom + window.scrollY + 5}px`;
host.style.left = `${r.left + window.scrollX}px`;
host.style.width = `${Math.max(r.width, 260)}px`;
}
// These bindings are referenced from renderLocked()/dismiss() — they MUST
// be initialized before renderLocked() runs, or the synchronous assignment
// `captureKeydownRef = captureKeydown` inside renderLocked hits the TDZ
// and throws ReferenceError. The throw lands after the popup is rendered
// but before showAutofillSelector returns its dismiss handle, leaving an
// orphan host in the DOM (which is what caused the locked-popup stacking
// and missing autofocus on every click).
let captureKeydownRef: ((e: KeyboardEvent) => void) | null = null;
const stopObserving = observeAnchorPosition(anchorEl, updatePosition);
if (options.locked) {
renderLocked();
} else if (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 7 — card silver platter. Single-card focused fill view rendered
* over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the
* theme tokens, and the .sp-fill-btn / .sp-actions primary action classes
* so card and login flows render identically. Multi-card list view is a
* follow-up; today this picks the first card.
*/
export function showCardSelector(
anchorEl: HTMLInputElement,
cards: CardSelectorEntry[],
options: CardSelectorOptions
): () => void {
if (cards.length === 0) return () => {};
const card = cards[0]!;
const host = document.createElement("div");
host.style.cssText = "position: absolute; z-index: 2147483647;";
host.style.setProperty("--peach-accent", accentHex);
host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));
host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));
host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));
const shadow = host.attachShadow({ mode: "closed" });
const style = shadow.appendChild(document.createElement("style"));
style.textContent = SELECTOR_STYLES;
const rect = anchorEl.getBoundingClientRect();
host.style.top = `${rect.bottom + window.scrollY + 5}px`;
host.style.left = `${rect.left + window.scrollX}px`;
host.style.width = `${Math.max(rect.width, 320)}px`;
const container = shadow.appendChild(document.createElement("div"));
container.className = "peach-inline-popup";
container.setAttribute("data-theme", popupTheme);
// Domain header — eyebrow communicates the card context, not the site
const domHdr = document.createElement("div");
domHdr.className = "domain-header";
const eyebrow = document.createElement("div");
eyebrow.className = "domain-eyebrow";
eyebrow.textContent = "Pay with Peach";
const domName = document.createElement("div");
domName.className = "domain-name";
domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");
domHdr.append(eyebrow, domName);
container.appendChild(domHdr);
const platter = document.createElement("div");
platter.className = "card-platter";
if (card.network && card.network !== "unknown") {
const net = document.createElement("div");
net.className = "cp-network";
net.textContent = card.network;
platter.appendChild(net);
}
const nick = document.createElement("div");
nick.className = "cp-nickname";
nick.textContent = card.title;
platter.appendChild(nick);
if (card.last4) {
const l4 = document.createElement("div");
l4.className = "cp-last4";
l4.textContent = `•••• ${card.last4}`;
platter.appendChild(l4);
}
const meta = document.createElement("div");
meta.className = "cp-meta";
if (card.cardholderName) {
const name = document.createElement("span");
name.textContent = card.cardholderName;
meta.appendChild(name);
}
if (card.expMonth && card.expYear) {
if (card.cardholderName) {
const sep = document.createElement("span");
sep.className = "cp-meta-sep";
sep.textContent = "·";
meta.appendChild(sep);
}
const exp = document.createElement("span");
const yy = card.expYear.length === 4 ? card.expYear.slice(-2) : card.expYear;
exp.textContent = `${card.expMonth.padStart(2, "0")}/${yy}`;
meta.appendChild(exp);
}
if (meta.childElementCount > 0) platter.appendChild(meta);
const fillWrap = document.createElement("div");
fillWrap.className = "cp-fill-wrap";
const fillBtn = document.createElement("button");
fillBtn.className = "sp-fill-btn";
fillBtn.type = "button";
fillBtn.textContent = "Fill card details";
fillBtn.addEventListener("click", async () => {
if (fillBtn.disabled) return;
fillBtn.disabled = true;
fillBtn.classList.add("filled");
fillBtn.innerHTML = checkSvgStr();
const resp = await chrome.runtime.sendMessage({
type: "GET_ENTRY_FOR_FILL",
payload: { id: card.id },
}).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;
if (resp?.ok && resp.data) options.onFill(resp.data);
setTimeout(() => dismiss(), 400);
});
fillWrap.appendChild(fillBtn);
platter.appendChild(fillWrap);
// Secondary actions — copy number, copy CVC, dismiss
const actions = document.createElement("div");
actions.className = "sp-actions";
function flashBtn(b: HTMLButtonElement) {
b.classList.add("flashed");
setTimeout(() => b.classList.remove("flashed"), 700);
}
async function fetchFullCard(): Promise {
const resp = await chrome.runtime.sendMessage({
type: "GET_ENTRY_FOR_FILL",
payload: { id: card.id },
}).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;
return resp?.ok && resp.data ? resp.data : null;
}
const copyNum = document.createElement("button");
copyNum.className = "sp-action-btn";
copyNum.type = "button";
copyNum.title = "Copy card number";
copyNum.textContent = "Copy 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`;
host.style.width = `${Math.max(r.width, 320)}px`;
}
const stopObserving = observeAnchorPosition(anchorEl, updatePosition);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); dismiss(true); }
}
document.addEventListener("keydown", onKeyDown);
let exitScheduled = false;
function dismiss(userAction = false) {
if (exitScheduled) return;
exitScheduled = true;
stopObserving();
document.removeEventListener("click", outsideClick, { capture: true });
document.removeEventListener("keydown", onKeyDown);
if (userAction) options.onDismiss?.();
container.classList.add("is-exiting");
setTimeout(() => host.remove(), 130);
}
function outsideClick(ev: MouseEvent) {
const path = ev.composedPath();
if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);
}
setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);
return dismiss;
}