Entry.tsx

73.2 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) {96        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);97        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);98      }99      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);100    }101    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];102    const seen = new Set<string>();103    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {104      if (seen.has(v)) continue;105      seen.add(v);106      const ec = emailMap.get(v) ?? 0;107      const uc = usernameMap.get(v) ?? 0;108      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });109    }110    return result.sort((a, b) => b.count - a.count);111  });112113  const identitySuggestions = createMemo(() => {114    const field = editingField();115    if (field !== "username" && field !== "email") return [];116    const input = draft().toLowerCase();117    return allIdentities()118      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))119      .slice(0, 8);120  });121122  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);123  onCleanup(() => window.clearInterval(interval));124125  const displayDomain = createMemo(() =>126    props.entry?.url ? getDisplayDomain(props.entry.url) : ""127  );128129  createEffect(() => {130    const domain = displayDomain();131    setFaviconSrc(domain ? faviconUrl(domain) : null);132  });133134  // Reset transient prompts (trash confirmation, inline edits, advanced panel)135  // when the selected entry changes so they don't carry over between entries.136  // Track the last id so a same-entry refresh (e.g. after saving a field)137  // doesn't blow away in-flight edit state — that race used to cancel138  // startEdit calls that ran right after onFieldSaved.139  let lastEntryId: string | null = null;140  createEffect(() => {141    const id = props.entry?.id ?? null;142    if (id === lastEntryId) return;143    lastEntryId = id;144    setConfirmDelete(false);145    setEditingField(null);146    setEditingTags(false);147    setShowAdvanced(false);148  });149150  const totpState = createMemo(() => {151    void totpTick();152    if (!props.entry?.totp) return null;153    return generateTOTP(props.entry.totp);154  });155156  let prevTotpCode: string | undefined;157  createEffect(() => {158    const state = totpState();159    if (!state) { prevTotpCode = undefined; return; }160    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {161      setTotpHot(true);162      window.setTimeout(() => setTotpHot(false), 3000);163    }164    prevTotpCode = state.code;165  });166167  const totpProgress = createMemo(() => {168    const state = totpState();169    if (!state) return 0;170    return ((state.remaining - 1) / 29) * 100;171  });172173  createEffect(() => {174    const shell = document.getElementById("hd-shell");175    if (!shell) return;176177    const state = totpState();178    if (!state) {179      delete shell.dataset.totpActive;180      shell.style.removeProperty("--peach-totp-progress");181      return;182    }183184    shell.dataset.totpActive = "true";185    shell.style.setProperty(186      "--peach-totp-progress",187      `${Math.max(0, totpProgress())}%`188    );189190    onCleanup(() => {191      delete shell.dataset.totpActive;192      shell.style.removeProperty("--peach-totp-progress");193    });194  });195196  const titleSizeClass = createMemo(() => {197    const length = props.entry?.title.length ?? 0;198    if (length >= 28) return "peach-entry-name--xxl";199    if (length >= 20) return "peach-entry-name--xl";200    if (length >= 14) return "peach-entry-name--lg";201    return "";202  });203  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);204205  // ── Inline editing ────────────────────────────────────────────────206207  function startEdit(field: EditableField, value: string) {208    setEditingField(field);209    setDraft(value);210    setIdentityHelperSuppressed(false);211    setShowGenerator(false);212    setConfirmDelete(false);213    if (field === "url") {214      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);215    }216  }217218  // Compare URLs by string after trim — vault.ts has its own canonicalisation219  // when saving, so the dedupe here is a UX-level "don't show the button if220  // we'd just add a duplicate" rather than a security check.221  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {222    const c = candidate.trim();223    if (!c) return true;224    if (primary.trim() === c) return true;225    return others.some((u) => u.trim() === c);226  }227228  function addCurrentSite(url: string) {229    if (!url) return;230    if (!draft().trim()) {231      setDraft(url);232      return;233    }234    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;235    setAdditionalUrlsDraft((arr) => [...arr, url]);236  }237238  async function saveField() {239    const field = editingField();240    const entry = props.entry;241    if (!field || !entry) return;242    // Don't trim passwords — spaces may be intentional243    let value = field === "password" ? draft() : draft().trim();244    if (field === "totp" && value.startsWith("otpauth://")) {245      const parsed = parseOtpauthURI(value);246      value = parsed?.secret ?? value;247    }248    if (field === "password" && entry.type !== "note" && !value) {249      return;250    }251    setSaving(true);252    try {253      const patch: Partial<VaultEntry> = { [field]: value };254      if (field === "url") {255        // Sanitization layer in vault.ts trims, dedupes, and drops empties.256        patch.additionalUrls = additionalUrlsDraft();257      }258      await sendMessage({259        type: "UPDATE_ENTRY",260        payload: { id: entry.id, patch },261      });262      triggerRing();263      setEditingField(null);264      setShowGenerator(false);265      await props.onFieldSaved();266    } catch {267      // silent — leave editing open268    } finally {269      setSaving(false);270    }271  }272273  function startEditTags() {274    setTagsDraft([...(props.entry?.tags ?? [])]);275    setTagInput("");276    setEditingTags(true);277  }278279  function addTag(value: string) {280    const n = value.toLowerCase().trim();281    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;282    setTagsDraft([...tagsDraft(), n]);283    setTagInput("");284  }285286  async function saveTags() {287    if (tagInput()) addTag(tagInput());288    const entry = props.entry;289    if (!entry) return;290    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;291    setEditingTags(false);292    setSaving(true);293    try {294      await sendMessage({295        type: "UPDATE_ENTRY",296        payload: { id: entry.id, patch: { tags: next } },297      });298      triggerRing();299      await props.onFieldSaved();300    } catch {301      // silent302    } finally {303      setSaving(false);304    }305  }306307  function cancelEdit() {308    setEditingField(null);309    setDraft("");310    setShowGenerator(false);311  }312313  // Switch from one identity slot to the other.314  //315  // Migration source is the SAVED entry value, never the live draft — using316  // the draft caused data loss when the user typed or cleared the input317  // before clicking +add (the migration would skip and a later Save would318  // overwrite the original email-shape value with whatever was in the input).319  //320  // Drafts only get committed when they are non-empty AND actually changed,321  // so an empty input never accidentally clears a saved field.322  async function addOtherIdentity(target: "username" | "email") {323    const entry = props.entry;324    if (!entry) return;325    const current = editingField();326    const draftValue = draft().trim();327    const savedUsername = entry.username ?? "";328    const savedEmail = entry.email ?? "";329330    if (current === "username" && target === "username") {331      // Legacy fix-up: saved username is email-shaped and no separate email332      // exists yet. Move it to entry.email and free the username slot.333      if (savedUsername.includes("@") && !entry.email) {334        setSaving(true);335        try {336          await sendMessage({337            type: "UPDATE_ENTRY",338            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },339          });340          await props.onFieldSaved();341        } finally {342          setSaving(false);343        }344      }345      startEdit("username", "");346      return;347    }348349    if (current === "username" && target === "email") {350      if (draftValue && draftValue !== savedUsername) {351        setSaving(true);352        try {353          await sendMessage({354            type: "UPDATE_ENTRY",355            payload: { id: entry.id, patch: { username: draftValue } },356          });357          await props.onFieldSaved();358        } finally {359          setSaving(false);360        }361      }362      startEdit("email", "");363      return;364    }365366    if (current === "email" && target === "username") {367      if (draftValue && draftValue !== savedEmail) {368        setSaving(true);369        try {370          await sendMessage({371            type: "UPDATE_ENTRY",372            payload: { id: entry.id, patch: { email: draftValue } },373          });374          await props.onFieldSaved();375        } finally {376          setSaving(false);377        }378      }379      startEdit("username", "");380      return;381    }382  }383384  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {385    switch (field) {386      case "title":387        return entry.title;388      case "url":389        return entry.url ?? "";390      case "username":391        return entry.username ?? "";392      case "email":393        return entry.email ?? "";394      case "password":395        return entry.password ?? "";396      case "notes":397        return entry.notes ?? "";398      case "totp":399        return entry.totp ?? "";400    }401  }402403  function handleEditorFocusOut(404    field: Exclude<EditableField, null>,405    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }406  ) {407    const nextTarget = event.relatedTarget;408    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;409410    // addOtherIdentity / startEdit may have already switched editingField before411    // focusout fires (SolidJS removes the old editor node synchronously, which412    // triggers blur with relatedTarget=null). Don't cancel the new edit.413    if (editingField() !== field) return;414415    const entry = props.entry;416    if (!entry) return;417418    const currentValue = fieldValue(entry, field);419    const nextValue = field === "password" ? draft() : draft().trim();420    if (nextValue === currentValue) {421      cancelEdit();422    }423  }424425  async function savePassword(password: string) {426    const entry = props.entry;427    if (!entry) return;428    setSaving(true);429    try {430      await sendMessage({431        type: "UPDATE_ENTRY",432        payload: { id: entry.id, patch: { password } },433      });434      triggerRing();435      setShowGenerator(false);436      await props.onFieldSaved();437    } catch {438      // silent439    } finally {440      setSaving(false);441    }442  }443444  // ── Copy ─────────────────────────────────────────────────────────445446  async function copyField(text: string, label: string, button?: HTMLButtonElement) {447    const ok = await popupCopy(text, label);448    if (ok) {449      const entry = props.entry;450      if (entry?.canary?.webhookUrl) {451        const trigger =452          label === "password"453            ? "password_copied"454            : label === "username"455              ? "username_copied"456              : undefined;457        if (trigger) {458          void sendMessage({459            type: "FIRE_CANARY_PING",460            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },461          });462        }463      }464      triggerRing();465      triggerPipPulse();466      setCopied(null);467      requestAnimationFrame(() => setCopied(label));468      if (button) {469        button.classList.remove("is-copied", "is-retreat");470        void button.offsetWidth;471        const drain = document.createElement("span");472        drain.className = "peach-copy-drain";473        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";474        drain.appendChild(bar);475        button.appendChild(drain);476        window.setTimeout(() => {477          button.classList.remove("is-copied");478          button.classList.add("is-retreat");479          drain.remove();480          window.setTimeout(() => button.classList.remove("is-retreat"), 160);481        }, 1800);482      }483      window.setTimeout(() => setCopied(null), 1800);484    }485  }486487  // ── Keyboard shortcuts ────────────────────────────────────────────488489  createEffect(() => {490    if (!props.entry) return;491    const handler = (e: KeyboardEvent) => {492      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;493      if (inInput) return;494      switch (e.key) {495        case "c": e.preventDefault();496          if (props.entry?.password) void copyField(props.entry.password, "password"); break;497        case "u": e.preventDefault();498          if (props.entry?.username) void copyField(props.entry.username, "username"); break;499        case "Escape": e.preventDefault(); props.onEscape?.(); break;500      }501    };502    window.addEventListener("keydown", handler);503    onCleanup(() => window.removeEventListener("keydown", handler));504  });505506  // ── Input ref focus helper ────────────────────────────────────────507508  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {509    requestAnimationFrame(() => { el.focus(); el.select?.(); });510  }511512  function onEditKeyDown(e: KeyboardEvent) {513    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {514      e.preventDefault();515      void saveField();516    }517    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }518  }519520  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {521    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;522    if (e.key === "ArrowDown" && listEl) {523      e.preventDefault();524      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();525      return;526    }527    onEditKeyDown(e);528  }529530  return (531    <>532      <div533        class="peach-ghost"534        aria-hidden="true"535        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;"536      >537        {props.entry?.title || "empty"}538      </div>539540      <div class="peach-stage" style="position:relative;">541        <Show when={props.entry} fallback={<EmptyVaultState />}>542          {(entry) => (543            <>544              {/* ── Breach strip ─────────────────────────────────── */}545              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>546                <div class="peach-breach-badge">547                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">548                    <path d="M6 1L11 10H1L6 1z" />549                    <line x1="6" y1="5" x2="6" y2="7.5" />550                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />551                  </svg>552                  <span style="flex:1">found in a known data breach</span>553                  <button554                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"555                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}556                  >557                    Change password558                  </button>559                </div>560              </Show>561562              {/* ── Title + trash ─────────────────────────────────── */}563              <div class="peach-entry-header">564                <div class="peach-entry-header-name">565                  <Show566                    when={editingField() === "title"}567                    fallback={568                      <button569                        class="peach-entry-name-button"570                        title="Click to edit title"571                        onClick={() => startEdit("title", entry().title)}572                      >573                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>574                          <span class="peach-entry-name-text">575                            <Show576                              when={useAnimatedTitle()}577                              fallback={entry().title}578                            >579                              <For each={entry().title.split("")}>580                                {(ch, i) => (581                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>582                                    {ch === " " ? "\u00A0" : ch}583                                  </span>584                                )}585                              </For>586                            </Show>587                          </span>588                        </h1>589                      </button>590                    }591                  >592                    <div593                      class="peach-inline-title-wrap"594                      onFocusOut={(event) => handleEditorFocusOut("title", event)}595                    >596                      <input597                        class="peach-inline-title-input"598                        value={draft()}599                        onInput={(e) => setDraft(e.currentTarget.value)}600                        onKeyDown={onEditKeyDown}601                        ref={autoFocus}602                        disabled={saving()}603                      />604                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />605                    </div>606                  </Show>607                </div>608609                {/* Subtle trash icon — hidden while editing title */}610                <Show when={editingField() !== "title"}>611                  <Show612                    when={confirmDelete()}613                    fallback={614                      <button615                        class="peach-entry-trash-btn"616                        onClick={() => setConfirmDelete(true)}617                        aria-label="Move to trash"618                        title="Delete entry"619                      >620                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">621                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />622                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />623                        </svg>624                      </button>625                    }626                  >627                    <div class="peach-entry-trash-confirm">628                      <span class="peach-entry-trash-confirm-label">trash?</span>629                      <button630                        class="peach-btn peach-btn--sm peach-btn--danger"631                        style="font-size:10px;padding:2px 8px;min-height:22px;"632                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}633                      >634                        yes635                      </button>636                      <button637                        class="peach-btn peach-btn--ghost peach-btn--sm"638                        style="font-size:10px;padding:2px 8px;min-height:22px;"639                        onClick={() => setConfirmDelete(false)}640                      >641                        no642                      </button>643                    </div>644                  </Show>645                </Show>646              </div>647648              {/* ── Domain / URL ─────────────────────────────────── */}649              <Show when={entry().type !== "note" && entry().type !== "card"}>650                <div class="peach-domain" style="position:relative;z-index:1;">651                  <Show652                    when={editingField() === "url"}653                    fallback={654                      <div655                        class="peach-domain-row peach-inline-clickable"656                        title="Click to edit URL"657                        onClick={() => startEdit("url", entry().url ?? "")}658                      >659                        <Show when={faviconSrc()}>660                          <img661                            src={faviconSrc()!}662                            width="16"663                            height="16"664                            alt=""665                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"666                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}667                          />668                        </Show>669                        <span class="peach-domain-value">670                          {displayDomain() || (entry().url ? entry().url : "add a website url")}671                        </span>672                        <span class="peach-inline-edit-hint">edit</span>673                      </div>674                    }675                  >676                    <div677                      class="peach-inline-field-wrap peach-inline-field-wrap--url"678                      onFocusOut={(event) => handleEditorFocusOut("url", event)}679                    >680                      <div class="peach-inline-field-row">681                        <input682                          class="peach-inline-input"683                          type="url"684                          placeholder="https://"685                          value={draft()}686                          onInput={(e) => setDraft(e.currentTarget.value)}687                          onKeyDown={onEditKeyDown}688                          ref={autoFocus}689                          disabled={saving()}690                        />691                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />692                      </div>693                      <div class="peach-additional-urls">694                        <Show when={additionalUrlsDraft().length > 0}>695                          <span class="peach-label peach-additional-urls-label">also matches</span>696                        </Show>697                        <For each={additionalUrlsDraft()}>698                          {(value, i) => (699                            <div class="peach-additional-url-row">700                              <input701                                class="peach-inline-input"702                                type="url"703                                placeholder="https://"704                                value={value}705                                onInput={(e) => setAdditionalUrlsDraft((arr) => {706                                  const next = [...arr];707                                  next[i()] = e.currentTarget.value;708                                  return next;709                                })}710                                disabled={saving()}711                              />712                              <button713                                class="peach-additional-url-remove"714                                type="button"715                                aria-label="Remove URL"716                                onMouseDown={(e) => e.preventDefault()}717                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}718                                disabled={saving()}719                              >×</button>720                            </div>721                          )}722                        </For>723                        <button724                          class="peach-add-field-link peach-additional-urls-add"725                          type="button"726                          onMouseDown={(e) => e.preventDefault()}727                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}728                          disabled={saving()}729                        >730                          + add another url731                        </button>732                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>733                          <button734                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"735                            type="button"736                            title={props.currentTabUrl}737                            onMouseDown={(e) => e.preventDefault()}738                            onClick={() => addCurrentSite(props.currentTabUrl!)}739                            disabled={saving()}740                          >741                            + add current site742                          </button>743                        </Show>744                      </div>745                    </div>746                  </Show>747                </div>748              </Show>749750              {/* ── Tags — read-only chips (editing state lives at bottom) */}751              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>752                <div753                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"754                  onClick={startEditTags}755                  title="Click to edit tags"756                >757                  <For each={entry().tags!}>758                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}759                  </For>760                </div>761              </Show>762763              {/* ── Note toolbar + inline editor ─────────────────── */}764              <Show when={entry().type === "note"}>765                <div class="peach-entry-note-toolbar-row">766                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />767                </div>768                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">769                  <NoteEditorInline770                    entryKey={entry().id}771                    initialContent={entry().notes ?? ""}772                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}773                    onBlur={saveNoteInline}774                    autoFocus={false}775                  />776                </section>777              </Show>778779              {/* ── Card detail view ─────────────────────────────── */}780              <Show when={entry().type === "card"}>781                <section class="peach-fields" style="position:relative;z-index:1;">782                  {/* Card subtitle: •••• last4 */}783                  <Show when={entry().last4}>784                    <div class="peach-field-card" style="padding-bottom:4px;">785                      <span class="peach-label">786                        <Show when={entry().network && entry().network !== "unknown"}>787                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>788                        </Show>789                        {entry().cardNickname || `•••• ${entry().last4}`}790                      </span>791                    </div>792                  </Show>793794                  {/* Cardholder name */}795                  <div class="peach-field-card">796                    <span class="peach-label">cardholder name</span>797                    <div class="peach-field-value-row">798                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>799                      <Show when={entry().cardholderName}>800                        <button801                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}802                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}803                          aria-label="Copy cardholder name"804                        >805                          <CopyGlyph /><span class="peach-copy-sheen" />806                        </button>807                      </Show>808                    </div>809                  </div>810811                  {/* Card number */}812                  <div class="peach-field-card">813                    <span class="peach-label">card number</span>814                    <div class="peach-field-value-row">815                      <span class={`peach-field-value peach-card-number`}>816                        <Show when={showCardNumber()} fallback={817                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>818                        }>819                          <span>{entry().cardNumber ?? ""}</span>820                        </Show>821                      </span>822                      <div class="peach-field-actions">823                        <button824                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"825                          onClick={() => setShowCardNumber(!showCardNumber())}826                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}827                        >828                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>829                            <EyeClosedGlyph />830                          </Show>831                        </button>832                        <Show when={entry().cardNumber}>833                          <button834                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}835                            onClick={(e) => {836                              const cardNum = entry().cardNumber!;837                              if (entry().canary?.webhookUrl) {838                                void sendMessage({839                                  type: "FIRE_CANARY_PING",840                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },841                                });842                              }843                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);844                            }}845                            aria-label="Copy card number"846                          >847                            <CopyGlyph /><span class="peach-copy-sheen" />848                          </button>849                        </Show>850                      </div>851                    </div>852                  </div>853854                  {/* Expiry */}855                  <Show when={entry().expMonth || entry().expYear}>856                    <div class="peach-field-card">857                      <span class="peach-label">expiry</span>858                      <div class="peach-field-value-row">859                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>860                        <button861                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}862                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}863                          aria-label="Copy expiry"864                        >865                          <CopyGlyph /><span class="peach-copy-sheen" />866                        </button>867                      </div>868                    </div>869                  </Show>870871                  {/* CVC */}872                  <Show when={entry().cvc}>873                    <div class="peach-field-card">874                      <span class="peach-label">CVC</span>875                      <div class="peach-field-value-row">876                        <span class="peach-field-value">877                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>878                            <span>{entry().cvc}</span>879                          </Show>880                        </span>881                        <div class="peach-field-actions">882                          <button883                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"884                            onClick={() => setShowCardCvc(!showCardCvc())}885                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}886                          >887                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>888                              <EyeClosedGlyph />889                            </Show>890                          </button>891                          <button892                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}893                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}894                            aria-label="Copy CVC"895                          >896                            <CopyGlyph /><span class="peach-copy-sheen" />897                          </button>898                        </div>899                      </div>900                    </div>901                  </Show>902                </section>903              </Show>904905              {/* ── Login fields ─────────────────────────────────── */}906              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>907                <section class="peach-fields" style="position:relative;z-index:1;">908909                  {/* Username — primary identity slot. Auto-labels as "email"910                      when the stored value is email-shaped and no separate911                      email field exists (legacy data path). */}912                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>913                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>914                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>915                      <Show916                        when={editingField() === "username"}917                        fallback={918                          <div class="peach-field-value-row">919                            <span920                              class="peach-field-value peach-inline-clickable"921                              title="Click to edit"922                              onClick={() => startEdit("username", entry().username ?? "")}923                            >924                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}925                            </span>926                            <Show when={entry().username}>927                              <button928                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}929                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}930                                aria-label="Copy username"931                              >932                                <CopyGlyph /><span class="peach-copy-sheen" />933                              </button>934                            </Show>935                          </div>936                        }937                      >938                        <div939                          class="peach-inline-field-wrap"940                          onFocusOut={(event) => handleEditorFocusOut("username", event)}941                        >942                          <input943                            class="peach-inline-input"944                            value={draft()}945                            onInput={(e) => setDraft(e.currentTarget.value)}946                            onKeyDown={(e) => identityKeyDown(e, "username")}947                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}948                            disabled={saving()}949                            autocomplete="off"950                          />951                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />952                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>953                            <IdentitySuggestList954                              listRef={(el) => (usernameIdentityEl = el)}955                              inputRef={() => usernameInlineInput}956                              suggestions={identitySuggestions()}957                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}958                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}959                            />960                          </Show>961                          {/* +add the OTHER identity. If the slot is acting as962                              email (value has @, no separate email), offer963                              "+ add username" — clicking migrates the email964                              into entry.email. Otherwise offer "+ add email". */}965                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>966                            <button967                              class="peach-add-field-btn"968                              type="button"969                              onMouseDown={(e) => e.preventDefault()}970                              onClick={() => {971                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);972                                const hasSeparateEmail = !!entry().email;973                                if (isEmailShape && !hasSeparateEmail) {974                                  void addOtherIdentity("username");975                                } else {976                                  void addOtherIdentity("email");977                                }978                              }}979                            >980                              {(() => {981                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);982                                const hasSeparateEmail = !!entry().email;983                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";984                              })()}985                            </button>986                          </Show>987                        </div>988                      </Show>989                    </div>990                  </Show>991992                  {/* Email — secondary identity slot. Visible whenever a993                      separate email field is set, or while it is being edited. */}994                  <Show when={entry().email || editingField() === "email"}>995                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>996                      <span class="peach-label">email</span>997                      <Show998                        when={editingField() === "email"}999                        fallback={1000                          <div class="peach-field-value-row">1001                            <span1002                              class="peach-field-value peach-inline-clickable"1003                              title="Click to edit"1004                              onClick={() => startEdit("email", entry().email ?? "")}1005                            >1006                              {entry().email}1007                            </span>1008                            <Show when={entry().email}>1009                              <button1010                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1011                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1012                                aria-label="Copy email"1013                              >1014                                <CopyGlyph /><span class="peach-copy-sheen" />1015                              </button>1016                            </Show>1017                          </div>1018                        }1019                      >1020                        <div1021                          class="peach-inline-field-wrap"1022                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1023                        >1024                          <input1025                            class="peach-inline-input"1026                            value={draft()}1027                            onInput={(e) => setDraft(e.currentTarget.value)}1028                            onKeyDown={(e) => identityKeyDown(e, "email")}1029                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1030                            disabled={saving()}1031                            autocomplete="email"1032                            type="email"1033                          />1034                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1035                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1036                            <IdentitySuggestList1037                              listRef={(el) => (emailIdentityEl = el)}1038                              inputRef={() => emailInlineInput}1039                              suggestions={identitySuggestions()}1040                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1041                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1042                            />1043                          </Show>1044                          <Show when={!entry().username || entry().username?.includes("@")}>1045                            <button1046                              class="peach-add-field-btn"1047                              type="button"1048                              onMouseDown={(e) => e.preventDefault()}1049                              onClick={() => void addOtherIdentity("username")}1050                            >1051                              + add username1052                            </button>1053                          </Show>1054                        </div>1055                      </Show>1056                    </div>1057                  </Show>10581059                  {/* Password */}1060                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1061                    <span class="peach-label">password</span>1062                    <Show1063                      when={editingField() === "password"}1064                      fallback={1065                        <div class="peach-field-value-row">1066                          <span1067                            class="peach-password-value"1068                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1069                            style="cursor:pointer;"1070                            title="Click to edit password"1071                            onClick={() => startEdit("password", entry().password ?? "")}1072                          >1073                            <Show1074                              when={showPassword()}1075                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1076                            >1077                              <span class="peach-password-text">{entry().password ?? ""}</span>1078                            </Show>1079                          </span>1080                          <div class="peach-field-actions">1081                            <button1082                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1083                              onClick={() => setShowPassword(!showPassword())}1084                              aria-label="Reveal password"1085                            >1086                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1087                                <EyeClosedGlyph />1088                              </Show>1089                            </button>1090                            <button1091                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1092                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1093                              aria-label="Copy password"1094                            >1095                              <CopyGlyph /><span class="peach-copy-sheen" />1096                            </button>1097                          </div>1098                        </div>1099                      }1100                    >1101                      {/* ── Edit mode: type / paste / generate ── */}1102                      <div1103                        class="peach-inline-field-wrap"1104                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1105                      >1106                        <div class="peach-password-edit-row">1107                          <input1108                            class="peach-inline-input"1109                            type={showPassword() ? "text" : "password"}1110                            placeholder="paste or type a password"1111                            value={draft()}1112                            onInput={(e) => setDraft(e.currentTarget.value)}1113                            onKeyDown={(e) => {1114                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1115                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1116                            }}1117                            ref={autoFocus}1118                            disabled={saving()}1119                            autocomplete="new-password"1120                          />1121                          <button1122                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1123                            type="button"1124                            onClick={() => setShowPassword(!showPassword())}1125                            aria-label={showPassword() ? "Hide" : "Show"}1126                          >1127                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1128                              <EyeClosedGlyph />1129                            </Show>1130                          </button>1131                        </div>1132                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1133                        <button1134                          class="peach-generate-toggle-link"1135                          type="button"1136                          onClick={() => setShowGenerator(!showGenerator())}1137                        >1138                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1139                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1140                            <path d="M9.5 2.5H11V1" />1141                          </svg>1142                          {showGenerator() ? "hide generator" : "generate"}1143                        </button>1144                        <Show when={showGenerator()}>1145                          <GeneratorDrawer1146                            open={true}1147                            compact={true}1148                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1149                          />1150                        </Show>1151                      </div>1152                    </Show>1153                  </div>11541155                </section>1156              </Show>11571158              {/* ── Notes (on login entries) ──────────────────────── */}1159              <Show when={entry().type !== "note" && entry().type !== "card"}>1160                <Show1161                  when={entry().notes || editingField() === "notes"}1162                  fallback={1163                    <button1164                      class="peach-add-field-link peach-entry-add-link"1165                      onClick={() => startEdit("notes", "")}1166                    >1167                      + notes1168                    </button>1169                  }1170                >1171                  <div style="position:relative;z-index:1;margin-top:6px;">1172                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1173                    <Show1174                      when={editingField() === "notes"}1175                      fallback={1176                        <div1177                          class="peach-note-content peach-inline-clickable"1178                          style="cursor:text;white-space:pre-wrap;"1179                          onClick={() => startEdit("notes", entry().notes ?? "")}1180                        >1181                          {entry().notes}1182                        </div>1183                      }1184                    >1185                      <div1186                        class="peach-inline-field-wrap"1187                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1188                      >1189                        <textarea1190                          class="peach-inline-textarea"1191                          value={draft()}1192                          onInput={(e) => setDraft(e.currentTarget.value)}1193                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1194                          ref={(el) => requestAnimationFrame(() => el?.focus())}1195                          rows={4}1196                          disabled={saving()}1197                        />1198                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1199                      </div>1200                    </Show>1201                  </div>1202                </Show>1203              </Show>12041205              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1206              <Show1207                when={editingTags()}1208                fallback={1209                  <Show when={!entry().tags || entry().tags!.length === 0}>1210                    <button1211                      class="peach-add-field-link peach-entry-add-link"1212                      onClick={startEditTags}1213                    >1214                      + tags1215                    </button>1216                  </Show>1217                }1218              >1219                <div1220                  class="peach-inline-tag-editor"1221                  onFocusOut={(e) => {1222                    const next = e.relatedTarget;1223                    if (next instanceof Node && e.currentTarget.contains(next)) return;1224                    void saveTags();1225                  }}1226                >1227                  <span class="peach-label">tags</span>1228                  <div class="peach-tag-input-wrap">1229                    <For each={tagsDraft()}>1230                      {(tag) => (1231                        <span class="peach-tag-chip">1232                          {tag}1233                          <button1234                            class="peach-tag-chip-remove"1235                            onMouseDown={(e) => e.preventDefault()}1236                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1237                            aria-label={`Remove ${tag}`}1238                          >×</button>1239                        </span>1240                      )}1241                    </For>1242                    <input1243                      class="peach-tag-input"1244                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1245                      value={tagInput()}1246                      onInput={(e) => setTagInput(e.currentTarget.value)}1247                      onKeyDown={(e) => {1248                        if (e.key === "Enter" || e.key === ",") {1249                          e.preventDefault();1250                          addTag(tagInput());1251                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1252                          setTagsDraft(tagsDraft().slice(0, -1));1253                        } else if (e.key === "Escape") {1254                          e.preventDefault();1255                          setEditingTags(false);1256                          setTagInput("");1257                        }1258                      }}1259                      ref={(el) => requestAnimationFrame(() => el?.focus())}1260                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1261                    />1262                  </div>1263                </div>1264              </Show>12651266              {/* ── TOTP ─────────────────────────────────────────── */}1267              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1268                <Show1269                  when={totpState() || editingField() === "totp"}1270                  fallback={1271                    <button1272                      class="peach-add-field-link peach-entry-add-link"1273                      onClick={() => startEdit("totp", "")}1274                    >1275                      + 2FA1276                    </button>1277                  }1278                >1279                  <section class="peach-totp-band">1280                    <Show1281                      when={editingField() === "totp"}1282                      fallback={1283                        <Show when={totpState()}>1284                          {(state) => (1285                            <>1286                              <div1287                                class="peach-totp-main"1288                                onClick={() => {1289                                  // Delay copy so a double-click can cancel it before1290                                  // it overwrites the clipboard the user wants to paste from.1291                                  if (totpClickTimer) return;1292                                  totpClickTimer = setTimeout(() => {1293                                    totpClickTimer = null;1294                                    void copyField(state().code.replace(" ", ""), "totp");1295                                  }, 220);1296                                }}1297                                onDblClick={() => {1298                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1299                                  startEdit("totp", entry().totp ?? "");1300                                }}1301                                title="Click to copy · double-click to edit"1302                              >1303                                <span class="peach-label">2-factor</span>1304                                <div class="peach-totp-code-row">1305                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1306                                    <span class="peach-totp-code-text">1307                                      {state().code.slice(0, 3)} {state().code.slice(3)}1308                                    </span>1309                                  </p>1310                                </div>1311                              </div>1312                              <div class="peach-totp-meta">1313                                <span class="peach-label">expires</span>1314                                <div class="peach-totp-countdown">{state().remaining}s</div>1315                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1316                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1317                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1318                                    stroke-dasharray="106.8"1319                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1320                                  />1321                                </svg>1322                              </div>1323                            </>1324                          )}1325                        </Show>1326                      }1327                    >1328                      <div1329                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1330                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1331                      >1332                        <span class="peach-label">2-factor</span>1333                        <input1334                          class="peach-inline-input"1335                          value={draft()}1336                          onInput={(e) => setDraft(e.currentTarget.value)}1337                          onKeyDown={onEditKeyDown}1338                          ref={autoFocus}1339                          disabled={saving()}1340                          placeholder="otpauth://... or base32 secret"1341                          autocomplete="off"1342                        />1343                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1344                      </div>1345                    </Show>1346                  </section>1347                </Show>1348              </Show>13491350              {/* ── Advanced (canary + passkey) ───────────────────── */}1351              <Show when={entry().type === "login"}>1352                <button1353                  class="peach-add-field-link peach-entry-add-link"1354                  onClick={() => setShowAdvanced((v) => !v)}1355                  aria-expanded={showAdvanced()}1356                >1357                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1358                </button>1359                <Show when={showAdvanced()}>1360                  <Show when={!isPasskeyOnly(entry())}>1361                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1362                  </Show>13631364                  <Show when={entry().passkey}>1365                    {(passkey) => {1366                      const currentBrowser = detectBrowser();1367                      const crossBrowser =1368                        passkey().browser &&1369                        currentBrowser !== "unknown" &&1370                        passkey().browser !== currentBrowser;1371                      const algorithmLabel =1372                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1373                      const formatDate = (ts?: number) =>1374                        ts ? new Date(ts).toLocaleDateString() : "Never";1375                      return (1376                        <section1377                          class="peach-fields peach-passkey-section"1378                          style="position:relative;z-index:1;margin-top:6px;"1379                        >1380                          <div class="peach-field-card">1381                            <span class="peach-label">passkey · {passkey().rpId}</span>1382                            <div class="peach-field-value-row">1383                              <span class="peach-field-value">{passkey().userName}</span>1384                              <span class="peach-passkey-badge">passkey</span>1385                            </div>1386                          </div>13871388                          <div class="peach-field-card">1389                            <span class="peach-label">algorithm</span>1390                            <div class="peach-field-value-row">1391                              <span class="peach-field-value">{algorithmLabel}</span>1392                            </div>1393                          </div>13941395                          <div class="peach-field-card">1396                            <span class="peach-label">activity</span>1397                            <div class="peach-field-value-row peach-passkey-activity">1398                              <span class="peach-field-value">1399                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1400                              </span>1401                              <span class="peach-passkey-meta">1402                                created {formatDate(passkey().createdAt)}1403                              </span>1404                              <span class="peach-passkey-meta">1405                                last used {formatDate(passkey().lastUsedAt)}1406                              </span>1407                            </div>1408                          </div>14091410                          <Show when={crossBrowser}>1411                            <div class="peach-info-notice peach-info-notice--warn">1412                              {`This passkey was created on ${1413                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1414                              }. To use it on ${1415                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1416                              }, re-register it on this device.`}1417                            </div>1418                          </Show>14191420                          <div class="peach-info-notice">1421                            {passkey().browser === "firefox"1422                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1423                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1424                          </div>14251426                          <button1427                            class="peach-add-field-link peach-entry-add-link"1428                            style="color:rgba(220,38,38,0.7);"1429                            onClick={async () => {1430                              const id = entry().id;1431                              await sendMessage({1432                                type: "UPDATE_ENTRY",1433                                payload: { id, patch: { passkey: undefined } },1434                              });1435                              await props.onFieldSaved();1436                            }}1437                          >1438                            − remove passkey1439                          </button>1440                        </section>1441                      );1442                    }}1443                  </Show>1444                </Show>1445              </Show>14461447              {/* ── Bottom spacer ─────────────────────────────────── */}1448              <div style="height:4px;" />1449            </>1450          )}1451        </Show>1452      </div>14531454    </>1455  );1456}14571458// ── Sub-components ────────────────────────────────────────────────────14591460function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1461  return (1462    <div class="peach-inline-actions">1463      <button1464        class="peach-inline-save-btn"1465        onClick={props.onSave}1466        disabled={props.saving}1467        aria-label="Save"1468        title="Save (Enter)"1469      >1470        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1471          <path d="M1 4.5L4 7.5L10 1.5" />1472        </svg>1473      </button>1474      <button1475        class="peach-inline-cancel-btn"1476        onClick={props.onCancel}1477        disabled={props.saving}1478        aria-label="Cancel"1479        title="Cancel (Esc)"1480      >1481        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1482          <path d="M1 1L8 8M8 1L1 8" />1483        </svg>1484      </button>1485    </div>1486  );1487}14881489function EmptyVaultState() {1490  return (1491    <>1492      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1493        <div class="peach-entry-name">1494          <span class="peach-entry-name-text">1495            <span class="peach-entry-name-char" style="--char-index:0">e</span>1496            <span class="peach-entry-name-char" style="--char-index:1">m</span>1497            <span class="peach-entry-name-char" style="--char-index:2">p</span>1498            <span class="peach-entry-name-char" style="--char-index:3">t</span>1499            <span class="peach-entry-name-char" style="--char-index:4">y</span>1500          </span>1501        </div>1502      </div>1503      <div class="peach-empty-copy">1504        <span class="peach-label">vault</span>1505        <p>Your vault is ready for its first entry.</p>1506      </div>1507    </>1508  );1509}15101511function CopyGlyph() {1512  return (1513    <span class="peach-copy-icon">1514      <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">1515        <rect x="4" y="4" width="8" height="9" rx="1.5" />1516        <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" />1517      </svg>1518    </span>1519  );1520}15211522function EyeClosedGlyph() {1523  return (1524    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1525      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1526      <path d="M3.5 5.5L2.5 7.5" />1527      <path d="M7 6.5V8.5" />1528      <path d="M10.5 5.5L11.5 7.5" />1529    </svg>1530  );1531}15321533function EyeOpenGlyph() {1534  return (1535    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1536      <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" />1537      <circle cx="7" cy="5" r="1.75" />1538    </svg>1539  );1540}15411542function ChevronGlyph(props: { open: boolean }) {1543  return (1544    <svg1545      width="8"1546      height="8"1547      viewBox="0 0 8 8"1548      fill="none"1549      stroke="currentColor"1550      stroke-width="1.3"1551      stroke-linecap="round"1552      stroke-linejoin="round"1553      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1554    >1555      <path d="M2 1L6 4L2 7" />1556    </svg>1557  );1558}15591560function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1561  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1562  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1563  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1564  const [testResult, setTestResult] = createSignal<string | null>(null);1565  const [testing, setTesting] = createSignal(false);15661567  async function save() {1568    const url = webhookUrl().trim();1569    const nextLabel = label().trim() || undefined;1570    const canary = enabled() && url1571      ? {1572          webhookUrl: url,1573          label: nextLabel,1574          lastPinged: props.entry.canary?.lastPinged,1575          pingCount: props.entry.canary?.pingCount,1576        }1577      : undefined;1578    await sendMessage({1579      type: "UPDATE_ENTRY",1580      payload: { id: props.entry.id, patch: { canary } },1581    });1582    await props.onSaved();1583  }15841585  async function testPing() {1586    const url = webhookUrl().trim();1587    if (!url) return;1588    setTesting(true);1589    setTestResult(null);1590    const res = await sendMessage({1591      type: "FIRE_CANARY_PING",1592      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1593    }) as { ok: boolean; error?: string };1594    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1595    setTesting(false);1596  }15971598  return (1599    <div class="peach-canary-section">1600      <div class="peach-field-value-row">1601        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1602        <button1603          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1604          onClick={() => { setEnabled(!enabled()); void save(); }}1605          aria-label="Toggle canary"1606        >1607          <span class="peach-toggle-knob" />1608        </button>1609      </div>1610      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1611        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1612      </p>1613      <Show when={enabled()}>1614        <div class="peach-inline-field-wrap" style="margin-top:8px;">1615          <input1616            class="peach-inline-input"1617            placeholder="https://hooks.zapier.com/..."1618            value={webhookUrl()}1619            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1620            onBlur={() => void save()}1621          />1622          <input1623            class="peach-inline-input"1624            placeholder="label (optional, e.g. AWS decoy)"1625            value={label()}1626            onInput={(e) => setLabel(e.currentTarget.value)}1627            onBlur={() => void save()}1628          />1629          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1630            <button1631              class="peach-btn peach-btn--ghost peach-btn--sm"1632              onClick={() => void testPing()}1633              disabled={testing() || !webhookUrl().trim()}1634            >1635              {testing() ? "sending..." : "test ping"}1636            </button>1637            <Show when={testResult()}>1638              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1639            </Show>1640          </div>1641          <Show when={props.entry.canary?.lastPinged}>1642            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1643              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1644              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1645            </p>1646          </Show>1647        </div>1648      </Show>1649    </div>1650  );1651}