fb549556f757

add silver platter (State 1) for single-match — full-width Fill button with ✓ flash micro-confirmation, secondary copy ⓤ / copy ⓟ / dismiss row, Escape-to-dismiss handling

time
2026-04-26 18:15 UTC · 6 months ago
machine
koh2
parent
7aa9d2bbb445
1 file src/content/inject.ts+183 −2
src/content/inject.ts+183 −2
@@ -524,6 +524,64 @@524524   .success-text { font-size: 12.5px; color: var(--peach-text); font-weight: 500; }525525   .success-sub { font-size: 10px; color: var(--peach-text-dim); font-family:"IBM Plex Mono",monospace; }526526 527+  /* Silver platter (State 1 — single match) */528+  .silver-platter {529+    padding: 14px 14px 12px;530+    display: flex; flex-direction: column; gap: 6px;531+  }532+  .sp-title {533+    font-size: 14px; font-weight: 500; color: var(--peach-text);534+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;535+    line-height: 1.25;536+  }537+  .sp-username {538+    font-size: 12px; color: var(--peach-text-dim);539+    font-family: "IBM Plex Mono", monospace;540+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;541+    margin-bottom: 6px;542+  }543+  .sp-fill-btn {544+    width: 100%; padding: 11px; border-radius: 9px; border: none;545+    background: var(--peach-accent); color: #fff;546+    font-size: 13px; font-weight: 500; cursor: pointer;547+    font-family: Inter, sans-serif; letter-spacing: 0.01em;548+    display: flex; align-items: center; justify-content: center; min-height: 38px;549+    transition: filter 120ms ease, transform 80ms ease, box-shadow 120ms ease, background 120ms ease;550+  }551+  .sp-fill-btn:hover {552+    filter: brightness(1.1);553+    box-shadow: 0 4px 20px var(--peach-glow, transparent);554+    transform: translateY(-1px);555+  }556+  .sp-fill-btn:active { transform: translateY(0); filter: brightness(0.95); }557+  .sp-fill-btn.filled {558+    background: #16a34a; transform: none; filter: none;559+    box-shadow: 0 4px 16px rgba(22,163,74,0.35);560+    cursor: default;561+  }562+  .sp-fill-btn:disabled { cursor: default; }563+  .sp-actions {564+    display: flex; gap: 4px; justify-content: space-between; align-items: center;565+    margin-top: 4px;566+  }567+  .sp-action-btn {568+    flex: 1; height: 28px; border: none; border-radius: 6px;569+    background: transparent; color: var(--peach-text-dim);570+    font-size: 12px; cursor: pointer;571+    transition: background 120ms ease, color 120ms ease;572+    font-family: Inter, sans-serif; letter-spacing: 0.04em;573+    display: inline-flex; align-items: center; justify-content: center; gap: 4px;574+  }575+  .sp-action-btn:hover:not(:disabled) {576+    background: color-mix(in srgb, var(--peach-text) 5%, transparent);577+    color: var(--peach-text);578+  }579+  .sp-action-btn:disabled { opacity: 0.3; cursor: default; }580+  .sp-action-btn.flashed {581+    color: var(--peach-accent);582+    background: color-mix(in srgb, var(--peach-accent) 12%, transparent);583+  }584+527585   /* Locked state */528586   .locked-wrap {529587     display: flex; flex-direction: column; align-items: stretch;@@ -600,7 +658,7 @@600658 601659   let query = "";602660   let focusedIndex = -1;603-  let currentMode: "list" | "create" | "success" = "list";661+  let currentMode: "list" | "create" | "success" | "silver-platter" = "list";604662   let searchInput: HTMLInputElement | null = null;605663 606664   // Pre-capture the anchor field value for username prefill@@ -898,6 +956,123 @@898956     setTimeout(() => dismiss(), 1600);899957   }900958 959+  function flashActionBtn(btn: HTMLButtonElement) {960+    btn.classList.add("flashed");961+    setTimeout(() => btn.classList.remove("flashed"), 700);962+  }963+964+  function renderSilverPlatter(entry: SelectorEntry) {965+    container.innerHTML = "";966+    searchInput = null;967+    focusedIndex = -1;968+    currentMode = "silver-platter";969+970+    const domHdr = document.createElement("div");971+    domHdr.className = "domain-header";972+    const eyebrow = document.createElement("div");973+    eyebrow.className = "domain-eyebrow";974+    eyebrow.textContent = "Saved for this site";975+    const domName = document.createElement("div");976+    domName.className = "domain-name";977+    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");978+    domHdr.append(eyebrow, domName);979+    container.appendChild(domHdr);980+981+    const platter = document.createElement("div");982+    platter.className = "silver-platter";983+984+    const titleEl = document.createElement("div");985+    titleEl.className = "sp-title";986+    titleEl.textContent = entry.title;987+    platter.appendChild(titleEl);988+989+    if (entry.username) {990+      const userEl = document.createElement("div");991+      userEl.className = "sp-username";992+      userEl.textContent = entry.username;993+      platter.appendChild(userEl);994+    }995+996+    const fillBtn = document.createElement("button");997+    fillBtn.className = "sp-fill-btn";998+    fillBtn.type = "button";999+    fillBtn.textContent = "Fill credentials";1000+    fillBtn.addEventListener("click", () => {1001+      if (fillBtn.disabled) return;1002+      fillBtn.disabled = true;1003+      fillBtn.classList.add("filled");1004+      fillBtn.innerHTML = checkSvgStr();1005+      // Fire the fill, then exit via the standard exit animation. The fill1006+      // itself is async (handled in index.ts) — by 400ms the form is filled1007+      // and the popup begins its exit. Total dwell from click to host1008+      // removal: 400ms + 130ms (exit animation) = 530ms.1009+      onSelect(entry.id);1010+      setTimeout(() => dismiss(), 400);1011+    });1012+    platter.appendChild(fillBtn);1013+1014+    const actions = document.createElement("div");1015+    actions.className = "sp-actions";1016+1017+    const copyU = document.createElement("button");1018+    copyU.className = "sp-action-btn";1019+    copyU.type = "button";1020+    copyU.title = "Copy username";1021+    copyU.textContent = "copy ⓤ";1022+    copyU.disabled = !entry.username;1023+    copyU.addEventListener("click", async (e) => {1024+      e.stopPropagation();1025+      if (!entry.username) return;1026+      let alreadyWritten = false;1027+      try { await navigator.clipboard.writeText(entry.username); alreadyWritten = true; } catch {}1028+      // Mirror the popup's copy path so the COPY message can clear the1029+      // clipboard via the configured timeout. alreadyWritten lets the SW1030+      // skip a redundant write when the page-level write succeeded.1031+      await chrome.runtime.sendMessage({1032+        type: "COPY",1033+        payload: { text: entry.username, label: "Username", alreadyWritten },1034+      }).catch(() => {});1035+      flashActionBtn(copyU);1036+    });1037+    actions.appendChild(copyU);1038+1039+    const copyP = document.createElement("button");1040+    copyP.className = "sp-action-btn";1041+    copyP.type = "button";1042+    copyP.title = "Copy password";1043+    copyP.textContent = "copy ⓟ";1044+    copyP.addEventListener("click", async (e) => {1045+      e.stopPropagation();1046+      const resp = await chrome.runtime.sendMessage({1047+        type: "GET_ENTRY_FOR_FILL",1048+        payload: { id: entry.id },1049+      }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;1050+      if (!resp?.ok || !resp.data?.password) return;1051+      let alreadyWritten = false;1052+      try { await navigator.clipboard.writeText(resp.data.password); alreadyWritten = true; } catch {}1053+      await chrome.runtime.sendMessage({1054+        type: "COPY",1055+        payload: { text: resp.data.password, label: "Password", alreadyWritten },1056+      }).catch(() => {});1057+      flashActionBtn(copyP);1058+    });1059+    actions.appendChild(copyP);1060+1061+    const closeBtn = document.createElement("button");1062+    closeBtn.className = "sp-action-btn";1063+    closeBtn.type = "button";1064+    closeBtn.title = "Dismiss";1065+    closeBtn.textContent = "✕";1066+    closeBtn.addEventListener("click", (e) => {1067+      e.stopPropagation();1068+      dismiss(true);1069+    });1070+    actions.appendChild(closeBtn);1071+1072+    platter.appendChild(actions);1073+    container.appendChild(platter);1074+  }1075+9011076   function renderLocked() {9021077     container.innerHTML = "";9031078 @@ -1020,7 +1195,7 @@10201195       if (e.key === "Escape") { e.preventDefault(); dismiss(true); }10211196       return;10221197     }1023-    if (currentMode === "create" || currentMode === "success") {1198+    if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") {10241199       if (e.key === "Escape") { e.preventDefault(); dismiss(true); }10251200       return;10261201     }@@ -1073,6 +1248,12 @@10731248 10741249   if (options.locked) {10751250     renderLocked();1251+  } else if (entries.length === 1 && !options.isSignup) {1252+    // Silver platter (State 1): single domain match → focused fill view.1253+    // Multi-match goes through renderList instead. Signup mode always uses1254+    // the list because the user is choosing which existing identity to1255+    // surface — there's no single "fill credentials" interpretation.1256+    renderSilverPlatter(entries[0]!);10761257   } else {10771258     renderList();10781259   }

view file