Entry.tsx

68.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";1213type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1415interface EntryProps {16  entry: VaultEntry | null;17  /**18   * Current active tab's full URL (https only). Empty string when unavailable19   * — Capacitor, an extension page, or a chrome:///about: URL. The URL20   * editor uses this to render an "add current site" shortcut.21   */22  currentTabUrl?: string;23  onDelete: () => void;24  /** Called after any inline save so App can reload entries */25  onFieldSaved: () => void | Promise<void>;26  onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30  const [editingField, setEditingField] = createSignal<EditableField>(null);31  const [draft, setDraft] = createSignal("");32  const [saving, setSaving] = createSignal(false);33  const [showPassword, setShowPassword] = createSignal(false);34  const [showGenerator, setShowGenerator] = createSignal(false);35  const [copied, setCopied] = createSignal<string | null>(null);36  // Local draft for the additional URLs list, edited only while the URL field37  // is in inline-edit mode. Saved alongside `url` when the user confirms.38  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39  const [editingTags, setEditingTags] = createSignal(false);40  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41  const [tagInput, setTagInput] = createSignal("");42  const [showCardNumber, setShowCardNumber] = createSignal(false);43  const [showCardCvc, setShowCardCvc] = createSignal(false);44  const [totpTick, setTotpTick] = createSignal(0);45  const [confirmDelete, setConfirmDelete] = createSignal(false);46  const [totpHot, setTotpHot] = createSignal(false);47  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49  // Auto-open advanced when entry already has passkey or canary configured50  const [showAdvanced, setShowAdvanced] = createSignal(false);51  let lastSavedNote: string | null = null;5253  function noteExec(cmd: string, val?: string) {54    document.execCommand(cmd, false, val);55    noteEditorEl()?.focus();56  }5758  async function saveNoteInline(html: string) {59    const entry = props.entry;60    if (!entry) return;61    const currentStored = entry.notes ?? "";62    if (html === currentStored) return;63    if (lastSavedNote !== null && html === lastSavedNote) return;64    lastSavedNote = html;65    setSaving(true);66    try {67      await sendMessage({68        type: "UPDATE_ENTRY",69        payload: { id: entry.id, patch: { notes: html } },70      });71      triggerRing();72      await props.onFieldSaved();73    } catch {74      lastSavedNote = null;75    } finally {76      setSaving(false);77    }78  }7980  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81  onCleanup(() => window.clearInterval(interval));8283  const displayDomain = createMemo(() =>84    props.entry?.url ? getDisplayDomain(props.entry.url) : ""85  );8687  createEffect(() => {88    const domain = displayDomain();89    setFaviconSrc(domain ? faviconUrl(domain) : null);90  });9192  // Reset transient prompts (trash confirmation, inline edits, advanced panel)93  // when the selected entry changes so they don't carry over between entries.94  createEffect(() => {95    void props.entry?.id;96    setConfirmDelete(false);97    setEditingField(null);98    setEditingTags(false);99    setShowAdvanced(false);100  });101102  const totpState = createMemo(() => {103    void totpTick();104    if (!props.entry?.totp) return null;105    return generateTOTP(props.entry.totp);106  });107108  let prevTotpCode: string | undefined;109  createEffect(() => {110    const state = totpState();111    if (!state) { prevTotpCode = undefined; return; }112    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113      setTotpHot(true);114      window.setTimeout(() => setTotpHot(false), 3000);115    }116    prevTotpCode = state.code;117  });118119  const totpProgress = createMemo(() => {120    const state = totpState();121    if (!state) return 0;122    return ((state.remaining - 1) / 29) * 100;123  });124125  createEffect(() => {126    const shell = document.getElementById("hd-shell");127    if (!shell) return;128129    const state = totpState();130    if (!state) {131      delete shell.dataset.totpActive;132      shell.style.removeProperty("--peach-totp-progress");133      return;134    }135136    shell.dataset.totpActive = "true";137    shell.style.setProperty(138      "--peach-totp-progress",139      `${Math.max(0, totpProgress())}%`140    );141142    onCleanup(() => {143      delete shell.dataset.totpActive;144      shell.style.removeProperty("--peach-totp-progress");145    });146  });147148  const titleSizeClass = createMemo(() => {149    const length = props.entry?.title.length ?? 0;150    if (length >= 28) return "peach-entry-name--xxl";151    if (length >= 20) return "peach-entry-name--xl";152    if (length >= 14) return "peach-entry-name--lg";153    return "";154  });155  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157  // ── Inline editing ────────────────────────────────────────────────158159  function startEdit(field: EditableField, value: string) {160    setEditingField(field);161    setDraft(value);162    setShowGenerator(false);163    setConfirmDelete(false);164    if (field === "url") {165      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166    }167  }168169  // Compare URLs by string after trim — vault.ts has its own canonicalisation170  // when saving, so the dedupe here is a UX-level "don't show the button if171  // we'd just add a duplicate" rather than a security check.172  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173    const c = candidate.trim();174    if (!c) return true;175    if (primary.trim() === c) return true;176    return others.some((u) => u.trim() === c);177  }178179  function addCurrentSite(url: string) {180    if (!url) return;181    if (!draft().trim()) {182      setDraft(url);183      return;184    }185    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186    setAdditionalUrlsDraft((arr) => [...arr, url]);187  }188189  async function saveField() {190    const field = editingField();191    const entry = props.entry;192    if (!field || !entry) return;193    // Don't trim passwords — spaces may be intentional194    let value = field === "password" ? draft() : draft().trim();195    if (field === "totp" && value.startsWith("otpauth://")) {196      const parsed = parseOtpauthURI(value);197      value = parsed?.secret ?? value;198    }199    if (field === "password" && entry.type !== "note" && !value) {200      return;201    }202    setSaving(true);203    try {204      const patch: Partial<VaultEntry> = { [field]: value };205      if (field === "url") {206        // Sanitization layer in vault.ts trims, dedupes, and drops empties.207        patch.additionalUrls = additionalUrlsDraft();208      }209      await sendMessage({210        type: "UPDATE_ENTRY",211        payload: { id: entry.id, patch },212      });213      triggerRing();214      setEditingField(null);215      setShowGenerator(false);216      await props.onFieldSaved();217    } catch {218      // silent — leave editing open219    } finally {220      setSaving(false);221    }222  }223224  function startEditTags() {225    setTagsDraft([...(props.entry?.tags ?? [])]);226    setTagInput("");227    setEditingTags(true);228  }229230  function addTag(value: string) {231    const n = value.toLowerCase().trim();232    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233    setTagsDraft([...tagsDraft(), n]);234    setTagInput("");235  }236237  async function saveTags() {238    if (tagInput()) addTag(tagInput());239    const entry = props.entry;240    if (!entry) return;241    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242    setEditingTags(false);243    setSaving(true);244    try {245      await sendMessage({246        type: "UPDATE_ENTRY",247        payload: { id: entry.id, patch: { tags: next } },248      });249      triggerRing();250      await props.onFieldSaved();251    } catch {252      // silent253    } finally {254      setSaving(false);255    }256  }257258  function cancelEdit() {259    setEditingField(null);260    setDraft("");261    setShowGenerator(false);262  }263264  // Switch from one identity slot to the other while editing.265  // If we're in the username slot with an email-shaped draft and no separate266  // email yet, migrate that value into entry.email so the now-empty username267  // slot can hold a plain username — this is the legacy-data fix-up path.268  async function addOtherIdentity(target: "username" | "email") {269    const entry = props.entry;270    if (!entry) return;271    const current = editingField();272    const value = draft().trim();273    if (current === "username" && target === "email") {274      // Commit current username draft (if changed), then open email editor.275      if (value !== (entry.username ?? "")) {276        setSaving(true);277        try {278          await sendMessage({279            type: "UPDATE_ENTRY",280            payload: { id: entry.id, patch: { username: value } },281          });282          await props.onFieldSaved();283        } finally {284          setSaving(false);285        }286      }287      startEdit("email", "");288      return;289    }290    if (current === "username" && target === "username") {291      // Already editing username — only meaningful when the saved username292      // is email-shaped. Migrate it to entry.email and reset the slot.293      if (value.includes("@") && !entry.email) {294        setSaving(true);295        try {296          await sendMessage({297            type: "UPDATE_ENTRY",298            payload: { id: entry.id, patch: { email: value, username: "" } },299          });300          await props.onFieldSaved();301        } finally {302          setSaving(false);303        }304      }305      startEdit("username", "");306      return;307    }308    if (current === "email" && target === "username") {309      // Commit current email draft (if changed), then open username editor.310      if (value !== (entry.email ?? "")) {311        setSaving(true);312        try {313          await sendMessage({314            type: "UPDATE_ENTRY",315            payload: { id: entry.id, patch: { email: value } },316          });317          await props.onFieldSaved();318        } finally {319          setSaving(false);320        }321      }322      startEdit("username", "");323      return;324    }325  }326327  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {328    switch (field) {329      case "title":330        return entry.title;331      case "url":332        return entry.url ?? "";333      case "username":334        return entry.username ?? "";335      case "email":336        return entry.email ?? "";337      case "password":338        return entry.password ?? "";339      case "notes":340        return entry.notes ?? "";341      case "totp":342        return entry.totp ?? "";343    }344  }345346  function handleEditorFocusOut(347    field: Exclude<EditableField, null>,348    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }349  ) {350    const nextTarget = event.relatedTarget;351    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;352353    const entry = props.entry;354    if (!entry) return;355356    const currentValue = fieldValue(entry, field);357    const nextValue = field === "password" ? draft() : draft().trim();358    if (nextValue === currentValue) {359      cancelEdit();360    }361  }362363  async function savePassword(password: string) {364    const entry = props.entry;365    if (!entry) return;366    setSaving(true);367    try {368      await sendMessage({369        type: "UPDATE_ENTRY",370        payload: { id: entry.id, patch: { password } },371      });372      triggerRing();373      setShowGenerator(false);374      await props.onFieldSaved();375    } catch {376      // silent377    } finally {378      setSaving(false);379    }380  }381382  // ── Copy ─────────────────────────────────────────────────────────383384  async function copyField(text: string, label: string, button?: HTMLButtonElement) {385    const ok = await popupCopy(text, label);386    if (ok) {387      const entry = props.entry;388      if (entry?.canary?.webhookUrl) {389        const trigger =390          label === "password"391            ? "password_copied"392            : label === "username"393              ? "username_copied"394              : undefined;395        if (trigger) {396          void sendMessage({397            type: "FIRE_CANARY_PING",398            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },399          });400        }401      }402      triggerRing();403      triggerPipPulse();404      setCopied(null);405      requestAnimationFrame(() => setCopied(label));406      if (button) {407        button.classList.remove("is-copied", "is-retreat");408        void button.offsetWidth;409        const drain = document.createElement("span");410        drain.className = "peach-copy-drain";411        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";412        drain.appendChild(bar);413        button.appendChild(drain);414        window.setTimeout(() => {415          button.classList.remove("is-copied");416          button.classList.add("is-retreat");417          drain.remove();418          window.setTimeout(() => button.classList.remove("is-retreat"), 160);419        }, 1800);420      }421      window.setTimeout(() => setCopied(null), 1800);422    }423  }424425  // ── Keyboard shortcuts ────────────────────────────────────────────426427  createEffect(() => {428    if (!props.entry) return;429    const handler = (e: KeyboardEvent) => {430      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;431      if (inInput) return;432      switch (e.key) {433        case "c": e.preventDefault();434          if (props.entry?.password) void copyField(props.entry.password, "password"); break;435        case "u": e.preventDefault();436          if (props.entry?.username) void copyField(props.entry.username, "username"); break;437        case "Escape": e.preventDefault(); props.onEscape?.(); break;438      }439    };440    window.addEventListener("keydown", handler);441    onCleanup(() => window.removeEventListener("keydown", handler));442  });443444  // ── Input ref focus helper ────────────────────────────────────────445446  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {447    requestAnimationFrame(() => { el.focus(); el.select?.(); });448  }449450  function onEditKeyDown(e: KeyboardEvent) {451    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {452      e.preventDefault();453      void saveField();454    }455    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }456  }457458  return (459    <>460      <div461        class="peach-ghost"462        aria-hidden="true"463        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;"464      >465        {props.entry?.title || "empty"}466      </div>467468      <div class="peach-stage" style="position:relative;">469        <Show when={props.entry} fallback={<EmptyVaultState />}>470          {(entry) => (471            <>472              {/* ── Breach strip ─────────────────────────────────── */}473              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>474                <div class="peach-breach-badge">475                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">476                    <path d="M6 1L11 10H1L6 1z" />477                    <line x1="6" y1="5" x2="6" y2="7.5" />478                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />479                  </svg>480                  <span style="flex:1">found in a known data breach</span>481                  <button482                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"483                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}484                  >485                    Change password486                  </button>487                </div>488              </Show>489490              {/* ── Title + trash ─────────────────────────────────── */}491              <div class="peach-entry-header">492                <div class="peach-entry-header-name">493                  <Show494                    when={editingField() === "title"}495                    fallback={496                      <button497                        class="peach-entry-name-button"498                        title="Click to edit title"499                        onClick={() => startEdit("title", entry().title)}500                      >501                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>502                          <span class="peach-entry-name-text">503                            <Show504                              when={useAnimatedTitle()}505                              fallback={entry().title}506                            >507                              <For each={entry().title.split("")}>508                                {(ch, i) => (509                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>510                                    {ch === " " ? "\u00A0" : ch}511                                  </span>512                                )}513                              </For>514                            </Show>515                          </span>516                        </h1>517                      </button>518                    }519                  >520                    <div521                      class="peach-inline-title-wrap"522                      onFocusOut={(event) => handleEditorFocusOut("title", event)}523                    >524                      <input525                        class="peach-inline-title-input"526                        value={draft()}527                        onInput={(e) => setDraft(e.currentTarget.value)}528                        onKeyDown={onEditKeyDown}529                        ref={autoFocus}530                        disabled={saving()}531                      />532                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />533                    </div>534                  </Show>535                </div>536537                {/* Subtle trash icon — hidden while editing title */}538                <Show when={editingField() !== "title"}>539                  <Show540                    when={confirmDelete()}541                    fallback={542                      <button543                        class="peach-entry-trash-btn"544                        onClick={() => setConfirmDelete(true)}545                        aria-label="Move to trash"546                        title="Delete entry"547                      >548                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">549                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />550                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />551                        </svg>552                      </button>553                    }554                  >555                    <div class="peach-entry-trash-confirm">556                      <span class="peach-entry-trash-confirm-label">trash?</span>557                      <button558                        class="peach-btn peach-btn--sm peach-btn--danger"559                        style="font-size:10px;padding:2px 8px;min-height:22px;"560                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}561                      >562                        yes563                      </button>564                      <button565                        class="peach-btn peach-btn--ghost peach-btn--sm"566                        style="font-size:10px;padding:2px 8px;min-height:22px;"567                        onClick={() => setConfirmDelete(false)}568                      >569                        no570                      </button>571                    </div>572                  </Show>573                </Show>574              </div>575576              {/* ── Domain / URL ─────────────────────────────────── */}577              <Show when={entry().type !== "note" && entry().type !== "card"}>578                <div class="peach-domain" style="position:relative;z-index:1;">579                  <Show580                    when={editingField() === "url"}581                    fallback={582                      <div583                        class="peach-domain-row peach-inline-clickable"584                        title="Click to edit URL"585                        onClick={() => startEdit("url", entry().url ?? "")}586                      >587                        <Show when={faviconSrc()}>588                          <img589                            src={faviconSrc()!}590                            width="16"591                            height="16"592                            alt=""593                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"594                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}595                          />596                        </Show>597                        <span class="peach-domain-value">598                          {displayDomain() || (entry().url ? entry().url : "add a website url")}599                        </span>600                        <span class="peach-inline-edit-hint">edit</span>601                      </div>602                    }603                  >604                    <div605                      class="peach-inline-field-wrap peach-inline-field-wrap--url"606                      onFocusOut={(event) => handleEditorFocusOut("url", event)}607                    >608                      <div class="peach-inline-field-row">609                        <input610                          class="peach-inline-input"611                          type="url"612                          placeholder="https://"613                          value={draft()}614                          onInput={(e) => setDraft(e.currentTarget.value)}615                          onKeyDown={onEditKeyDown}616                          ref={autoFocus}617                          disabled={saving()}618                        />619                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />620                      </div>621                      <div class="peach-additional-urls">622                        <Show when={additionalUrlsDraft().length > 0}>623                          <span class="peach-label peach-additional-urls-label">also matches</span>624                        </Show>625                        <For each={additionalUrlsDraft()}>626                          {(value, i) => (627                            <div class="peach-additional-url-row">628                              <input629                                class="peach-inline-input"630                                type="url"631                                placeholder="https://"632                                value={value}633                                onInput={(e) => setAdditionalUrlsDraft((arr) => {634                                  const next = [...arr];635                                  next[i()] = e.currentTarget.value;636                                  return next;637                                })}638                                disabled={saving()}639                              />640                              <button641                                class="peach-additional-url-remove"642                                type="button"643                                aria-label="Remove URL"644                                onMouseDown={(e) => e.preventDefault()}645                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}646                                disabled={saving()}647                              >×</button>648                            </div>649                          )}650                        </For>651                        <button652                          class="peach-add-field-link peach-additional-urls-add"653                          type="button"654                          onMouseDown={(e) => e.preventDefault()}655                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}656                          disabled={saving()}657                        >658                          + add another url659                        </button>660                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>661                          <button662                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"663                            type="button"664                            title={props.currentTabUrl}665                            onMouseDown={(e) => e.preventDefault()}666                            onClick={() => addCurrentSite(props.currentTabUrl!)}667                            disabled={saving()}668                          >669                            + add current site670                          </button>671                        </Show>672                      </div>673                    </div>674                  </Show>675                </div>676              </Show>677678              {/* ── Tags — read-only chips (editing state lives at bottom) */}679              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>680                <div681                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"682                  onClick={startEditTags}683                  title="Click to edit tags"684                >685                  <For each={entry().tags!}>686                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}687                  </For>688                </div>689              </Show>690691              {/* ── Note toolbar + inline editor ─────────────────── */}692              <Show when={entry().type === "note"}>693                <div class="peach-entry-note-toolbar-row">694                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />695                </div>696                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">697                  <NoteEditorInline698                    entryKey={entry().id}699                    initialContent={entry().notes ?? ""}700                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}701                    onBlur={saveNoteInline}702                    autoFocus={false}703                  />704                </section>705              </Show>706707              {/* ── Card detail view ─────────────────────────────── */}708              <Show when={entry().type === "card"}>709                <section class="peach-fields" style="position:relative;z-index:1;">710                  {/* Card subtitle: •••• last4 */}711                  <Show when={entry().last4}>712                    <div class="peach-field-card" style="padding-bottom:4px;">713                      <span class="peach-label">714                        <Show when={entry().network && entry().network !== "unknown"}>715                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>716                        </Show>717                        {entry().cardNickname || `•••• ${entry().last4}`}718                      </span>719                    </div>720                  </Show>721722                  {/* Cardholder name */}723                  <div class="peach-field-card">724                    <span class="peach-label">cardholder name</span>725                    <div class="peach-field-value-row">726                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>727                      <Show when={entry().cardholderName}>728                        <button729                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}730                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}731                          aria-label="Copy cardholder name"732                        >733                          <CopyGlyph /><span class="peach-copy-sheen" />734                        </button>735                      </Show>736                    </div>737                  </div>738739                  {/* Card number */}740                  <div class="peach-field-card">741                    <span class="peach-label">card number</span>742                    <div class="peach-field-value-row">743                      <span class={`peach-field-value peach-card-number`}>744                        <Show when={showCardNumber()} fallback={745                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>746                        }>747                          <span>{entry().cardNumber ?? ""}</span>748                        </Show>749                      </span>750                      <div class="peach-field-actions">751                        <button752                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"753                          onClick={() => setShowCardNumber(!showCardNumber())}754                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}755                        >756                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>757                            <EyeClosedGlyph />758                          </Show>759                        </button>760                        <Show when={entry().cardNumber}>761                          <button762                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}763                            onClick={(e) => {764                              const cardNum = entry().cardNumber!;765                              if (entry().canary?.webhookUrl) {766                                void sendMessage({767                                  type: "FIRE_CANARY_PING",768                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },769                                });770                              }771                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);772                            }}773                            aria-label="Copy card number"774                          >775                            <CopyGlyph /><span class="peach-copy-sheen" />776                          </button>777                        </Show>778                      </div>779                    </div>780                  </div>781782                  {/* Expiry */}783                  <Show when={entry().expMonth || entry().expYear}>784                    <div class="peach-field-card">785                      <span class="peach-label">expiry</span>786                      <div class="peach-field-value-row">787                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>788                        <button789                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}790                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}791                          aria-label="Copy expiry"792                        >793                          <CopyGlyph /><span class="peach-copy-sheen" />794                        </button>795                      </div>796                    </div>797                  </Show>798799                  {/* CVC */}800                  <Show when={entry().cvc}>801                    <div class="peach-field-card">802                      <span class="peach-label">CVC</span>803                      <div class="peach-field-value-row">804                        <span class="peach-field-value">805                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>806                            <span>{entry().cvc}</span>807                          </Show>808                        </span>809                        <div class="peach-field-actions">810                          <button811                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"812                            onClick={() => setShowCardCvc(!showCardCvc())}813                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}814                          >815                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>816                              <EyeClosedGlyph />817                            </Show>818                          </button>819                          <button820                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}821                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}822                            aria-label="Copy CVC"823                          >824                            <CopyGlyph /><span class="peach-copy-sheen" />825                          </button>826                        </div>827                      </div>828                    </div>829                  </Show>830                </section>831              </Show>832833              {/* ── Login fields ─────────────────────────────────── */}834              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>835                <section class="peach-fields" style="position:relative;z-index:1;">836837                  {/* Username — primary identity slot. Auto-labels as "email"838                      when the stored value is email-shaped and no separate839                      email field exists (legacy data path). */}840                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>841                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>842                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>843                      <Show844                        when={editingField() === "username"}845                        fallback={846                          <div class="peach-field-value-row">847                            <span848                              class="peach-field-value peach-inline-clickable"849                              title="Click to edit"850                              onClick={() => startEdit("username", entry().username ?? "")}851                            >852                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}853                            </span>854                            <Show when={entry().username}>855                              <button856                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}857                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}858                                aria-label="Copy username"859                              >860                                <CopyGlyph /><span class="peach-copy-sheen" />861                              </button>862                            </Show>863                          </div>864                        }865                      >866                        <div867                          class="peach-inline-field-wrap"868                          onFocusOut={(event) => handleEditorFocusOut("username", event)}869                        >870                          <input871                            class="peach-inline-input"872                            value={draft()}873                            onInput={(e) => setDraft(e.currentTarget.value)}874                            onKeyDown={onEditKeyDown}875                            ref={autoFocus}876                            disabled={saving()}877                            autocomplete="off"878                          />879                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />880                          {/* +add the OTHER identity. If the slot is acting as881                              email (value has @, no separate email), offer882                              "+ add username" — clicking migrates the email883                              into entry.email. Otherwise offer "+ add email". */}884                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>885                            <button886                              class="peach-add-field-btn"887                              type="button"888                              onMouseDown={(e) => e.preventDefault()}889                              onClick={() => {890                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);891                                const hasSeparateEmail = !!entry().email;892                                if (isEmailShape && !hasSeparateEmail) {893                                  void addOtherIdentity("username");894                                } else {895                                  void addOtherIdentity("email");896                                }897                              }}898                            >899                              {(() => {900                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);901                                const hasSeparateEmail = !!entry().email;902                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";903                              })()}904                            </button>905                          </Show>906                        </div>907                      </Show>908                    </div>909                  </Show>910911                  {/* Email — secondary identity slot. Visible whenever a912                      separate email field is set, or while it is being edited. */}913                  <Show when={entry().email || editingField() === "email"}>914                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>915                      <span class="peach-label">email</span>916                      <Show917                        when={editingField() === "email"}918                        fallback={919                          <div class="peach-field-value-row">920                            <span921                              class="peach-field-value peach-inline-clickable"922                              title="Click to edit"923                              onClick={() => startEdit("email", entry().email ?? "")}924                            >925                              {entry().email}926                            </span>927                            <Show when={entry().email}>928                              <button929                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}930                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}931                                aria-label="Copy email"932                              >933                                <CopyGlyph /><span class="peach-copy-sheen" />934                              </button>935                            </Show>936                          </div>937                        }938                      >939                        <div940                          class="peach-inline-field-wrap"941                          onFocusOut={(event) => handleEditorFocusOut("email", event)}942                        >943                          <input944                            class="peach-inline-input"945                            value={draft()}946                            onInput={(e) => setDraft(e.currentTarget.value)}947                            onKeyDown={onEditKeyDown}948                            ref={autoFocus}949                            disabled={saving()}950                            autocomplete="email"951                            type="email"952                          />953                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />954                          <Show when={!entry().username || entry().username?.includes("@")}>955                            <button956                              class="peach-add-field-btn"957                              type="button"958                              onMouseDown={(e) => e.preventDefault()}959                              onClick={() => void addOtherIdentity("username")}960                            >961                              + add username962                            </button>963                          </Show>964                        </div>965                      </Show>966                    </div>967                  </Show>968969                  {/* Password */}970                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>971                    <span class="peach-label">password</span>972                    <Show973                      when={editingField() === "password"}974                      fallback={975                        <div class="peach-field-value-row">976                          <span977                            class="peach-password-value"978                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}979                            style="cursor:pointer;"980                            title="Click to edit password"981                            onClick={() => startEdit("password", entry().password ?? "")}982                          >983                            <Show984                              when={showPassword()}985                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}986                            >987                              <span class="peach-password-text">{entry().password ?? ""}</span>988                            </Show>989                          </span>990                          <div class="peach-field-actions">991                            <button992                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"993                              onClick={() => setShowPassword(!showPassword())}994                              aria-label="Reveal password"995                            >996                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>997                                <EyeClosedGlyph />998                              </Show>999                            </button>1000                            <button1001                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1002                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1003                              aria-label="Copy password"1004                            >1005                              <CopyGlyph /><span class="peach-copy-sheen" />1006                            </button>1007                          </div>1008                        </div>1009                      }1010                    >1011                      {/* ── Edit mode: type / paste / generate ── */}1012                      <div1013                        class="peach-inline-field-wrap"1014                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1015                      >1016                        <div class="peach-password-edit-row">1017                          <input1018                            class="peach-inline-input"1019                            type={showPassword() ? "text" : "password"}1020                            placeholder="paste or type a password"1021                            value={draft()}1022                            onInput={(e) => setDraft(e.currentTarget.value)}1023                            onKeyDown={(e) => {1024                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1025                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1026                            }}1027                            ref={autoFocus}1028                            disabled={saving()}1029                            autocomplete="new-password"1030                          />1031                          <button1032                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1033                            type="button"1034                            onClick={() => setShowPassword(!showPassword())}1035                            aria-label={showPassword() ? "Hide" : "Show"}1036                          >1037                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1038                              <EyeClosedGlyph />1039                            </Show>1040                          </button>1041                        </div>1042                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1043                        <button1044                          class="peach-generate-toggle-link"1045                          type="button"1046                          onClick={() => setShowGenerator(!showGenerator())}1047                        >1048                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1049                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1050                            <path d="M9.5 2.5H11V1" />1051                          </svg>1052                          {showGenerator() ? "hide generator" : "generate"}1053                        </button>1054                        <Show when={showGenerator()}>1055                          <GeneratorDrawer1056                            open={true}1057                            compact={true}1058                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1059                          />1060                        </Show>1061                      </div>1062                    </Show>1063                  </div>10641065                </section>1066              </Show>10671068              {/* ── Notes (on login entries) ──────────────────────── */}1069              <Show when={entry().type !== "note" && entry().type !== "card"}>1070                <Show1071                  when={entry().notes || editingField() === "notes"}1072                  fallback={1073                    <button1074                      class="peach-add-field-link peach-entry-add-link"1075                      onClick={() => startEdit("notes", "")}1076                    >1077                      + notes1078                    </button>1079                  }1080                >1081                  <div style="position:relative;z-index:1;margin-top:6px;">1082                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1083                    <Show1084                      when={editingField() === "notes"}1085                      fallback={1086                        <div1087                          class="peach-note-content peach-inline-clickable"1088                          style="cursor:text;white-space:pre-wrap;"1089                          onClick={() => startEdit("notes", entry().notes ?? "")}1090                        >1091                          {entry().notes}1092                        </div>1093                      }1094                    >1095                      <div1096                        class="peach-inline-field-wrap"1097                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1098                      >1099                        <textarea1100                          class="peach-inline-textarea"1101                          value={draft()}1102                          onInput={(e) => setDraft(e.currentTarget.value)}1103                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1104                          ref={(el) => requestAnimationFrame(() => el?.focus())}1105                          rows={4}1106                          disabled={saving()}1107                        />1108                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1109                      </div>1110                    </Show>1111                  </div>1112                </Show>1113              </Show>11141115              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1116              <Show1117                when={editingTags()}1118                fallback={1119                  <Show when={!entry().tags || entry().tags!.length === 0}>1120                    <button1121                      class="peach-add-field-link peach-entry-add-link"1122                      onClick={startEditTags}1123                    >1124                      + tags1125                    </button>1126                  </Show>1127                }1128              >1129                <div1130                  class="peach-inline-tag-editor"1131                  onFocusOut={(e) => {1132                    const next = e.relatedTarget;1133                    if (next instanceof Node && e.currentTarget.contains(next)) return;1134                    void saveTags();1135                  }}1136                >1137                  <span class="peach-label">tags</span>1138                  <div class="peach-tag-input-wrap">1139                    <For each={tagsDraft()}>1140                      {(tag) => (1141                        <span class="peach-tag-chip">1142                          {tag}1143                          <button1144                            class="peach-tag-chip-remove"1145                            onMouseDown={(e) => e.preventDefault()}1146                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1147                            aria-label={`Remove ${tag}`}1148                          >×</button>1149                        </span>1150                      )}1151                    </For>1152                    <input1153                      class="peach-tag-input"1154                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1155                      value={tagInput()}1156                      onInput={(e) => setTagInput(e.currentTarget.value)}1157                      onKeyDown={(e) => {1158                        if (e.key === "Enter" || e.key === ",") {1159                          e.preventDefault();1160                          addTag(tagInput());1161                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1162                          setTagsDraft(tagsDraft().slice(0, -1));1163                        } else if (e.key === "Escape") {1164                          e.preventDefault();1165                          setEditingTags(false);1166                          setTagInput("");1167                        }1168                      }}1169                      ref={(el) => requestAnimationFrame(() => el?.focus())}1170                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1171                    />1172                  </div>1173                </div>1174              </Show>11751176              {/* ── TOTP ─────────────────────────────────────────── */}1177              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1178                <Show1179                  when={totpState() || editingField() === "totp"}1180                  fallback={1181                    <button1182                      class="peach-add-field-link peach-entry-add-link"1183                      onClick={() => startEdit("totp", "")}1184                    >1185                      + 2FA1186                    </button>1187                  }1188                >1189                  <section class="peach-totp-band">1190                    <Show1191                      when={editingField() === "totp"}1192                      fallback={1193                        <Show when={totpState()}>1194                          {(state) => (1195                            <>1196                              <div1197                                class="peach-totp-main"1198                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1199                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1200                                title="Click to copy · double-click to edit"1201                              >1202                                <span class="peach-label">2-factor</span>1203                                <div class="peach-totp-code-row">1204                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1205                                    <span class="peach-totp-code-text">1206                                      {state().code.slice(0, 3)} {state().code.slice(3)}1207                                    </span>1208                                  </p>1209                                </div>1210                              </div>1211                              <div class="peach-totp-meta">1212                                <span class="peach-label">expires</span>1213                                <div class="peach-totp-countdown">{state().remaining}s</div>1214                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1215                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1216                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1217                                    stroke-dasharray="106.8"1218                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1219                                  />1220                                </svg>1221                              </div>1222                            </>1223                          )}1224                        </Show>1225                      }1226                    >1227                      <div1228                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1229                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1230                      >1231                        <span class="peach-label">2-factor</span>1232                        <input1233                          class="peach-inline-input"1234                          value={draft()}1235                          onInput={(e) => setDraft(e.currentTarget.value)}1236                          onKeyDown={onEditKeyDown}1237                          ref={autoFocus}1238                          disabled={saving()}1239                          placeholder="otpauth://... or base32 secret"1240                          autocomplete="off"1241                        />1242                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1243                      </div>1244                    </Show>1245                  </section>1246                </Show>1247              </Show>12481249              {/* ── Advanced (canary + passkey) ───────────────────── */}1250              <Show when={entry().type === "login"}>1251                <button1252                  class="peach-add-field-link peach-entry-add-link"1253                  onClick={() => setShowAdvanced((v) => !v)}1254                  aria-expanded={showAdvanced()}1255                >1256                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1257                </button>1258                <Show when={showAdvanced()}>1259                  <Show when={!isPasskeyOnly(entry())}>1260                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1261                  </Show>12621263                  <Show when={entry().passkey}>1264                    {(passkey) => {1265                      const currentBrowser = detectBrowser();1266                      const crossBrowser =1267                        passkey().browser &&1268                        currentBrowser !== "unknown" &&1269                        passkey().browser !== currentBrowser;1270                      const algorithmLabel =1271                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1272                      const formatDate = (ts?: number) =>1273                        ts ? new Date(ts).toLocaleDateString() : "Never";1274                      return (1275                        <section1276                          class="peach-fields peach-passkey-section"1277                          style="position:relative;z-index:1;margin-top:6px;"1278                        >1279                          <div class="peach-field-card">1280                            <span class="peach-label">passkey · {passkey().rpId}</span>1281                            <div class="peach-field-value-row">1282                              <span class="peach-field-value">{passkey().userName}</span>1283                              <span class="peach-passkey-badge">passkey</span>1284                            </div>1285                          </div>12861287                          <div class="peach-field-card">1288                            <span class="peach-label">algorithm</span>1289                            <div class="peach-field-value-row">1290                              <span class="peach-field-value">{algorithmLabel}</span>1291                            </div>1292                          </div>12931294                          <div class="peach-field-card">1295                            <span class="peach-label">activity</span>1296                            <div class="peach-field-value-row peach-passkey-activity">1297                              <span class="peach-field-value">1298                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1299                              </span>1300                              <span class="peach-passkey-meta">1301                                created {formatDate(passkey().createdAt)}1302                              </span>1303                              <span class="peach-passkey-meta">1304                                last used {formatDate(passkey().lastUsedAt)}1305                              </span>1306                            </div>1307                          </div>13081309                          <Show when={crossBrowser}>1310                            <div class="peach-info-notice peach-info-notice--warn">1311                              {`This passkey was created on ${1312                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1313                              }. To use it on ${1314                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1315                              }, re-register it on this device.`}1316                            </div>1317                          </Show>13181319                          <div class="peach-info-notice">1320                            {passkey().browser === "firefox"1321                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1322                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1323                          </div>13241325                          <button1326                            class="peach-add-field-link peach-entry-add-link"1327                            style="color:rgba(220,38,38,0.7);"1328                            onClick={async () => {1329                              const id = entry().id;1330                              await sendMessage({1331                                type: "UPDATE_ENTRY",1332                                payload: { id, patch: { passkey: undefined } },1333                              });1334                              await props.onFieldSaved();1335                            }}1336                          >1337                            − remove passkey1338                          </button>1339                        </section>1340                      );1341                    }}1342                  </Show>1343                </Show>1344              </Show>13451346              {/* ── Bottom spacer ─────────────────────────────────── */}1347              <div style="height:4px;" />1348            </>1349          )}1350        </Show>1351      </div>13521353    </>1354  );1355}13561357// ── Sub-components ────────────────────────────────────────────────────13581359function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1360  return (1361    <div class="peach-inline-actions">1362      <button1363        class="peach-inline-save-btn"1364        onClick={props.onSave}1365        disabled={props.saving}1366        aria-label="Save"1367        title="Save (Enter)"1368      >1369        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1370          <path d="M1 4.5L4 7.5L10 1.5" />1371        </svg>1372      </button>1373      <button1374        class="peach-inline-cancel-btn"1375        onClick={props.onCancel}1376        disabled={props.saving}1377        aria-label="Cancel"1378        title="Cancel (Esc)"1379      >1380        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1381          <path d="M1 1L8 8M8 1L1 8" />1382        </svg>1383      </button>1384    </div>1385  );1386}13871388function EmptyVaultState() {1389  return (1390    <>1391      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1392        <div class="peach-entry-name">1393          <span class="peach-entry-name-text">1394            <span class="peach-entry-name-char" style="--char-index:0">e</span>1395            <span class="peach-entry-name-char" style="--char-index:1">m</span>1396            <span class="peach-entry-name-char" style="--char-index:2">p</span>1397            <span class="peach-entry-name-char" style="--char-index:3">t</span>1398            <span class="peach-entry-name-char" style="--char-index:4">y</span>1399          </span>1400        </div>1401      </div>1402      <div class="peach-empty-copy">1403        <span class="peach-label">vault</span>1404        <p>Your vault is ready for its first entry.</p>1405      </div>1406    </>1407  );1408}14091410function CopyGlyph() {1411  return (1412    <span class="peach-copy-icon">1413      <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">1414        <rect x="4" y="4" width="8" height="9" rx="1.5" />1415        <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" />1416      </svg>1417    </span>1418  );1419}14201421function EyeClosedGlyph() {1422  return (1423    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1424      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1425      <path d="M3.5 5.5L2.5 7.5" />1426      <path d="M7 6.5V8.5" />1427      <path d="M10.5 5.5L11.5 7.5" />1428    </svg>1429  );1430}14311432function EyeOpenGlyph() {1433  return (1434    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1435      <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" />1436      <circle cx="7" cy="5" r="1.75" />1437    </svg>1438  );1439}14401441function ChevronGlyph(props: { open: boolean }) {1442  return (1443    <svg1444      width="8"1445      height="8"1446      viewBox="0 0 8 8"1447      fill="none"1448      stroke="currentColor"1449      stroke-width="1.3"1450      stroke-linecap="round"1451      stroke-linejoin="round"1452      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1453    >1454      <path d="M2 1L6 4L2 7" />1455    </svg>1456  );1457}14581459function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1460  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1461  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1462  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1463  const [testResult, setTestResult] = createSignal<string | null>(null);1464  const [testing, setTesting] = createSignal(false);14651466  async function save() {1467    const url = webhookUrl().trim();1468    const nextLabel = label().trim() || undefined;1469    const canary = enabled() && url1470      ? {1471          webhookUrl: url,1472          label: nextLabel,1473          lastPinged: props.entry.canary?.lastPinged,1474          pingCount: props.entry.canary?.pingCount,1475        }1476      : undefined;1477    await sendMessage({1478      type: "UPDATE_ENTRY",1479      payload: { id: props.entry.id, patch: { canary } },1480    });1481    await props.onSaved();1482  }14831484  async function testPing() {1485    const url = webhookUrl().trim();1486    if (!url) return;1487    setTesting(true);1488    setTestResult(null);1489    const res = await sendMessage({1490      type: "FIRE_CANARY_PING",1491      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1492    }) as { ok: boolean; error?: string };1493    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1494    setTesting(false);1495  }14961497  return (1498    <div class="peach-canary-section">1499      <div class="peach-field-value-row">1500        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1501        <button1502          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1503          onClick={() => { setEnabled(!enabled()); void save(); }}1504          aria-label="Toggle canary"1505        >1506          <span class="peach-toggle-knob" />1507        </button>1508      </div>1509      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1510        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1511      </p>1512      <Show when={enabled()}>1513        <div class="peach-inline-field-wrap" style="margin-top:8px;">1514          <input1515            class="peach-inline-input"1516            placeholder="https://hooks.zapier.com/..."1517            value={webhookUrl()}1518            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1519            onBlur={() => void save()}1520          />1521          <input1522            class="peach-inline-input"1523            placeholder="label (optional, e.g. AWS decoy)"1524            value={label()}1525            onInput={(e) => setLabel(e.currentTarget.value)}1526            onBlur={() => void save()}1527          />1528          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1529            <button1530              class="peach-btn peach-btn--ghost peach-btn--sm"1531              onClick={() => void testPing()}1532              disabled={testing() || !webhookUrl().trim()}1533            >1534              {testing() ? "sending..." : "test ping"}1535            </button>1536            <Show when={testResult()}>1537              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1538            </Show>1539          </div>1540          <Show when={props.entry.canary?.lastPinged}>1541            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1542              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1543              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1544            </p>1545          </Show>1546        </div>1547      </Show>1548    </div>1549  );1550}