e088bc468451
add card silver platter (State 7): extend AUTOFILL_QUERY with kind=card, new showCardSelector + setupCardAutofill wiring with icon injection on cc-number, fillCardForm integration, dismissal + fill-success lock reuse
- time
- 2026-04-26 19:01 UTC · 6 months ago
- machine
- koh2
- parent
- 217979dbf3f7
↓ peach-browser-e088bc46.tar.gzatlas at this save
files changed3 files
src/background/messages.ts+21 −1src/content/index.ts+122 −1src/content/inject.ts+264 −0src/background/messages.ts+21 −1
@@ -188,10 +188,30 @@188188 if (!vault.isUnlocked()) {189189 return { ok: false, error: "VAULT_LOCKED" };190190 }191- const { domain, includeAll } = msg.payload as {191+ const { domain, includeAll, kind } = msg.payload as {192192 domain: string;193193 includeAll?: boolean;194+ kind?: "login" | "card";194195 };196+ // kind="card": return saved cards as compact summaries for the197+ // inline card silver platter. Cards are not domain-scoped — every198+ // saved card is a candidate on every checkout page — so domain is199+ // ignored when kind === "card".200+ if (kind === "card") {201+ const cards = vault.getEntries().filter((e) => e.type === "card");202+ return {203+ ok: true,204+ data: cards.map((e) => ({205+ id: e.id,206+ title: e.cardNickname || e.title,207+ cardholderName: e.cardholderName,208+ last4: e.last4,209+ expMonth: e.expMonth,210+ expYear: e.expYear,211+ network: e.network,212+ })),213+ };214+ }195215 const matches = includeAll196216 ? vault197217 .getEntries()src/content/index.ts+122 −1
@@ -1,4 +1,5 @@11 import {2+ detectCardForm,23 detectLoginFields,34 getDeepActiveInput,45 getPageDomain,@@ -7,13 +8,15 @@78 isVisible,89 } from "./detect";910 import {11+ CardSelectorEntry,1012 injectFieldIcon,1113 observeAnchorPosition,1214 SelectorEntry,1315 showAutofillSelector,16+ showCardSelector,1417 showRememberSiteToast,1518 } from "./inject";16-import { fillFields } from "./fill";19+import { fillCardForm, fillFields } from "./fill";1720 import { setupSaveDetection } from "./save";1821 import { contentMLDetector } from "./content-ml-detector";1922 import { detectPasswordPolicy } from "./policy";@@ -677,6 +680,7 @@677680 678681 setupMutationObserver();679682 setupSaveDetection();683+setupCardAutofill();680684 681685 // SPA navigation reset: the content script does not reload across in-app682686 // route changes (history.pushState, hash navigation, framework routers).@@ -709,6 +713,123 @@709713 setTimeout(() => initialScan(aggressionLevel, false), 500);710714 setTimeout(() => initialScan(aggressionLevel, true), 1500);711715 })();716+717+// ── Card autofill (State 7) ─────────────────────────────────────────718+// Card forms get a parallel injection path. The login icon-injection gate719+// in reattachListeners deliberately skips non-text fields, so we wire720+// cards independently rather than retrofit AUTOFILL_FIELD_TYPES — keeping721+// card detection contained makes the boundary easy to follow.722+const processedCardInputs = new WeakMap<HTMLInputElement, () => void>();723+let currentCardDismiss: (() => void) | null = null;724+let currentCardAnchor: HTMLInputElement | null = null;725+let lastDismissedCardField: HTMLInputElement | null = null;726+727+async function fetchSavedCards(): Promise<CardSelectorEntry[]> {728+ const resp = await chrome.runtime.sendMessage({729+ type: "AUTOFILL_QUERY",730+ payload: { domain: getPageDomain(), kind: "card" },731+ }).catch(() => null);732+ const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;733+ return r?.ok ? (r.data ?? []) : [];734+}735+736+async function openCardSelector(anchor: HTMLInputElement): Promise<void> {737+ if (!canOpen()) return;738+ if (currentCardDismiss && currentCardAnchor === anchor) return;739+ if (anchor === lastDismissedCardField) return;740+ if (anchor !== lastDismissedCardField) lastDismissedCardField = null;741+ if (wasAlreadyFilled(anchor)) return;742+743+ const cards = await fetchSavedCards();744+ if (cards.length === 0) return; // no saved cards → nothing to offer745+746+ const cardForm = detectCardForm();747+ if (!cardForm?.number) return;748+749+ currentCardDismiss?.();750+ currentCardAnchor = anchor;751+ currentCardDismiss = showCardSelector(752+ anchor,753+ cards,754+ {755+ domain: getPageDomain(),756+ onFill: (entry) => {757+ if (!cardForm) return;758+ fillCardForm(cardForm, {759+ cardNumber: entry.cardNumber ?? "",760+ expMonth: entry.expMonth ?? "",761+ expYear: entry.expYear ?? "",762+ cvc: entry.cvc ?? "",763+ cardholderName: entry.cardholderName ?? "",764+ });765+ // All four card field types share the same form — recording the766+ // number field's form is sufficient to suppress reopens on any of767+ // them via wasAlreadyFilled().768+ recordFillSuccess(cardForm.number, cardForm.cvc, cardForm.expiryCombo, cardForm.name);769+ currentCardDismiss = null;770+ currentCardAnchor = null;771+ },772+ onDismiss: () => {773+ lastDismissedCardField = anchor;774+ startDismissLock();775+ currentCardDismiss = null;776+ currentCardAnchor = null;777+ },778+ }779+ );780+}781+782+function injectCardIcon(field: HTMLInputElement): void {783+ if (processedCardInputs.has(field)) return;784+ const cleanup = injectFieldIcon(field, () => {785+ currentCardDismiss?.();786+ currentCardDismiss = null;787+ lastDismissedCardField = null;788+ dismissLock = false;789+ if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }790+ clearFillStateForExplicitSummon(field);791+ field.focus({ preventScroll: true });792+ void openCardSelector(field);793+ });794+ processedCardInputs.set(field, cleanup);795+}796+797+function setupCardAutofill(): void {798+ function scan() {799+ const cardForm = detectCardForm();800+ if (!cardForm?.number) return;801+ if (!isVisible(cardForm.number)) return;802+ injectCardIcon(cardForm.number);803+ }804+805+ // Initial detection sweep — same retry cadence as login fields so806+ // late-hydrating SPA checkout pages get caught.807+ setTimeout(scan, 0);808+ setTimeout(scan, 500);809+ setTimeout(scan, 1500);810+811+ // Re-scan on DOM mutation (separate from the login MutationObserver812+ // because card forms typically appear on a single navigation event,813+ // not field-by-field — coarser observation is sufficient here).814+ let pending = false;815+ new MutationObserver(() => {816+ if (pending) return;817+ pending = true;818+ setTimeout(() => { pending = false; scan(); }, 200);819+ }).observe(document.body, { childList: true, subtree: true });820+821+ // Field-focus path: on focus of a cc-number input, ensure icon is822+ // present and consider opening the selector when the user explicitly823+ // clicks into the card number field.824+ document.addEventListener("focusin", (e) => {825+ const target = e.target;826+ if (!(target instanceof HTMLInputElement)) return;827+ const cardForm = detectCardForm();828+ if (cardForm?.number === target) {829+ injectCardIcon(target);830+ }831+ }, true);832+}712833 713834 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {714835 // Ignore anything not originating from our own background/service worker.src/content/inject.ts+264 −0
@@ -582,6 +582,37 @@582582 background: color-mix(in srgb, var(--peach-accent) 12%, transparent);583583 }584584 585+ /* Card silver platter (State 7) — reuses .sp-* primary action classes */586+ .card-platter {587+ padding: 12px 14px 10px;588+ display: flex; flex-direction: column; gap: 4px;589+ }590+ .cp-network {591+ font-size: 10px; font-weight: 600;592+ color: var(--peach-accent);593+ letter-spacing: 0.08em; text-transform: uppercase;594+ font-family: Inter, sans-serif;595+ }596+ .cp-nickname {597+ font-size: 14px; font-weight: 500; color: var(--peach-text);598+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;599+ line-height: 1.2;600+ }601+ .cp-last4 {602+ font-size: 12px; color: var(--peach-text-dim);603+ font-family: "IBM Plex Mono", monospace;604+ letter-spacing: 0.04em;605+ margin-top: 2px;606+ }607+ .cp-meta {608+ font-size: 11px; color: var(--peach-text-dim);609+ font-family: "IBM Plex Mono", monospace;610+ margin-top: 2px;611+ display: flex; gap: 10px; align-items: center;612+ }613+ .cp-meta-sep { opacity: 0.5; }614+ .cp-fill-wrap { margin-top: 10px; }615+585616 /* Locked state */586617 .locked-wrap {587618 display: flex; flex-direction: column; align-items: stretch;@@ -1291,6 +1322,239 @@12911322 if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);12921323 }12931324 // capture: true so pages that call stopPropagation() on click don't block our dismiss handler1325+ setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);1326+1327+ return dismiss;1328+}1329+1330+export interface CardSelectorEntry {1331+ id: string;1332+ title: string;1333+ cardholderName?: string;1334+ last4?: string;1335+ expMonth?: string;1336+ expYear?: string;1337+ network?: import("../shared/types").CardNetwork;1338+}1339+1340+interface CardSelectorOptions {1341+ domain?: string;1342+ /** Caller-provided fill — receives the full CardEntry payload from the SW. */1343+ onFill: (entry: import("../shared/types").VaultEntry) => void;1344+ /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */1345+ onDismiss?: () => void;1346+}1347+1348+/**1349+ * State 7 — card silver platter. Single-card focused fill view rendered1350+ * over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the1351+ * theme tokens, and the .sp-fill-btn / .sp-actions primary action classes1352+ * so card and login flows render identically. Multi-card list view is a1353+ * follow-up; today this picks the first card.1354+ */1355+export function showCardSelector(1356+ anchorEl: HTMLInputElement,1357+ cards: CardSelectorEntry[],1358+ options: CardSelectorOptions1359+): () => void {1360+ if (cards.length === 0) return () => {};1361+ const card = cards[0]!;1362+1363+ const host = document.createElement("div");1364+ host.style.cssText = "position: absolute; z-index: 2147483647;";1365+ host.style.setProperty("--peach-accent", accentHex);1366+ host.style.setProperty("--peach-accent-dim", withAlpha(accentHex, "28"));1367+ host.style.setProperty("--peach-glow", withAlpha(accentHex, "18"));1368+ host.style.setProperty("--peach-border-accent", withAlpha(accentHex, "30"));1369+ const shadow = host.attachShadow({ mode: "closed" });1370+1371+ const style = shadow.appendChild(document.createElement("style"));1372+ style.textContent = SELECTOR_STYLES;1373+1374+ const rect = anchorEl.getBoundingClientRect();1375+ host.style.top = `${rect.bottom + window.scrollY + 5}px`;1376+ host.style.left = `${rect.left + window.scrollX}px`;1377+ host.style.width = `${Math.max(rect.width, 320)}px`;1378+1379+ const container = shadow.appendChild(document.createElement("div"));1380+ container.className = "peach-inline-popup";1381+ container.setAttribute("data-theme", popupTheme);1382+1383+ // Domain header — eyebrow communicates the card context, not the site1384+ const domHdr = document.createElement("div");1385+ domHdr.className = "domain-header";1386+ const eyebrow = document.createElement("div");1387+ eyebrow.className = "domain-eyebrow";1388+ eyebrow.textContent = "Pay with Peach";1389+ const domName = document.createElement("div");1390+ domName.className = "domain-name";1391+ domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");1392+ domHdr.append(eyebrow, domName);1393+ container.appendChild(domHdr);1394+1395+ const platter = document.createElement("div");1396+ platter.className = "card-platter";1397+1398+ if (card.network && card.network !== "unknown") {1399+ const net = document.createElement("div");1400+ net.className = "cp-network";1401+ net.textContent = card.network;1402+ platter.appendChild(net);1403+ }1404+1405+ const nick = document.createElement("div");1406+ nick.className = "cp-nickname";1407+ nick.textContent = card.title;1408+ platter.appendChild(nick);1409+1410+ if (card.last4) {1411+ const l4 = document.createElement("div");1412+ l4.className = "cp-last4";1413+ l4.textContent = `•••• ${card.last4}`;1414+ platter.appendChild(l4);1415+ }1416+1417+ const meta = document.createElement("div");1418+ meta.className = "cp-meta";1419+ if (card.cardholderName) {1420+ const name = document.createElement("span");1421+ name.textContent = card.cardholderName;1422+ meta.appendChild(name);1423+ }1424+ if (card.expMonth && card.expYear) {1425+ if (card.cardholderName) {1426+ const sep = document.createElement("span");1427+ sep.className = "cp-meta-sep";1428+ sep.textContent = "·";1429+ meta.appendChild(sep);1430+ }1431+ const exp = document.createElement("span");1432+ const yy = card.expYear.length === 4 ? card.expYear.slice(-2) : card.expYear;1433+ exp.textContent = `${card.expMonth.padStart(2, "0")}/${yy}`;1434+ meta.appendChild(exp);1435+ }1436+ if (meta.childElementCount > 0) platter.appendChild(meta);1437+1438+ const fillWrap = document.createElement("div");1439+ fillWrap.className = "cp-fill-wrap";1440+ const fillBtn = document.createElement("button");1441+ fillBtn.className = "sp-fill-btn";1442+ fillBtn.type = "button";1443+ fillBtn.textContent = "Fill card details";1444+ fillBtn.addEventListener("click", async () => {1445+ if (fillBtn.disabled) return;1446+ fillBtn.disabled = true;1447+ fillBtn.classList.add("filled");1448+ fillBtn.innerHTML = checkSvgStr();1449+ const resp = await chrome.runtime.sendMessage({1450+ type: "GET_ENTRY_FOR_FILL",1451+ payload: { id: card.id },1452+ }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;1453+ if (resp?.ok && resp.data) options.onFill(resp.data);1454+ setTimeout(() => dismiss(), 400);1455+ });1456+ fillWrap.appendChild(fillBtn);1457+ platter.appendChild(fillWrap);1458+1459+ // Secondary actions — copy number, copy CVC, dismiss1460+ const actions = document.createElement("div");1461+ actions.className = "sp-actions";1462+1463+ function flashBtn(b: HTMLButtonElement) {1464+ b.classList.add("flashed");1465+ setTimeout(() => b.classList.remove("flashed"), 700);1466+ }1467+1468+ async function fetchFullCard(): Promise<import("../shared/types").VaultEntry | null> {1469+ const resp = await chrome.runtime.sendMessage({1470+ type: "GET_ENTRY_FOR_FILL",1471+ payload: { id: card.id },1472+ }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;1473+ return resp?.ok && resp.data ? resp.data : null;1474+ }1475+1476+ const copyNum = document.createElement("button");1477+ copyNum.className = "sp-action-btn";1478+ copyNum.type = "button";1479+ copyNum.title = "Copy card number";1480+ copyNum.textContent = "copy #";1481+ copyNum.addEventListener("click", async (e) => {1482+ e.stopPropagation();1483+ const full = await fetchFullCard();1484+ if (!full?.cardNumber) return;1485+ let alreadyWritten = false;1486+ try { await navigator.clipboard.writeText(full.cardNumber); alreadyWritten = true; } catch {}1487+ await chrome.runtime.sendMessage({1488+ type: "COPY",1489+ payload: { text: full.cardNumber, label: "Card number", alreadyWritten },1490+ }).catch(() => {});1491+ flashBtn(copyNum);1492+ });1493+ actions.appendChild(copyNum);1494+1495+ const copyCvc = document.createElement("button");1496+ copyCvc.className = "sp-action-btn";1497+ copyCvc.type = "button";1498+ copyCvc.title = "Copy CVC";1499+ copyCvc.textContent = "copy CVC";1500+ copyCvc.addEventListener("click", async (e) => {1501+ e.stopPropagation();1502+ const full = await fetchFullCard();1503+ if (!full?.cvc) return;1504+ let alreadyWritten = false;1505+ try { await navigator.clipboard.writeText(full.cvc); alreadyWritten = true; } catch {}1506+ await chrome.runtime.sendMessage({1507+ type: "COPY",1508+ payload: { text: full.cvc, label: "CVC", alreadyWritten },1509+ }).catch(() => {});1510+ flashBtn(copyCvc);1511+ });1512+ actions.appendChild(copyCvc);1513+1514+ const closeBtn = document.createElement("button");1515+ closeBtn.className = "sp-action-btn";1516+ closeBtn.type = "button";1517+ closeBtn.title = "Dismiss";1518+ closeBtn.textContent = "✕";1519+ closeBtn.addEventListener("click", (e) => {1520+ e.stopPropagation();1521+ dismiss(true);1522+ });1523+ actions.appendChild(closeBtn);1524+1525+ platter.appendChild(actions);1526+ container.appendChild(platter);1527+1528+ document.body.appendChild(host);1529+ function updatePosition() {1530+ const r = anchorEl.getBoundingClientRect();1531+ host.style.top = `${r.bottom + window.scrollY + 5}px`;1532+ host.style.left = `${r.left + window.scrollX}px`;1533+ host.style.width = `${Math.max(r.width, 320)}px`;1534+ }1535+ const stopObserving = observeAnchorPosition(anchorEl, updatePosition);1536+1537+ function onKeyDown(e: KeyboardEvent) {1538+ if (e.key === "Escape") { e.preventDefault(); dismiss(true); }1539+ }1540+ document.addEventListener("keydown", onKeyDown);1541+1542+ let exitScheduled = false;1543+ function dismiss(userAction = false) {1544+ if (exitScheduled) return;1545+ exitScheduled = true;1546+ stopObserving();1547+ document.removeEventListener("click", outsideClick, { capture: true });1548+ document.removeEventListener("keydown", onKeyDown);1549+ if (userAction) options.onDismiss?.();1550+ container.classList.add("is-exiting");1551+ setTimeout(() => host.remove(), 130);1552+ }1553+1554+ function outsideClick(ev: MouseEvent) {1555+ const path = ev.composedPath();1556+ if (!path.includes(host) && !path.includes(anchorEl)) dismiss(true);1557+ }12941558 setTimeout(() => document.addEventListener("click", outsideClick, { capture: true }), 0);12951559 12961560 return dismiss;