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
3 files src/background/messages.ts+21 −1src/content/index.ts+122 −1src/content/inject.ts+264 −0
src/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()

view file

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.

view file

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;

view file