Entry.tsx

73.1 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516interface EntryProps {17  entry: VaultEntry | null;18  /**19   * Current active tab's full URL (https only). Empty string when unavailable20   * — Capacitor, an extension page, or a chrome:///about: URL. The URL21   * editor uses this to render an "add current site" shortcut.22   */23  currentTabUrl?: string;24  onDelete: () => void;25  /** Called after any inline save so App can reload entries */26  onFieldSaved: () => void | Promise<void>;27  onEscape?: () => void;28  entries?: VaultEntry[];29}3031export function EntryView(props: EntryProps) {32  const [editingField, setEditingField] = createSignal<EditableField>(null);33  const [draft, setDraft] = createSignal("");34  const [saving, setSaving] = createSignal(false);35  const [showPassword, setShowPassword] = createSignal(false);36  const [showGenerator, setShowGenerator] = createSignal(false);37  const [copied, setCopied] = createSignal<string | null>(null);38  // Local draft for the additional URLs list, edited only while the URL field39  // is in inline-edit mode. Saved alongside `url` when the user confirms.40  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);41  const [editingTags, setEditingTags] = createSignal(false);42  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);43  const [tagInput, setTagInput] = createSignal("");44  const [showCardNumber, setShowCardNumber] = createSignal(false);45  const [showCardCvc, setShowCardCvc] = createSignal(false);46  const [totpTick, setTotpTick] = createSignal(0);47  const [confirmDelete, setConfirmDelete] = createSignal(false);48  const [totpHot, setTotpHot] = createSignal(false);49  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);50  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);51  // Auto-open advanced when entry already has passkey or canary configured52  const [showAdvanced, setShowAdvanced] = createSignal(false);53  const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);54  let usernameInlineInput: HTMLInputElement | undefined;55  let emailInlineInput: HTMLInputElement | undefined;56  let usernameIdentityEl: HTMLDivElement | undefined;57  let emailIdentityEl: HTMLDivElement | undefined;58  let lastSavedNote: string | null = null;59  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;60  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });6162  function noteExec(cmd: string, val?: string) {63    document.execCommand(cmd, false, val);64    noteEditorEl()?.focus();65  }6667  async function saveNoteInline(html: string) {68    const entry = props.entry;69    if (!entry) return;70    const currentStored = entry.notes ?? "";71    if (html === currentStored) return;72    if (lastSavedNote !== null && html === lastSavedNote) return;73    lastSavedNote = html;74    setSaving(true);75    try {76      await sendMessage({77        type: "UPDATE_ENTRY",78        payload: { id: entry.id, patch: { notes: html } },79      });80      triggerRing();81      await props.onFieldSaved();82    } catch {83      lastSavedNote = null;84    } finally {85      setSaving(false);86    }87  }8889  const allIdentities = createMemo(() => {90    const emailMap = new Map<string, number>();91    const usernameMap = new Map<string, number>();92    for (const e of props.entries ?? []) {93      const uname = e.username?.trim() ?? "";94      const em = e.email?.trim() ?? "";95      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);96      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);97    }98    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];99    const seen = new Set<string>();100    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {101      if (seen.has(v)) continue;102      seen.add(v);103      const ec = emailMap.get(v) ?? 0;104      const uc = usernameMap.get(v) ?? 0;105      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });106    }107    return result.sort((a, b) => b.count - a.count);108  });109110  const identitySuggestions = createMemo(() => {111    const field = editingField();112    if (field !== "username" && field !== "email") return [];113    const input = draft().toLowerCase();114    return allIdentities()115      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))116      .slice(0, 8);117  });118119  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);120  onCleanup(() => window.clearInterval(interval));121122  const displayDomain = createMemo(() =>123    props.entry?.url ? getDisplayDomain(props.entry.url) : ""124  );125126  createEffect(() => {127    const domain = displayDomain();128    setFaviconSrc(domain ? faviconUrl(domain) : null);129  });130131  // Reset transient prompts (trash confirmation, inline edits, advanced panel)132  // when the selected entry changes so they don't carry over between entries.133  // Track the last id so a same-entry refresh (e.g. after saving a field)134  // doesn't blow away in-flight edit state — that race used to cancel135  // startEdit calls that ran right after onFieldSaved.136  let lastEntryId: string | null = null;137  createEffect(() => {138    const id = props.entry?.id ?? null;139    if (id === lastEntryId) return;140    lastEntryId = id;141    setConfirmDelete(false);142    setEditingField(null);143    setEditingTags(false);144    setShowAdvanced(false);145  });146147  const totpState = createMemo(() => {148    void totpTick();149    if (!props.entry?.totp) return null;150    return generateTOTP(props.entry.totp);151  });152153  let prevTotpCode: string | undefined;154  createEffect(() => {155    const state = totpState();156    if (!state) { prevTotpCode = undefined; return; }157    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {158      setTotpHot(true);159      window.setTimeout(() => setTotpHot(false), 3000);160    }161    prevTotpCode = state.code;162  });163164  const totpProgress = createMemo(() => {165    const state = totpState();166    if (!state) return 0;167    return ((state.remaining - 1) / 29) * 100;168  });169170  createEffect(() => {171    const shell = document.getElementById("hd-shell");172    if (!shell) return;173174    const state = totpState();175    if (!state) {176      delete shell.dataset.totpActive;177      shell.style.removeProperty("--peach-totp-progress");178      return;179    }180181    shell.dataset.totpActive = "true";182    shell.style.setProperty(183      "--peach-totp-progress",184      `${Math.max(0, totpProgress())}%`185    );186187    onCleanup(() => {188      delete shell.dataset.totpActive;189      shell.style.removeProperty("--peach-totp-progress");190    });191  });192193  const titleSizeClass = createMemo(() => {194    const length = props.entry?.title.length ?? 0;195    if (length >= 28) return "peach-entry-name--xxl";196    if (length >= 20) return "peach-entry-name--xl";197    if (length >= 14) return "peach-entry-name--lg";198    return "";199  });200  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);201202  // ── Inline editing ────────────────────────────────────────────────203204  function startEdit(field: EditableField, value: string) {205    setEditingField(field);206    setDraft(value);207    setIdentityHelperSuppressed(false);208    setShowGenerator(false);209    setConfirmDelete(false);210    if (field === "url") {211      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);212    }213  }214215  // Compare URLs by string after trim — vault.ts has its own canonicalisation216  // when saving, so the dedupe here is a UX-level "don't show the button if217  // we'd just add a duplicate" rather than a security check.218  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {219    const c = candidate.trim();220    if (!c) return true;221    if (primary.trim() === c) return true;222    return others.some((u) => u.trim() === c);223  }224225  function addCurrentSite(url: string) {226    if (!url) return;227    if (!draft().trim()) {228      setDraft(url);229      return;230    }231    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;232    setAdditionalUrlsDraft((arr) => [...arr, url]);233  }234235  async function saveField() {236    const field = editingField();237    const entry = props.entry;238    if (!field || !entry) return;239    // Don't trim passwords — spaces may be intentional240    let value = field === "password" ? draft() : draft().trim();241    if (field === "totp" && value.startsWith("otpauth://")) {242      const parsed = parseOtpauthURI(value);243      value = parsed?.secret ?? value;244    }245    if (field === "password" && entry.type !== "note" && !value) {246      return;247    }248    setSaving(true);249    try {250      const patch: Partial<VaultEntry> = { [field]: value };251      if (field === "url") {252        // Sanitization layer in vault.ts trims, dedupes, and drops empties.253        patch.additionalUrls = additionalUrlsDraft();254      }255      await sendMessage({256        type: "UPDATE_ENTRY",257        payload: { id: entry.id, patch },258      });259      triggerRing();260      setEditingField(null);261      setShowGenerator(false);262      await props.onFieldSaved();263    } catch {264      // silent — leave editing open265    } finally {266      setSaving(false);267    }268  }269270  function startEditTags() {271    setTagsDraft([...(props.entry?.tags ?? [])]);272    setTagInput("");273    setEditingTags(true);274  }275276  function addTag(value: string) {277    const n = value.toLowerCase().trim();278    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;279    setTagsDraft([...tagsDraft(), n]);280    setTagInput("");281  }282283  async function saveTags() {284    if (tagInput()) addTag(tagInput());285    const entry = props.entry;286    if (!entry) return;287    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;288    setEditingTags(false);289    setSaving(true);290    try {291      await sendMessage({292        type: "UPDATE_ENTRY",293        payload: { id: entry.id, patch: { tags: next } },294      });295      triggerRing();296      await props.onFieldSaved();297    } catch {298      // silent299    } finally {300      setSaving(false);301    }302  }303304  function cancelEdit() {305    setEditingField(null);306    setDraft("");307    setShowGenerator(false);308  }309310  // Switch from one identity slot to the other.311  //312  // Migration source is the SAVED entry value, never the live draft — using313  // the draft caused data loss when the user typed or cleared the input314  // before clicking +add (the migration would skip and a later Save would315  // overwrite the original email-shape value with whatever was in the input).316  //317  // Drafts only get committed when they are non-empty AND actually changed,318  // so an empty input never accidentally clears a saved field.319  async function addOtherIdentity(target: "username" | "email") {320    const entry = props.entry;321    if (!entry) return;322    const current = editingField();323    const draftValue = draft().trim();324    const savedUsername = entry.username ?? "";325    const savedEmail = entry.email ?? "";326327    if (current === "username" && target === "username") {328      // Legacy fix-up: saved username is email-shaped and no separate email329      // exists yet. Move it to entry.email and free the username slot.330      if (savedUsername.includes("@") && !entry.email) {331        setSaving(true);332        try {333          await sendMessage({334            type: "UPDATE_ENTRY",335            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },336          });337          await props.onFieldSaved();338        } finally {339          setSaving(false);340        }341      }342      startEdit("username", "");343      return;344    }345346    if (current === "username" && target === "email") {347      if (draftValue && draftValue !== savedUsername) {348        setSaving(true);349        try {350          await sendMessage({351            type: "UPDATE_ENTRY",352            payload: { id: entry.id, patch: { username: draftValue } },353          });354          await props.onFieldSaved();355        } finally {356          setSaving(false);357        }358      }359      startEdit("email", "");360      return;361    }362363    if (current === "email" && target === "username") {364      if (draftValue && draftValue !== savedEmail) {365        setSaving(true);366        try {367          await sendMessage({368            type: "UPDATE_ENTRY",369            payload: { id: entry.id, patch: { email: draftValue } },370          });371          await props.onFieldSaved();372        } finally {373          setSaving(false);374        }375      }376      startEdit("username", "");377      return;378    }379  }380381  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {382    switch (field) {383      case "title":384        return entry.title;385      case "url":386        return entry.url ?? "";387      case "username":388        return entry.username ?? "";389      case "email":390        return entry.email ?? "";391      case "password":392        return entry.password ?? "";393      case "notes":394        return entry.notes ?? "";395      case "totp":396        return entry.totp ?? "";397    }398  }399400  function handleEditorFocusOut(401    field: Exclude<EditableField, null>,402    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }403  ) {404    const nextTarget = event.relatedTarget;405    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;406407    // addOtherIdentity / startEdit may have already switched editingField before408    // focusout fires (SolidJS removes the old editor node synchronously, which409    // triggers blur with relatedTarget=null). Don't cancel the new edit.410    if (editingField() !== field) return;411412    const entry = props.entry;413    if (!entry) return;414415    const currentValue = fieldValue(entry, field);416    const nextValue = field === "password" ? draft() : draft().trim();417    if (nextValue === currentValue) {418      cancelEdit();419    }420  }421422  async function savePassword(password: string) {423    const entry = props.entry;424    if (!entry) return;425    setSaving(true);426    try {427      await sendMessage({428        type: "UPDATE_ENTRY",429        payload: { id: entry.id, patch: { password } },430      });431      triggerRing();432      setShowGenerator(false);433      await props.onFieldSaved();434    } catch {435      // silent436    } finally {437      setSaving(false);438    }439  }440441  // ── Copy ─────────────────────────────────────────────────────────442443  async function copyField(text: string, label: string, button?: HTMLButtonElement) {444    const ok = await popupCopy(text, label);445    if (ok) {446      const entry = props.entry;447      if (entry?.canary?.webhookUrl) {448        const trigger =449          label === "password"450            ? "password_copied"451            : label === "username"452              ? "username_copied"453              : undefined;454        if (trigger) {455          void sendMessage({456            type: "FIRE_CANARY_PING",457            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },458          });459        }460      }461      triggerRing();462      triggerPipPulse();463      setCopied(null);464      requestAnimationFrame(() => setCopied(label));465      if (button) {466        button.classList.remove("is-copied", "is-retreat");467        void button.offsetWidth;468        const drain = document.createElement("span");469        drain.className = "peach-copy-drain";470        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";471        drain.appendChild(bar);472        button.appendChild(drain);473        window.setTimeout(() => {474          button.classList.remove("is-copied");475          button.classList.add("is-retreat");476          drain.remove();477          window.setTimeout(() => button.classList.remove("is-retreat"), 160);478        }, 1800);479      }480      window.setTimeout(() => setCopied(null), 1800);481    }482  }483484  // ── Keyboard shortcuts ────────────────────────────────────────────485486  createEffect(() => {487    if (!props.entry) return;488    const handler = (e: KeyboardEvent) => {489      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;490      if (inInput) return;491      switch (e.key) {492        case "c": e.preventDefault();493          if (props.entry?.password) void copyField(props.entry.password, "password"); break;494        case "u": e.preventDefault();495          if (props.entry?.username) void copyField(props.entry.username, "username"); break;496        case "Escape": e.preventDefault(); props.onEscape?.(); break;497      }498    };499    window.addEventListener("keydown", handler);500    onCleanup(() => window.removeEventListener("keydown", handler));501  });502503  // ── Input ref focus helper ────────────────────────────────────────504505  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {506    requestAnimationFrame(() => { el.focus(); el.select?.(); });507  }508509  function onEditKeyDown(e: KeyboardEvent) {510    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {511      e.preventDefault();512      void saveField();513    }514    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }515  }516517  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {518    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;519    if (e.key === "ArrowDown" && listEl) {520      e.preventDefault();521      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();522      return;523    }524    onEditKeyDown(e);525  }526527  return (528    <>529      <div530        class="peach-ghost"531        aria-hidden="true"532        style="position:absolute;top:30px;left:12px;right:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:150px;font-style:italic;font-weight:400;color:var(--t0);opacity:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"533      >534        {props.entry?.title || "empty"}535      </div>536537      <div class="peach-stage" style="position:relative;">538        <Show when={props.entry} fallback={<EmptyVaultState />}>539          {(entry) => (540            <>541              {/* ── Breach strip ─────────────────────────────────── */}542              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>543                <div class="peach-breach-badge">544                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">545                    <path d="M6 1L11 10H1L6 1z" />546                    <line x1="6" y1="5" x2="6" y2="7.5" />547                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />548                  </svg>549                  <span style="flex:1">found in a known data breach</span>550                  <button551                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"552                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}553                  >554                    Change password555                  </button>556                </div>557              </Show>558559              {/* ── Title + trash ─────────────────────────────────── */}560              <div class="peach-entry-header">561                <div class="peach-entry-header-name">562                  <Show563                    when={editingField() === "title"}564                    fallback={565                      <button566                        class="peach-entry-name-button"567                        title="Click to edit title"568                        onClick={() => startEdit("title", entry().title)}569                      >570                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>571                          <span class="peach-entry-name-text">572                            <Show573                              when={useAnimatedTitle()}574                              fallback={entry().title}575                            >576                              <For each={entry().title.split("")}>577                                {(ch, i) => (578                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>579                                    {ch === " " ? "\u00A0" : ch}580                                  </span>581                                )}582                              </For>583                            </Show>584                          </span>585                        </h1>586                      </button>587                    }588                  >589                    <div590                      class="peach-inline-title-wrap"591                      onFocusOut={(event) => handleEditorFocusOut("title", event)}592                    >593                      <input594                        class="peach-inline-title-input"595                        value={draft()}596                        onInput={(e) => setDraft(e.currentTarget.value)}597                        onKeyDown={onEditKeyDown}598                        ref={autoFocus}599                        disabled={saving()}600                      />601                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />602                    </div>603                  </Show>604                </div>605606                {/* Subtle trash icon — hidden while editing title */}607                <Show when={editingField() !== "title"}>608                  <Show609                    when={confirmDelete()}610                    fallback={611                      <button612                        class="peach-entry-trash-btn"613                        onClick={() => setConfirmDelete(true)}614                        aria-label="Move to trash"615                        title="Delete entry"616                      >617                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">618                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />619                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />620                        </svg>621                      </button>622                    }623                  >624                    <div class="peach-entry-trash-confirm">625                      <span class="peach-entry-trash-confirm-label">trash?</span>626                      <button627                        class="peach-btn peach-btn--sm peach-btn--danger"628                        style="font-size:10px;padding:2px 8px;min-height:22px;"629                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}630                      >631                        yes632                      </button>633                      <button634                        class="peach-btn peach-btn--ghost peach-btn--sm"635                        style="font-size:10px;padding:2px 8px;min-height:22px;"636                        onClick={() => setConfirmDelete(false)}637                      >638                        no639                      </button>640                    </div>641                  </Show>642                </Show>643              </div>644645              {/* ── Domain / URL ─────────────────────────────────── */}646              <Show when={entry().type !== "note" && entry().type !== "card"}>647                <div class="peach-domain" style="position:relative;z-index:1;">648                  <Show649                    when={editingField() === "url"}650                    fallback={651                      <div652                        class="peach-domain-row peach-inline-clickable"653                        title="Click to edit URL"654                        onClick={() => startEdit("url", entry().url ?? "")}655                      >656                        <Show when={faviconSrc()}>657                          <img658                            src={faviconSrc()!}659                            width="16"660                            height="16"661                            alt=""662                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"663                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}664                          />665                        </Show>666                        <span class="peach-domain-value">667                          {displayDomain() || (entry().url ? entry().url : "add a website url")}668                        </span>669                        <span class="peach-inline-edit-hint">edit</span>670                      </div>671                    }672                  >673                    <div674                      class="peach-inline-field-wrap peach-inline-field-wrap--url"675                      onFocusOut={(event) => handleEditorFocusOut("url", event)}676                    >677                      <div class="peach-inline-field-row">678                        <input679                          class="peach-inline-input"680                          type="url"681                          placeholder="https://"682                          value={draft()}683                          onInput={(e) => setDraft(e.currentTarget.value)}684                          onKeyDown={onEditKeyDown}685                          ref={autoFocus}686                          disabled={saving()}687                        />688                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />689                      </div>690                      <div class="peach-additional-urls">691                        <Show when={additionalUrlsDraft().length > 0}>692                          <span class="peach-label peach-additional-urls-label">also matches</span>693                        </Show>694                        <For each={additionalUrlsDraft()}>695                          {(value, i) => (696                            <div class="peach-additional-url-row">697                              <input698                                class="peach-inline-input"699                                type="url"700                                placeholder="https://"701                                value={value}702                                onInput={(e) => setAdditionalUrlsDraft((arr) => {703                                  const next = [...arr];704                                  next[i()] = e.currentTarget.value;705                                  return next;706                                })}707                                disabled={saving()}708                              />709                              <button710                                class="peach-additional-url-remove"711                                type="button"712                                aria-label="Remove URL"713                                onMouseDown={(e) => e.preventDefault()}714                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}715                                disabled={saving()}716                              >×</button>717                            </div>718                          )}719                        </For>720                        <button721                          class="peach-add-field-link peach-additional-urls-add"722                          type="button"723                          onMouseDown={(e) => e.preventDefault()}724                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}725                          disabled={saving()}726                        >727                          + add another url728                        </button>729                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>730                          <button731                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"732                            type="button"733                            title={props.currentTabUrl}734                            onMouseDown={(e) => e.preventDefault()}735                            onClick={() => addCurrentSite(props.currentTabUrl!)}736                            disabled={saving()}737                          >738                            + add current site739                          </button>740                        </Show>741                      </div>742                    </div>743                  </Show>744                </div>745              </Show>746747              {/* ── Tags — read-only chips (editing state lives at bottom) */}748              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>749                <div750                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"751                  onClick={startEditTags}752                  title="Click to edit tags"753                >754                  <For each={entry().tags!}>755                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}756                  </For>757                </div>758              </Show>759760              {/* ── Note toolbar + inline editor ─────────────────── */}761              <Show when={entry().type === "note"}>762                <div class="peach-entry-note-toolbar-row">763                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />764                </div>765                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">766                  <NoteEditorInline767                    entryKey={entry().id}768                    initialContent={entry().notes ?? ""}769                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}770                    onBlur={saveNoteInline}771                    autoFocus={false}772                  />773                </section>774              </Show>775776              {/* ── Card detail view ─────────────────────────────── */}777              <Show when={entry().type === "card"}>778                <section class="peach-fields" style="position:relative;z-index:1;">779                  {/* Card subtitle: •••• last4 */}780                  <Show when={entry().last4}>781                    <div class="peach-field-card" style="padding-bottom:4px;">782                      <span class="peach-label">783                        <Show when={entry().network && entry().network !== "unknown"}>784                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>785                        </Show>786                        {entry().cardNickname || `•••• ${entry().last4}`}787                      </span>788                    </div>789                  </Show>790791                  {/* Cardholder name */}792                  <div class="peach-field-card">793                    <span class="peach-label">cardholder name</span>794                    <div class="peach-field-value-row">795                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>796                      <Show when={entry().cardholderName}>797                        <button798                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}799                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}800                          aria-label="Copy cardholder name"801                        >802                          <CopyGlyph /><span class="peach-copy-sheen" />803                        </button>804                      </Show>805                    </div>806                  </div>807808                  {/* Card number */}809                  <div class="peach-field-card">810                    <span class="peach-label">card number</span>811                    <div class="peach-field-value-row">812                      <span class={`peach-field-value peach-card-number`}>813                        <Show when={showCardNumber()} fallback={814                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>815                        }>816                          <span>{entry().cardNumber ?? ""}</span>817                        </Show>818                      </span>819                      <div class="peach-field-actions">820                        <button821                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"822                          onClick={() => setShowCardNumber(!showCardNumber())}823                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}824                        >825                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>826                            <EyeClosedGlyph />827                          </Show>828                        </button>829                        <Show when={entry().cardNumber}>830                          <button831                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}832                            onClick={(e) => {833                              const cardNum = entry().cardNumber!;834                              if (entry().canary?.webhookUrl) {835                                void sendMessage({836                                  type: "FIRE_CANARY_PING",837                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },838                                });839                              }840                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);841                            }}842                            aria-label="Copy card number"843                          >844                            <CopyGlyph /><span class="peach-copy-sheen" />845                          </button>846                        </Show>847                      </div>848                    </div>849                  </div>850851                  {/* Expiry */}852                  <Show when={entry().expMonth || entry().expYear}>853                    <div class="peach-field-card">854                      <span class="peach-label">expiry</span>855                      <div class="peach-field-value-row">856                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>857                        <button858                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}859                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}860                          aria-label="Copy expiry"861                        >862                          <CopyGlyph /><span class="peach-copy-sheen" />863                        </button>864                      </div>865                    </div>866                  </Show>867868                  {/* CVC */}869                  <Show when={entry().cvc}>870                    <div class="peach-field-card">871                      <span class="peach-label">CVC</span>872                      <div class="peach-field-value-row">873                        <span class="peach-field-value">874                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>875                            <span>{entry().cvc}</span>876                          </Show>877                        </span>878                        <div class="peach-field-actions">879                          <button880                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"881                            onClick={() => setShowCardCvc(!showCardCvc())}882                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}883                          >884                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>885                              <EyeClosedGlyph />886                            </Show>887                          </button>888                          <button889                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}890                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}891                            aria-label="Copy CVC"892                          >893                            <CopyGlyph /><span class="peach-copy-sheen" />894                          </button>895                        </div>896                      </div>897                    </div>898                  </Show>899                </section>900              </Show>901902              {/* ── Login fields ─────────────────────────────────── */}903              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>904                <section class="peach-fields" style="position:relative;z-index:1;">905906                  {/* Username — primary identity slot. Auto-labels as "email"907                      when the stored value is email-shaped and no separate908                      email field exists (legacy data path). */}909                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>910                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>911                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>912                      <Show913                        when={editingField() === "username"}914                        fallback={915                          <div class="peach-field-value-row">916                            <span917                              class="peach-field-value peach-inline-clickable"918                              title="Click to edit"919                              onClick={() => startEdit("username", entry().username ?? "")}920                            >921                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}922                            </span>923                            <Show when={entry().username}>924                              <button925                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}926                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}927                                aria-label="Copy username"928                              >929                                <CopyGlyph /><span class="peach-copy-sheen" />930                              </button>931                            </Show>932                          </div>933                        }934                      >935                        <div936                          class="peach-inline-field-wrap"937                          onFocusOut={(event) => handleEditorFocusOut("username", event)}938                        >939                          <input940                            class="peach-inline-input"941                            value={draft()}942                            onInput={(e) => setDraft(e.currentTarget.value)}943                            onKeyDown={(e) => identityKeyDown(e, "username")}944                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}945                            disabled={saving()}946                            autocomplete="off"947                          />948                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />949                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>950                            <IdentitySuggestList951                              listRef={(el) => (usernameIdentityEl = el)}952                              inputRef={() => usernameInlineInput}953                              suggestions={identitySuggestions()}954                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}955                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}956                            />957                          </Show>958                          {/* +add the OTHER identity. If the slot is acting as959                              email (value has @, no separate email), offer960                              "+ add username" — clicking migrates the email961                              into entry.email. Otherwise offer "+ add email". */}962                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>963                            <button964                              class="peach-add-field-btn"965                              type="button"966                              onMouseDown={(e) => e.preventDefault()}967                              onClick={() => {968                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);969                                const hasSeparateEmail = !!entry().email;970                                if (isEmailShape && !hasSeparateEmail) {971                                  void addOtherIdentity("username");972                                } else {973                                  void addOtherIdentity("email");974                                }975                              }}976                            >977                              {(() => {978                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);979                                const hasSeparateEmail = !!entry().email;980                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";981                              })()}982                            </button>983                          </Show>984                        </div>985                      </Show>986                    </div>987                  </Show>988989                  {/* Email — secondary identity slot. Visible whenever a990                      separate email field is set, or while it is being edited. */}991                  <Show when={entry().email || editingField() === "email"}>992                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>993                      <span class="peach-label">email</span>994                      <Show995                        when={editingField() === "email"}996                        fallback={997                          <div class="peach-field-value-row">998                            <span999                              class="peach-field-value peach-inline-clickable"1000                              title="Click to edit"1001                              onClick={() => startEdit("email", entry().email ?? "")}1002                            >1003                              {entry().email}1004                            </span>1005                            <Show when={entry().email}>1006                              <button1007                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1008                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1009                                aria-label="Copy email"1010                              >1011                                <CopyGlyph /><span class="peach-copy-sheen" />1012                              </button>1013                            </Show>1014                          </div>1015                        }1016                      >1017                        <div1018                          class="peach-inline-field-wrap"1019                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1020                        >1021                          <input1022                            class="peach-inline-input"1023                            value={draft()}1024                            onInput={(e) => setDraft(e.currentTarget.value)}1025                            onKeyDown={(e) => identityKeyDown(e, "email")}1026                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1027                            disabled={saving()}1028                            autocomplete="email"1029                            type="email"1030                          />1031                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1032                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1033                            <IdentitySuggestList1034                              listRef={(el) => (emailIdentityEl = el)}1035                              inputRef={() => emailInlineInput}1036                              suggestions={identitySuggestions()}1037                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1038                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1039                            />1040                          </Show>1041                          <Show when={!entry().username || entry().username?.includes("@")}>1042                            <button1043                              class="peach-add-field-btn"1044                              type="button"1045                              onMouseDown={(e) => e.preventDefault()}1046                              onClick={() => void addOtherIdentity("username")}1047                            >1048                              + add username1049                            </button>1050                          </Show>1051                        </div>1052                      </Show>1053                    </div>1054                  </Show>10551056                  {/* Password */}1057                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1058                    <span class="peach-label">password</span>1059                    <Show1060                      when={editingField() === "password"}1061                      fallback={1062                        <div class="peach-field-value-row">1063                          <span1064                            class="peach-password-value"1065                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1066                            style="cursor:pointer;"1067                            title="Click to edit password"1068                            onClick={() => startEdit("password", entry().password ?? "")}1069                          >1070                            <Show1071                              when={showPassword()}1072                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1073                            >1074                              <span class="peach-password-text">{entry().password ?? ""}</span>1075                            </Show>1076                          </span>1077                          <div class="peach-field-actions">1078                            <button1079                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1080                              onClick={() => setShowPassword(!showPassword())}1081                              aria-label="Reveal password"1082                            >1083                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1084                                <EyeClosedGlyph />1085                              </Show>1086                            </button>1087                            <button1088                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1089                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1090                              aria-label="Copy password"1091                            >1092                              <CopyGlyph /><span class="peach-copy-sheen" />1093                            </button>1094                          </div>1095                        </div>1096                      }1097                    >1098                      {/* ── Edit mode: type / paste / generate ── */}1099                      <div1100                        class="peach-inline-field-wrap"1101                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1102                      >1103                        <div class="peach-password-edit-row">1104                          <input1105                            class="peach-inline-input"1106                            type={showPassword() ? "text" : "password"}1107                            placeholder="paste or type a password"1108                            value={draft()}1109                            onInput={(e) => setDraft(e.currentTarget.value)}1110                            onKeyDown={(e) => {1111                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1112                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1113                            }}1114                            ref={autoFocus}1115                            disabled={saving()}1116                            autocomplete="new-password"1117                          />1118                          <button1119                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1120                            type="button"1121                            onClick={() => setShowPassword(!showPassword())}1122                            aria-label={showPassword() ? "Hide" : "Show"}1123                          >1124                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1125                              <EyeClosedGlyph />1126                            </Show>1127                          </button>1128                        </div>1129                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1130                        <button1131                          class="peach-generate-toggle-link"1132                          type="button"1133                          onClick={() => setShowGenerator(!showGenerator())}1134                        >1135                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1136                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1137                            <path d="M9.5 2.5H11V1" />1138                          </svg>1139                          {showGenerator() ? "hide generator" : "generate"}1140                        </button>1141                        <Show when={showGenerator()}>1142                          <GeneratorDrawer1143                            open={true}1144                            compact={true}1145                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1146                          />1147                        </Show>1148                      </div>1149                    </Show>1150                  </div>11511152                </section>1153              </Show>11541155              {/* ── Notes (on login entries) ──────────────────────── */}1156              <Show when={entry().type !== "note" && entry().type !== "card"}>1157                <Show1158                  when={entry().notes || editingField() === "notes"}1159                  fallback={1160                    <button1161                      class="peach-add-field-link peach-entry-add-link"1162                      onClick={() => startEdit("notes", "")}1163                    >1164                      + notes1165                    </button>1166                  }1167                >1168                  <div style="position:relative;z-index:1;margin-top:6px;">1169                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1170                    <Show1171                      when={editingField() === "notes"}1172                      fallback={1173                        <div1174                          class="peach-note-content peach-inline-clickable"1175                          style="cursor:text;white-space:pre-wrap;"1176                          onClick={() => startEdit("notes", entry().notes ?? "")}1177                        >1178                          {entry().notes}1179                        </div>1180                      }1181                    >1182                      <div1183                        class="peach-inline-field-wrap"1184                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1185                      >1186                        <textarea1187                          class="peach-inline-textarea"1188                          value={draft()}1189                          onInput={(e) => setDraft(e.currentTarget.value)}1190                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1191                          ref={(el) => requestAnimationFrame(() => el?.focus())}1192                          rows={4}1193                          disabled={saving()}1194                        />1195                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1196                      </div>1197                    </Show>1198                  </div>1199                </Show>1200              </Show>12011202              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1203              <Show1204                when={editingTags()}1205                fallback={1206                  <Show when={!entry().tags || entry().tags!.length === 0}>1207                    <button1208                      class="peach-add-field-link peach-entry-add-link"1209                      onClick={startEditTags}1210                    >1211                      + tags1212                    </button>1213                  </Show>1214                }1215              >1216                <div1217                  class="peach-inline-tag-editor"1218                  onFocusOut={(e) => {1219                    const next = e.relatedTarget;1220                    if (next instanceof Node && e.currentTarget.contains(next)) return;1221                    void saveTags();1222                  }}1223                >1224                  <span class="peach-label">tags</span>1225                  <div class="peach-tag-input-wrap">1226                    <For each={tagsDraft()}>1227                      {(tag) => (1228                        <span class="peach-tag-chip">1229                          {tag}1230                          <button1231                            class="peach-tag-chip-remove"1232                            onMouseDown={(e) => e.preventDefault()}1233                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1234                            aria-label={`Remove ${tag}`}1235                          >×</button>1236                        </span>1237                      )}1238                    </For>1239                    <input1240                      class="peach-tag-input"1241                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1242                      value={tagInput()}1243                      onInput={(e) => setTagInput(e.currentTarget.value)}1244                      onKeyDown={(e) => {1245                        if (e.key === "Enter" || e.key === ",") {1246                          e.preventDefault();1247                          addTag(tagInput());1248                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1249                          setTagsDraft(tagsDraft().slice(0, -1));1250                        } else if (e.key === "Escape") {1251                          e.preventDefault();1252                          setEditingTags(false);1253                          setTagInput("");1254                        }1255                      }}1256                      ref={(el) => requestAnimationFrame(() => el?.focus())}1257                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1258                    />1259                  </div>1260                </div>1261              </Show>12621263              {/* ── TOTP ─────────────────────────────────────────── */}1264              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1265                <Show1266                  when={totpState() || editingField() === "totp"}1267                  fallback={1268                    <button1269                      class="peach-add-field-link peach-entry-add-link"1270                      onClick={() => startEdit("totp", "")}1271                    >1272                      + 2FA1273                    </button>1274                  }1275                >1276                  <section class="peach-totp-band">1277                    <Show1278                      when={editingField() === "totp"}1279                      fallback={1280                        <Show when={totpState()}>1281                          {(state) => (1282                            <>1283                              <div1284                                class="peach-totp-main"1285                                onClick={() => {1286                                  // Delay copy so a double-click can cancel it before1287                                  // it overwrites the clipboard the user wants to paste from.1288                                  if (totpClickTimer) return;1289                                  totpClickTimer = setTimeout(() => {1290                                    totpClickTimer = null;1291                                    void copyField(state().code.replace(" ", ""), "totp");1292                                  }, 220);1293                                }}1294                                onDblClick={() => {1295                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1296                                  startEdit("totp", entry().totp ?? "");1297                                }}1298                                title="Click to copy · double-click to edit"1299                              >1300                                <span class="peach-label">2-factor</span>1301                                <div class="peach-totp-code-row">1302                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1303                                    <span class="peach-totp-code-text">1304                                      {state().code.slice(0, 3)} {state().code.slice(3)}1305                                    </span>1306                                  </p>1307                                </div>1308                              </div>1309                              <div class="peach-totp-meta">1310                                <span class="peach-label">expires</span>1311                                <div class="peach-totp-countdown">{state().remaining}s</div>1312                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1313                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1314                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1315                                    stroke-dasharray="106.8"1316                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1317                                  />1318                                </svg>1319                              </div>1320                            </>1321                          )}1322                        </Show>1323                      }1324                    >1325                      <div1326                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1327                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1328                      >1329                        <span class="peach-label">2-factor</span>1330                        <input1331                          class="peach-inline-input"1332                          value={draft()}1333                          onInput={(e) => setDraft(e.currentTarget.value)}1334                          onKeyDown={onEditKeyDown}1335                          ref={autoFocus}1336                          disabled={saving()}1337                          placeholder="otpauth://... or base32 secret"1338                          autocomplete="off"1339                        />1340                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1341                      </div>1342                    </Show>1343                  </section>1344                </Show>1345              </Show>13461347              {/* ── Advanced (canary + passkey) ───────────────────── */}1348              <Show when={entry().type === "login"}>1349                <button1350                  class="peach-add-field-link peach-entry-add-link"1351                  onClick={() => setShowAdvanced((v) => !v)}1352                  aria-expanded={showAdvanced()}1353                >1354                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1355                </button>1356                <Show when={showAdvanced()}>1357                  <Show when={!isPasskeyOnly(entry())}>1358                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1359                  </Show>13601361                  <Show when={entry().passkey}>1362                    {(passkey) => {1363                      const currentBrowser = detectBrowser();1364                      const crossBrowser =1365                        passkey().browser &&1366                        currentBrowser !== "unknown" &&1367                        passkey().browser !== currentBrowser;1368                      const algorithmLabel =1369                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1370                      const formatDate = (ts?: number) =>1371                        ts ? new Date(ts).toLocaleDateString() : "Never";1372                      return (1373                        <section1374                          class="peach-fields peach-passkey-section"1375                          style="position:relative;z-index:1;margin-top:6px;"1376                        >1377                          <div class="peach-field-card">1378                            <span class="peach-label">passkey · {passkey().rpId}</span>1379                            <div class="peach-field-value-row">1380                              <span class="peach-field-value">{passkey().userName}</span>1381                              <span class="peach-passkey-badge">passkey</span>1382                            </div>1383                          </div>13841385                          <div class="peach-field-card">1386                            <span class="peach-label">algorithm</span>1387                            <div class="peach-field-value-row">1388                              <span class="peach-field-value">{algorithmLabel}</span>1389                            </div>1390                          </div>13911392                          <div class="peach-field-card">1393                            <span class="peach-label">activity</span>1394                            <div class="peach-field-value-row peach-passkey-activity">1395                              <span class="peach-field-value">1396                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1397                              </span>1398                              <span class="peach-passkey-meta">1399                                created {formatDate(passkey().createdAt)}1400                              </span>1401                              <span class="peach-passkey-meta">1402                                last used {formatDate(passkey().lastUsedAt)}1403                              </span>1404                            </div>1405                          </div>14061407                          <Show when={crossBrowser}>1408                            <div class="peach-info-notice peach-info-notice--warn">1409                              {`This passkey was created on ${1410                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1411                              }. To use it on ${1412                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1413                              }, re-register it on this device.`}1414                            </div>1415                          </Show>14161417                          <div class="peach-info-notice">1418                            {passkey().browser === "firefox"1419                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1420                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1421                          </div>14221423                          <button1424                            class="peach-add-field-link peach-entry-add-link"1425                            style="color:rgba(220,38,38,0.7);"1426                            onClick={async () => {1427                              const id = entry().id;1428                              await sendMessage({1429                                type: "UPDATE_ENTRY",1430                                payload: { id, patch: { passkey: undefined } },1431                              });1432                              await props.onFieldSaved();1433                            }}1434                          >1435                            − remove passkey1436                          </button>1437                        </section>1438                      );1439                    }}1440                  </Show>1441                </Show>1442              </Show>14431444              {/* ── Bottom spacer ─────────────────────────────────── */}1445              <div style="height:4px;" />1446            </>1447          )}1448        </Show>1449      </div>14501451    </>1452  );1453}14541455// ── Sub-components ────────────────────────────────────────────────────14561457function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1458  return (1459    <div class="peach-inline-actions">1460      <button1461        class="peach-inline-save-btn"1462        onClick={props.onSave}1463        disabled={props.saving}1464        aria-label="Save"1465        title="Save (Enter)"1466      >1467        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1468          <path d="M1 4.5L4 7.5L10 1.5" />1469        </svg>1470      </button>1471      <button1472        class="peach-inline-cancel-btn"1473        onClick={props.onCancel}1474        disabled={props.saving}1475        aria-label="Cancel"1476        title="Cancel (Esc)"1477      >1478        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1479          <path d="M1 1L8 8M8 1L1 8" />1480        </svg>1481      </button>1482    </div>1483  );1484}14851486function EmptyVaultState() {1487  return (1488    <>1489      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1490        <div class="peach-entry-name">1491          <span class="peach-entry-name-text">1492            <span class="peach-entry-name-char" style="--char-index:0">e</span>1493            <span class="peach-entry-name-char" style="--char-index:1">m</span>1494            <span class="peach-entry-name-char" style="--char-index:2">p</span>1495            <span class="peach-entry-name-char" style="--char-index:3">t</span>1496            <span class="peach-entry-name-char" style="--char-index:4">y</span>1497          </span>1498        </div>1499      </div>1500      <div class="peach-empty-copy">1501        <span class="peach-label">vault</span>1502        <p>Your vault is ready for its first entry.</p>1503      </div>1504    </>1505  );1506}15071508function CopyGlyph() {1509  return (1510    <span class="peach-copy-icon">1511      <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1512        <rect x="4" y="4" width="8" height="9" rx="1.5" />1513        <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />1514      </svg>1515    </span>1516  );1517}15181519function EyeClosedGlyph() {1520  return (1521    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1522      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1523      <path d="M3.5 5.5L2.5 7.5" />1524      <path d="M7 6.5V8.5" />1525      <path d="M10.5 5.5L11.5 7.5" />1526    </svg>1527  );1528}15291530function EyeOpenGlyph() {1531  return (1532    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1533      <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />1534      <circle cx="7" cy="5" r="1.75" />1535    </svg>1536  );1537}15381539function ChevronGlyph(props: { open: boolean }) {1540  return (1541    <svg1542      width="8"1543      height="8"1544      viewBox="0 0 8 8"1545      fill="none"1546      stroke="currentColor"1547      stroke-width="1.3"1548      stroke-linecap="round"1549      stroke-linejoin="round"1550      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1551    >1552      <path d="M2 1L6 4L2 7" />1553    </svg>1554  );1555}15561557function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1558  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1559  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1560  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1561  const [testResult, setTestResult] = createSignal<string | null>(null);1562  const [testing, setTesting] = createSignal(false);15631564  async function save() {1565    const url = webhookUrl().trim();1566    const nextLabel = label().trim() || undefined;1567    const canary = enabled() && url1568      ? {1569          webhookUrl: url,1570          label: nextLabel,1571          lastPinged: props.entry.canary?.lastPinged,1572          pingCount: props.entry.canary?.pingCount,1573        }1574      : undefined;1575    await sendMessage({1576      type: "UPDATE_ENTRY",1577      payload: { id: props.entry.id, patch: { canary } },1578    });1579    await props.onSaved();1580  }15811582  async function testPing() {1583    const url = webhookUrl().trim();1584    if (!url) return;1585    setTesting(true);1586    setTestResult(null);1587    const res = await sendMessage({1588      type: "FIRE_CANARY_PING",1589      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1590    }) as { ok: boolean; error?: string };1591    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1592    setTesting(false);1593  }15941595  return (1596    <div class="peach-canary-section">1597      <div class="peach-field-value-row">1598        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1599        <button1600          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1601          onClick={() => { setEnabled(!enabled()); void save(); }}1602          aria-label="Toggle canary"1603        >1604          <span class="peach-toggle-knob" />1605        </button>1606      </div>1607      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1608        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1609      </p>1610      <Show when={enabled()}>1611        <div class="peach-inline-field-wrap" style="margin-top:8px;">1612          <input1613            class="peach-inline-input"1614            placeholder="https://hooks.zapier.com/..."1615            value={webhookUrl()}1616            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1617            onBlur={() => void save()}1618          />1619          <input1620            class="peach-inline-input"1621            placeholder="label (optional, e.g. AWS decoy)"1622            value={label()}1623            onInput={(e) => setLabel(e.currentTarget.value)}1624            onBlur={() => void save()}1625          />1626          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1627            <button1628              class="peach-btn peach-btn--ghost peach-btn--sm"1629              onClick={() => void testPing()}1630              disabled={testing() || !webhookUrl().trim()}1631            >1632              {testing() ? "sending..." : "test ping"}1633            </button>1634            <Show when={testResult()}>1635              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1636            </Show>1637          </div>1638          <Show when={props.entry.canary?.lastPinged}>1639            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1640              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1641              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1642            </p>1643          </Show>1644        </div>1645      </Show>1646    </div>1647  );1648}