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