123456789101112import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";13import { generateSmartPassword } from "../shared/generate";14import { detectPasswordPolicy } from "./policy";1516let accentHex = "#E8956D";17chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {18 const name = resp?.data?.accentColor ?? "peach";19 accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";20});2122export interface SelectorEntry {23 id: string;24 title: string;25 username?: string;26 url?: string;27 hasTotp?: boolean;28}2930interface SelectorOptions {31 domain?: string;32 searchableEntries?: SelectorEntry[];33 34 isSignup?: boolean;35 3637383940 onFillFields?: (username: string, password: string) => void;41 4243444546 locked?: boolean;47 48495051 onUnlocked?: () => void;52 5354555657 onDismiss?: () => void;58}5960function getDomainFromUrl(url: string): string {61 try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }62}6364function keySvgStr(): string {65 return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="rgba(240,232,216,0.35)" stroke-width="0.95" stroke-linecap="round"><rect x="2" y="5.5" width="8" height="6" rx="1.5"/><path d="M4 5.5V4.25A2 2 0 0 1 6 2.25A2 2 0 0 1 8 4.25V5.5"/></svg>`;66}6768function plusSvgStr(): string {69 return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1.5v9M1.5 6h9"/></svg>`;70}7172function peachIconSvg(): string {73 74 75 76 return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 64 64" fill="none" aria-hidden="true"><path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/><path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/></svg>`;77}7879function genSvgStr(): string {80 return `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"><path d="M9.5 2A4.5 4.5 0 1 0 10.5 5.5"/><path d="M8.5 2H9.5V1"/></svg>`;81}8283function checkSvgStr(): string {84 return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"><path d="M4 10.5L8 14.5L16 6.5"/></svg>`;85}8687function smartUrl(): { origin: string; title: string; loginSubdomain: boolean } {88 const { hostname, origin } = location;89 const parts = hostname.split(".");90 const AUTH_PREFIXES = ["accounts", "auth", "login", "signin", "sign-in", "sso",91 "id", "identity", "secure", "portal", "my", "am", "idp", "connect", "oauth",92 "oidc", "federation", "saml", "cas", "ldap"];93 const loginSubdomain = parts.length > 2 && AUTH_PREFIXES.includes(parts[0]!.toLowerCase());94 const rootName = parts.length >= 2 ? parts[parts.length - 2]! : parts[0]!;95 const title = rootName.charAt(0).toUpperCase() + rootName.slice(1);96 return { origin, title, loginSubdomain };97}9899100101102103104export function showRememberSiteToast(105 pageDomain: string,106 entryId: string,107): void {108 const host = document.createElement("div");109 host.style.cssText = "position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:auto;";110 host.style.setProperty("--peach-accent", accentHex);111 const shadow = host.attachShadow({ mode: "closed" });112113 const style = document.createElement("style");114 style.textContent = `115 .bar {116 display:flex;align-items:center;gap:10px;padding:10px 14px;117 background:#131109;border:1px solid rgba(240,232,216,0.09);118 border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,0.75);119 font-family:Inter,sans-serif;white-space:nowrap;120 animation:tf-in 180ms cubic-bezier(0.16,1,0.3,1) both;121 }122 @keyframes tf-in { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none} }123 .txt { font-size:11px;color:rgba(240,232,216,0.55);font-family:"IBM Plex Mono",monospace; }124 .yes {125 padding:5px 12px;border-radius:6px;border:none;cursor:pointer;font-size:11px;126 background:var(--peach-accent);color:#fff;transition:background 100ms;font-family:Inter,sans-serif;127 }128 .yes:hover { background:color-mix(in srgb, var(--peach-accent) 82%, #000); }129 .no {130 padding:5px 10px;border-radius:6px;border:none;cursor:pointer;font-size:11px;131 background:rgba(240,232,216,0.07);color:rgba(240,232,216,0.45);font-family:Inter,sans-serif;132 }133 `;134 shadow.appendChild(style);135136 const bar = document.createElement("div"); bar.className = "bar";137 const txt = document.createElement("span"); txt.className = "txt";138 txt.textContent = `Remember ${pageDomain} for this entry?`;139 const yesBtn = document.createElement("button"); yesBtn.className = "yes";140 yesBtn.textContent = "Remember";141 yesBtn.addEventListener("click", async () => {142 await chrome.runtime.sendMessage({143 type: "ADD_ADDITIONAL_URL",144 payload: { id: entryId, url: location.origin },145 });146 host.remove();147 });148 const noBtn = document.createElement("button"); noBtn.className = "no";149 noBtn.textContent = "Not now";150 noBtn.addEventListener("click", () => host.remove());151 bar.append(txt, yesBtn, noBtn);152 shadow.appendChild(bar);153 document.body.appendChild(host);154 setTimeout(() => host.remove(), 9000);155}156157function generatePasswordForSite(policy: PasswordPolicy = {}): string {158 return generateSmartPassword(policy);159}160161export function observeAnchorPosition(162 anchorEl: HTMLElement,163 onUpdate: () => void164): () => void {165 let rafId = 0;166 let burstUntil = 0;167168 const runUpdate = () => {169 rafId = 0;170 onUpdate();171 if (performance.now() < burstUntil) rafId = requestAnimationFrame(runUpdate);172 };173 const scheduleUpdate = () => { if (!rafId) rafId = requestAnimationFrame(runUpdate); };174 const scheduleBurst = (ms = 220) => {175 burstUntil = Math.max(burstUntil, performance.now() + ms);176 scheduleUpdate();177 };178179 const resizeObserver = new ResizeObserver(() => scheduleBurst());180 resizeObserver.observe(anchorEl);181 if (anchorEl.parentElement) resizeObserver.observe(anchorEl.parentElement);182183 const mutationObserver = new MutationObserver(() => scheduleBurst());184 mutationObserver.observe(anchorEl, { attributes: true, attributeFilter: ["class", "style", "type", "hidden"] });185 if (anchorEl.parentElement) {186 mutationObserver.observe(anchorEl.parentElement, { attributes: true, attributeFilter: ["class", "style", "hidden"] });187 }188189 const onScroll = () => scheduleUpdate();190 window.addEventListener("scroll", onScroll, true);191 window.addEventListener("resize", onScroll);192 window.visualViewport?.addEventListener("resize", onScroll);193 window.visualViewport?.addEventListener("scroll", onScroll);194 anchorEl.addEventListener("focus", () => scheduleBurst());195 anchorEl.addEventListener("input", () => scheduleBurst());196197 scheduleBurst(260);198199 return () => {200 if (rafId) cancelAnimationFrame(rafId);201 resizeObserver.disconnect();202 mutationObserver.disconnect();203 window.removeEventListener("scroll", onScroll, true);204 window.removeEventListener("resize", onScroll);205 window.visualViewport?.removeEventListener("resize", onScroll);206 window.visualViewport?.removeEventListener("scroll", onScroll);207 };208}209210export function injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void {211 const button = document.createElement("button");212 button.type = "button";213 button.setAttribute("aria-label", "Peach autofill");214 button.title = "Fill with Peach";215 button.innerHTML = peachIconSvg();216 217 218 219 220 button.style.cssText = `221 position: fixed; z-index: 2147483646;222 width: 26px; height: 26px; cursor: pointer;223 pointer-events: auto; border: none; padding: 3px;224 display: inline-flex; align-items: center; justify-content: center;225 background: transparent; border-radius: 999px;226 transition: opacity 140ms ease, background 120ms ease, box-shadow 120ms ease, transform 80ms ease;227 opacity: 0;228 `;229 button.addEventListener("mouseenter", () => {230 button.style.background = `color-mix(in srgb, ${accentHex} 12%, transparent)`;231 button.style.boxShadow = `0 0 0 1px color-mix(in srgb, ${accentHex} 18%, transparent), 0 0 14px color-mix(in srgb, ${accentHex} 12%, transparent)`;232 button.style.transform = "scale(1.08)";233 });234 button.addEventListener("mouseleave", () => {235 button.style.background = "transparent";236 button.style.boxShadow = "none";237 button.style.transform = "none";238 });239 button.addEventListener("pointerdown", e => { e.stopPropagation(); e.preventDefault(); });240 button.addEventListener("click", e => { e.stopPropagation(); e.preventDefault(); onClick(); });241242 document.body.appendChild(button);243244 function updatePosition() {245 const rect = field.getBoundingClientRect();246 if (rect.width === 0 || rect.height === 0) {247 button.style.opacity = "0"; button.style.pointerEvents = "none";248 return;249 }250 const sz = 26;251 const inset = Math.max(5, Math.min(10, rect.height * 0.22));252 const top = rect.top + rect.height / 2 - sz / 2;253 254 255 const minLeft = rect.left + 4;256 const left = Math.max(minLeft, rect.right - sz - inset);257 258 if (rect.width < 40) {259 button.style.opacity = "0"; button.style.pointerEvents = "none";260 return;261 }262 button.style.top = `${top}px`;263 button.style.left = `${left}px`;264 button.style.opacity = "1"; button.style.pointerEvents = "auto";265 }266 updatePosition();267 requestAnimationFrame(() => { button.style.opacity = "1"; });268 const stopObserving = observeAnchorPosition(field, updatePosition);269 return () => { stopObserving(); button.remove(); };270}271272273const SELECTOR_STYLES = `274 :host { all: initial; }275 *,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }276 .selector {277 background: #0f0e0b;278 border: 1px solid rgba(240,232,216,0.10);279 border-radius: 13px;280 overflow: hidden;281 box-shadow: 0 4px 16px rgba(0,0,0,0.45), 0 16px 48px rgba(0,0,0,0.7);282 font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;283 animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;284 }285 @keyframes sl-in {286 from { opacity:0; transform:translateY(-6px) scale(0.97) }287 to { opacity:1; transform:none }288 }289290 /* Domain header */291 .domain-header {292 padding: 9px 13px 7px;293 border-bottom: 1px solid rgba(240,232,216,0.055);294 display: flex; align-items: center; justify-content: space-between;295 }296 .domain-eyebrow {297 font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;298 color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;299 }300 .domain-name {301 font-size: 10px; color: color-mix(in srgb, var(--peach-accent) 60%, transparent);302 font-family: "IBM Plex Mono", monospace;303 }304305 /* Search */306 .search-wrap {307 padding: 9px 12px 7px;308 border-bottom: 1px solid rgba(240,232,216,0.04);309 }310 .search {311 width: 100%; background: rgba(240,232,216,0.03);312 border: 1px solid rgba(240,232,216,0.08);313 border-radius: 7px; color: #F0E8D8; padding: 7px 10px;314 font-size: 12px; outline: none; font-family: "IBM Plex Mono", monospace;315 transition: border-color 120ms, box-shadow 120ms;316 }317 .search:focus {318 border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);319 box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);320 }321 .search::placeholder { color: rgba(240,232,216,0.2); }322323 /* Results */324 .results { max-height: 256px; overflow-y: auto; scrollbar-width: none; }325 .results::-webkit-scrollbar { display: none; }326327 /* Entry rows */328 .entry {329 display: flex; align-items: center; padding: 8px 12px;330 cursor: pointer; border-bottom: 1px solid rgba(240,232,216,0.04);331 transition: background 70ms; position: relative; outline: none;332 }333 .entry:last-child { border-bottom: none; }334 .entry:hover { background: rgba(240,232,216,0.04); }335 .entry.focused { background: color-mix(in srgb, var(--peach-accent) 8%, transparent); }336 .entry.focused::before {337 content: ""; position: absolute; left: 0; top: 3px; bottom: 3px;338 width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--peach-accent) 55%, transparent);339 }340341 .entry-icon {342 width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;343 background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.07);344 display: flex; align-items: center; justify-content: center;345 overflow: hidden; margin-right: 10px;346 }347 .entry-icon img { width: 15px; height: 15px; border-radius: 2px; display: block; }348 .entry-icon.is-new {349 background: color-mix(in srgb, var(--peach-accent) 8%, transparent);350 border-color: color-mix(in srgb, var(--peach-accent) 15%, transparent);351 color: color-mix(in srgb, var(--peach-accent) 80%, transparent);352 }353354 .entry-info { flex: 1; min-width: 0; }355 .entry-title {356 font-size: 12.5px; color: #F0E8D8;357 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;358 line-height: 1.3;359 }360 .entry-title.is-new { color: color-mix(in srgb, var(--peach-accent) 90%, transparent); font-size: 12px; }361 .entry-sub {362 font-size: 10px; color: rgba(240,232,216,0.32);363 font-family: "IBM Plex Mono", monospace;364 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;365 margin-top: 1px;366 }367368 .totp-badge {369 font-size: 8px; letter-spacing: 0.04em; padding: 2px 6px;370 border-radius: 4px; background: color-mix(in srgb, var(--peach-accent) 10%, transparent);371 color: color-mix(in srgb, var(--peach-accent) 75%, transparent); font-family: "IBM Plex Mono", monospace;372 flex-shrink: 0; margin-left: 8px;373 }374375 .divider { height: 1px; background: rgba(240,232,216,0.05); margin: 2px 0; }376377 .empty-hint {378 padding: 14px 13px; font-size: 11px; color: rgba(240,232,216,0.28);379 font-family: "IBM Plex Mono", monospace;380 }381382 /* ── Create form ─────────────────────────────────────── */383 .create-form-wrap {384 animation: cf-in 180ms cubic-bezier(0.16,1,0.3,1) both;385 }386 @keyframes cf-in {387 from { opacity:0; transform:translateX(8px) }388 to { opacity:1; transform:none }389 }390 .create-head {391 display: flex; align-items: center; gap: 8px;392 padding: 10px 12px; border-bottom: 1px solid rgba(240,232,216,0.055);393 }394 .back-btn {395 background: transparent; border: none; cursor: pointer;396 color: rgba(240,232,216,0.38); padding: 3px; border-radius: 5px;397 display: flex; align-items: center; line-height: 0;398 transition: color 100ms, background 100ms;399 }400 .back-btn:hover { color: rgba(240,232,216,0.75); background: rgba(240,232,216,0.05); }401 .create-label { font-size: 12px; color: rgba(240,232,216,0.65); font-weight: 500; flex: 1; }402 .create-body { padding: 12px; display: grid; gap: 9px; }403 .f-group { display: grid; gap: 3px; }404 .f-label {405 font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;406 color: rgba(240,232,216,0.28); font-family: "IBM Plex Mono", monospace;407 }408 .f-input {409 background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);410 border-radius: 7px; color: #F0E8D8; padding: 7px 10px;411 font-size: 11px; font-family: "IBM Plex Mono", monospace;412 outline: none; width: 100%; transition: border-color 120ms, box-shadow 120ms;413 }414 .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); }415 .f-input::placeholder { color: rgba(240,232,216,0.18); }416 .f-input.url-f { font-size: 10px; color: rgba(240,232,216,0.5); letter-spacing: 0.01em; }417 .f-row { display: flex; gap: 6px; }418 .f-row .f-input { flex: 1; min-width: 0; }419 .gen-btn {420 padding: 0 10px; border-radius: 7px; border: none;421 background: color-mix(in srgb, var(--peach-accent) 10%, transparent);422 color: color-mix(in srgb, var(--peach-accent) 80%, transparent);423 font-size: 10px; font-family: "IBM Plex Mono", monospace;424 cursor: pointer; white-space: nowrap; flex-shrink: 0;425 display: flex; align-items: center; gap: 4px;426 transition: background 100ms;427 }428 .gen-btn:hover { background: color-mix(in srgb, var(--peach-accent) 18%, transparent); }429 .save-btn {430 width: 100%; padding: 8px; border-radius: 8px; border: none;431 background: var(--peach-accent); color: #fff; font-size: 12px;432 font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;433 transition: background 120ms; letter-spacing: 0.01em;434 }435 .save-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }436 .save-btn:disabled { opacity: 0.4; cursor: not-allowed; }437 .url-hint {438 font-size: 9px; color: color-mix(in srgb, var(--peach-accent) 45%, transparent);439 font-family: "IBM Plex Mono", monospace; font-style: italic;440 }441442 /* ── Success ─────────────────────────────────────────── */443 .success-wrap {444 display: flex; flex-direction: column; align-items: center;445 padding: 24px 16px 20px; gap: 8px;446 animation: su-in 280ms cubic-bezier(0.16,1,0.3,1) both;447 }448 @keyframes su-in { from{opacity:0;transform:scale(0.9)}to{opacity:1;transform:none} }449 .success-ring {450 width: 40px; height: 40px; border-radius: 50%;451 background: rgba(123,144,96,0.10); border: 1.5px solid rgba(123,144,96,0.25);452 display: flex; align-items: center; justify-content: center;453 }454 .success-text { font-size: 12.5px; color: #F0E8D8; font-weight: 500; }455 .success-sub { font-size: 10px; color: rgba(240,232,216,0.3); font-family:"IBM Plex Mono",monospace; }456457 /* ── Locked state ────────────────────────────────────── */458 .locked-wrap {459 display: flex; flex-direction: column; align-items: stretch;460 padding: 14px 13px 13px; gap: 9px;461 animation: sl-in 180ms cubic-bezier(0.16,1,0.3,1) both;462 }463 .locked-head {464 display: flex; align-items: center; gap: 8px; padding-bottom: 2px;465 }466 .locked-icon {467 width: 24px; height: 24px; border-radius: 6px; flex-shrink: 0;468 background: rgba(240,232,216,0.04); border: 1px solid rgba(240,232,216,0.08);469 display: flex; align-items: center; justify-content: center;470 }471 .locked-title {472 font-size: 11.5px; color: rgba(240,232,216,0.55); font-weight: 500;473 }474 .locked-input {475 width: 100%; background: rgba(240,232,216,0.035); border: 1px solid rgba(240,232,216,0.09);476 border-radius: 7px; color: #F0E8D8; padding: 7px 10px;477 font-size: 11px; font-family: "IBM Plex Mono", monospace;478 outline: none; transition: border-color 120ms, box-shadow 120ms;479 }480 .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); }481 .locked-input::placeholder { color: rgba(240,232,216,0.2); }482 .locked-btn {483 width: 100%; padding: 8px; border-radius: 8px; border: none;484 background: var(--peach-accent); color: #fff; font-size: 12px;485 font-family: Inter, sans-serif; cursor: pointer; font-weight: 500;486 transition: background 120ms; letter-spacing: 0.01em;487 }488 .locked-btn:hover { background: color-mix(in srgb, var(--peach-accent) 82%, #000); }489 .locked-btn:disabled { opacity: 0.4; cursor: not-allowed; }490 .locked-error {491 font-size: 10px; color: rgba(220,80,60,0.85);492 font-family: "IBM Plex Mono", monospace;493 }494`;495496export function showAutofillSelector(497 anchorEl: HTMLInputElement,498 entries: SelectorEntry[],499 onSelect: (id: string) => void,500 options: SelectorOptions = {}501): () => void {502 const host = document.createElement("div");503 host.style.cssText = "position: absolute; z-index: 2147483647;";504 host.style.setProperty("--peach-accent", accentHex);505 const shadow = host.attachShadow({ mode: "closed" });506507 const style = shadow.appendChild(document.createElement("style"));508 style.textContent = SELECTOR_STYLES;509510 const rect = anchorEl.getBoundingClientRect();511 host.style.top = `${rect.bottom + window.scrollY + 5}px`;512 host.style.left = `${rect.left + window.scrollX}px`;513 host.style.width = `${Math.max(rect.width, 260)}px`;514515 const container = shadow.appendChild(document.createElement("div"));516 container.className = "selector";517518 const searchableEntries = options.searchableEntries ?? [];519 const { origin: sOrigin, title: sTitle, loginSubdomain } = smartUrl();520521 let query = "";522 let focusedIndex = -1;523 let currentMode: "list" | "create" | "success" = "list";524 let searchInput: HTMLInputElement | null = null;525526 527 const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";528 const pwInForm = anchorEl.closest("form")?.querySelector<HTMLInputElement>("input[type='password']");529 const prePassword = pwInForm?.value?.trim() ?? (anchorEl.type === "password" ? anchorEl.value?.trim() : "") ?? "";530531 function uniqueEntries(list: SelectorEntry[]): SelectorEntry[] {532 const seen = new Set<string>();533 return list.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true; });534 }535536 function filterEntries(source: SelectorEntry[], value: string): SelectorEntry[] {537 const q = value.trim().toLowerCase();538 const deduped = uniqueEntries(source);539 if (!q) return deduped.slice(0, 8);540 return deduped.filter(e =>541 [e.title, e.username ?? "", e.url ?? ""].join(" ").toLowerCase().includes(q)542 ).slice(0, 12);543 }544545 function activeEntries(): SelectorEntry[] {546 547 if (query.trim()) {548 const merged = uniqueEntries([...entries, ...searchableEntries]);549 return filterEntries(merged, query);550 }551 552 if (entries.length > 0) return entries;553 return filterEntries(searchableEntries, "").slice(0, 8);554 }555556 function makeEntryIcon(url?: string, isNew = false): HTMLDivElement {557 const icon = document.createElement("div");558 icon.className = `entry-icon${isNew ? " is-new" : ""}`;559 if (isNew) {560 icon.innerHTML = plusSvgStr();561 } else {562 563 564 565 void url;566 icon.innerHTML = keySvgStr();567 }568 return icon;569 }570571 function renderList() {572 container.innerHTML = "";573 focusedIndex = -1;574 searchInput = null;575576 const current = activeEntries();577578 579 const domHdr = document.createElement("div");580 domHdr.className = "domain-header";581 const eyebrow = document.createElement("div");582 eyebrow.className = "domain-eyebrow";583 eyebrow.textContent = options.isSignup584 ? "Sign up as"585 : entries.length > 0 ? "Saved for this site" : "No saved login";586 const domName = document.createElement("div");587 domName.className = "domain-name";588 domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");589 domHdr.append(eyebrow, domName);590 container.appendChild(domHdr);591592 593 const sw = document.createElement("div"); sw.className = "search-wrap";594 searchInput = document.createElement("input");595 searchInput.className = "search";596 searchInput.placeholder = entries.length > 0 ? "search for another entry…" : "search vault…";597 searchInput.value = query;598 searchInput.addEventListener("input", () => {599 query = searchInput?.value ?? "";600 renderList();601 searchInput?.focus();602 searchInput?.setSelectionRange(query.length, query.length);603 });604 sw.appendChild(searchInput);605 container.appendChild(sw);606607 608 const results = document.createElement("div");609 results.className = "results";610 container.appendChild(results);611612 if (current.length === 0 && entries.length > 0) {613 614 }615616 for (let i = 0; i < current.length; i++) {617 const e = current[i]!;618 const entryEl = document.createElement("div");619 entryEl.className = "entry";620 entryEl.dataset.id = e.id;621 entryEl.dataset.index = String(i);622 entryEl.setAttribute("tabindex", "-1");623624 entryEl.appendChild(makeEntryIcon(e.url));625626 const info = document.createElement("div"); info.className = "entry-info";627 const titleEl = document.createElement("div"); titleEl.className = "entry-title"; titleEl.textContent = e.title;628 const subEl = document.createElement("div"); subEl.className = "entry-sub";629 const domain = e.url ? getDomainFromUrl(e.url) : "";630 subEl.textContent = domain || e.username || "";631 info.append(titleEl, subEl);632 entryEl.appendChild(info);633634 if (e.hasTotp) {635 const badge = document.createElement("span"); badge.className = "totp-badge"; badge.textContent = "TOTP";636 entryEl.appendChild(badge);637 }638639 entryEl.addEventListener("click", () => {640 if (options.isSignup && options.onFillFields) {641 642 options.onFillFields(e.username ?? "", "");643 dismiss();644 } else {645 onSelect(e.id);646 dismiss();647 }648 });649 results.appendChild(entryEl);650 }651652 653 if (current.length > 0) {654 results.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));655 }656657 658 const newEl = document.createElement("div");659 newEl.className = "entry";660 newEl.setAttribute("tabindex", "-1");661 newEl.appendChild(makeEntryIcon(undefined, true));662663 const newInfo = document.createElement("div"); newInfo.className = "entry-info";664 const newTitle = document.createElement("div");665 newTitle.className = "entry-title is-new";666 newTitle.textContent = `New entry for ${sTitle}`;667 const newSub = document.createElement("div");668 newSub.className = "entry-sub";669 newSub.textContent = sOrigin;670 newInfo.append(newTitle, newSub);671 newEl.appendChild(newInfo);672 newEl.addEventListener("click", showCreateForm);673 results.appendChild(newEl);674675 676 if (searchInput) {677 requestAnimationFrame(() => searchInput?.focus());678 }679 }680681 function setFocused(idx: number) {682 const all = container.querySelectorAll<HTMLElement>(".entry");683 all.forEach((el, i) => el.classList.toggle("focused", i === idx));684 focusedIndex = idx;685 if (idx >= 0) (all[idx] as HTMLElement).scrollIntoView({ block: "nearest" });686 }687688 async function showCreateForm() {689 currentMode = "create";690 container.innerHTML = "";691692 693 694 695 const statusRes = await chrome.runtime.sendMessage({ type: "GET_VAULT_STATUS" }).catch(() => null) as { unlocked?: boolean } | null;696 if (!statusRes?.unlocked) {697 renderLocked();698 return;699 }700701 702 703 const sitePolicy = detectPasswordPolicy();704705 const wrap = document.createElement("div");706 wrap.className = "create-form-wrap";707708 709 const head = document.createElement("div"); head.className = "create-head";710 const backBtn = document.createElement("button"); backBtn.className = "back-btn";711 backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="7" height="12" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M6 1L1.5 7L6 13"/></svg>`;712 backBtn.addEventListener("click", () => { currentMode = "list"; renderList(); });713 const lbl = document.createElement("div"); lbl.className = "create-label"; lbl.textContent = "New entry";714 head.append(backBtn, lbl);715716 717 const body = document.createElement("div"); body.className = "create-body";718719 function fGroup(label: string): { g: HTMLDivElement; inp: HTMLInputElement } {720 const g = document.createElement("div"); g.className = "f-group";721 const l = document.createElement("div"); l.className = "f-label"; l.textContent = label;722 const inp = document.createElement("input"); inp.className = "f-input";723 g.append(l, inp); return { g, inp };724 }725726 727 const tg = fGroup("title"); tg.inp.value = sTitle; tg.inp.placeholder = "e.g. Google";728 body.appendChild(tg.g);729730 731 const ug = fGroup("website");732 ug.inp.className = "f-input url-f"; ug.inp.value = sOrigin; ug.inp.placeholder = "https://";733 if (loginSubdomain) {734 const h = document.createElement("div"); h.className = "url-hint";735 h.textContent = "⊘ Recognized sign-in subdomain"; ug.g.appendChild(h);736 }737 body.appendChild(ug.g);738739 740 const xg = fGroup("username / email");741 xg.inp.value = preUsername; xg.inp.placeholder = "[email protected]";742 xg.inp.setAttribute("autocomplete", "off");743 body.appendChild(xg.g);744745 746 const pg = fGroup("password");747 const pwInp = document.createElement("input"); pwInp.className = "f-input";748 pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);749 pwInp.style.fontFamily = '"IBM Plex Mono", monospace';750 pwInp.style.letterSpacing = "0.04em";751 pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");752 const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";753 genBtn.innerHTML = genSvgStr() + " regen";754 genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });755 const pwRow = document.createElement("div"); pwRow.className = "f-row";756 pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);757 body.appendChild(pg.g);758759 760 const saveBtn = document.createElement("button"); saveBtn.className = "save-btn"; saveBtn.type = "button";761 saveBtn.textContent = "Save entry";762 const errEl = document.createElement("div"); errEl.className = "locked-error"; errEl.style.display = "none";763 function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }764 saveBtn.addEventListener("click", async () => {765 const title = tg.inp.value.trim();766 const pw = pwInp.value.trim();767 const username = xg.inp.value.trim();768 if (!title) {769 tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();770 showFormError("Entry needs a title");771 return;772 }773 if (!pw) {774 pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();775 showFormError("Entry needs a password");776 return;777 }778 errEl.style.display = "none";779 saveBtn.disabled = true; saveBtn.textContent = "Saving…";780 try {781 const res = await chrome.runtime.sendMessage({782 type: "ADD_ENTRY",783 payload: { title, url: ug.inp.value.trim(), username, password: pw, type: "login" },784 });785 if ((res as { ok?: boolean }).ok) {786 787 if (options.isSignup && options.onFillFields) {788 options.onFillFields(username, pw);789 }790 showSuccess(title);791 } else {792 const errMsg = (res as { error?: string }).error === "vault locked"793 ? "Vault locked — open Peach to save"794 : "Could not save entry";795 showFormError(errMsg);796 saveBtn.disabled = false; saveBtn.textContent = "Save entry";797 }798 } catch { showFormError("Could not reach Peach"); saveBtn.disabled = false; saveBtn.textContent = "Save entry"; }799 });800 body.appendChild(saveBtn);801 body.appendChild(errEl);802803 wrap.append(head, body);804 container.appendChild(wrap);805 requestAnimationFrame(() => { tg.inp.focus(); tg.inp.select(); });806 }807808 function showSuccess(title: string) {809 currentMode = "success";810 container.innerHTML = "";811 const wrap = document.createElement("div"); wrap.className = "success-wrap";812 const ring = document.createElement("div"); ring.className = "success-ring";813 ring.innerHTML = checkSvgStr();814 const t = document.createElement("div"); t.className = "success-text"; t.textContent = `${title} saved`;815 const s = document.createElement("div"); s.className = "success-sub"; s.textContent = sOrigin;816 wrap.append(ring, t, s);817 container.appendChild(wrap);818 setTimeout(() => dismiss(), 1600);819 }820821 function renderLocked() {822 container.innerHTML = "";823824 825 const domHdr = document.createElement("div");826 domHdr.className = "domain-header";827 const eyebrow = document.createElement("div");828 eyebrow.className = "domain-eyebrow";829 eyebrow.textContent = "vault locked";830 const domName = document.createElement("div");831 domName.className = "domain-name";832 domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");833 domHdr.append(eyebrow, domName);834 container.appendChild(domHdr);835836 const wrap = document.createElement("div");837 wrap.className = "locked-wrap";838839 840 const head = document.createElement("div"); head.className = "locked-head";841 const icon = document.createElement("div"); icon.className = "locked-icon";842 icon.innerHTML = keySvgStr();843 const title = document.createElement("div"); title.className = "locked-title";844 title.textContent = "unlock to autofill";845 head.append(icon, title);846847 848 const pwInp = document.createElement("input");849 pwInp.className = "locked-input";850 pwInp.type = "password";851 pwInp.placeholder = "master password";852 pwInp.setAttribute("autocomplete", "current-password");853854 855 const errEl = document.createElement("div");856 errEl.className = "locked-error";857 errEl.style.display = "none";858859 860 const unlockBtn = document.createElement("button");861 unlockBtn.className = "locked-btn";862 unlockBtn.type = "button";863 unlockBtn.textContent = "unlock";864865 async function tryUnlock() {866 const pw = pwInp.value.trim();867 if (!pw) { pwInp.focus(); return; }868 unlockBtn.disabled = true;869 unlockBtn.textContent = "unlocking…";870 errEl.style.display = "none";871 try {872 const res = await chrome.runtime.sendMessage({873 type: "UNLOCK",874 payload: { password: pw },875 });876 if ((res as { ok?: boolean }).ok) {877 dismiss();878 options.onUnlocked?.();879 } else {880 errEl.textContent = "incorrect password";881 errEl.style.display = "block";882 pwInp.value = "";883 pwInp.focus();884 unlockBtn.disabled = false;885 unlockBtn.textContent = "unlock";886 }887 } catch {888 errEl.textContent = "could not reach Peach";889 errEl.style.display = "block";890 unlockBtn.disabled = false;891 unlockBtn.textContent = "unlock";892 }893 }894895 pwInp.addEventListener("keydown", (e) => { if (e.key === "Enter") void tryUnlock(); });896 unlockBtn.addEventListener("click", () => void tryUnlock());897898 wrap.append(head, pwInp, errEl, unlockBtn);899 container.appendChild(wrap);900 requestAnimationFrame(() => pwInp.focus());901 }902903 function handleKeyDown(e: KeyboardEvent) {904 if (options.locked) {905 if (e.key === "Escape") { e.preventDefault(); dismiss(true); }906 return;907 }908 if (currentMode === "create" || currentMode === "success") {909 if (e.key === "Escape") { e.preventDefault(); dismiss(true); }910 return;911 }912 const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");913 const count = allEntryEls.length;914 if (document.activeElement === searchInput) {915 if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }916 if (count === 0) return;917 if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;918 }919 if (count === 0) return;920 switch (e.key) {921 case "ArrowDown": e.preventDefault(); setFocused(focusedIndex < count - 1 ? focusedIndex + 1 : 0); break;922 case "ArrowUp": e.preventDefault(); setFocused(focusedIndex > 0 ? focusedIndex - 1 : count - 1); break;923 case "Enter": {924 if (focusedIndex >= 0) {925 const el = allEntryEls[focusedIndex] as HTMLElement;926 const id = el.dataset.id;927 if (id) { onSelect(id); dismiss(); }928 else if (el.classList.contains("is-new") || !id) {929 930 showCreateForm();931 }932 }933 break;934 }935 case "Escape": case "Tab": e.preventDefault(); dismiss(true); break;936 }937 }938939 document.addEventListener("keydown", handleKeyDown);940941 document.body.appendChild(host);942 if (options.locked) {943 renderLocked();944 } else {945 renderList();946 }947948 function updatePosition() {949 const r = anchorEl.getBoundingClientRect();950 host.style.top = `${r.bottom + window.scrollY + 5}px`;951 host.style.left = `${r.left + window.scrollX}px`;952 host.style.width = `${Math.max(r.width, 260)}px`;953 }954 const stopObserving = observeAnchorPosition(anchorEl, updatePosition);955956 function dismiss(userAction = false) {957 stopObserving();958 host.remove();959 document.removeEventListener("click", outsideClick, { capture: true });960 document.removeEventListener("keydown", handleKeyDown);961 if (userAction) options.onDismiss?.();962 }963964 function outsideClick(ev: MouseEvent) {965 const path = ev.composedPath();966 967 968 969 if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);970 }971 972 setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);973974 return dismiss;975}