Entry.tsx

68.6 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  // Track the last id so a same-entry refresh (e.g. after saving a field)95  // doesn't blow away in-flight edit state — that race used to cancel96  // startEdit calls that ran right after onFieldSaved.97  let lastEntryId: string | null = null;98  createEffect(() => {99    const id = props.entry?.id ?? null;100    if (id === lastEntryId) return;101    lastEntryId = id;102    setConfirmDelete(false);103    setEditingField(null);104    setEditingTags(false);105    setShowAdvanced(false);106  });107108  const totpState = createMemo(() => {109    void totpTick();110    if (!props.entry?.totp) return null;111    return generateTOTP(props.entry.totp);112  });113114  let prevTotpCode: string | undefined;115  createEffect(() => {116    const state = totpState();117    if (!state) { prevTotpCode = undefined; return; }118    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {119      setTotpHot(true);120      window.setTimeout(() => setTotpHot(false), 3000);121    }122    prevTotpCode = state.code;123  });124125  const totpProgress = createMemo(() => {126    const state = totpState();127    if (!state) return 0;128    return ((state.remaining - 1) / 29) * 100;129  });130131  createEffect(() => {132    const shell = document.getElementById("hd-shell");133    if (!shell) return;134135    const state = totpState();136    if (!state) {137      delete shell.dataset.totpActive;138      shell.style.removeProperty("--peach-totp-progress");139      return;140    }141142    shell.dataset.totpActive = "true";143    shell.style.setProperty(144      "--peach-totp-progress",145      `${Math.max(0, totpProgress())}%`146    );147148    onCleanup(() => {149      delete shell.dataset.totpActive;150      shell.style.removeProperty("--peach-totp-progress");151    });152  });153154  const titleSizeClass = createMemo(() => {155    const length = props.entry?.title.length ?? 0;156    if (length >= 28) return "peach-entry-name--xxl";157    if (length >= 20) return "peach-entry-name--xl";158    if (length >= 14) return "peach-entry-name--lg";159    return "";160  });161  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);162163  // ── Inline editing ────────────────────────────────────────────────164165  function startEdit(field: EditableField, value: string) {166    setEditingField(field);167    setDraft(value);168    setShowGenerator(false);169    setConfirmDelete(false);170    if (field === "url") {171      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);172    }173  }174175  // Compare URLs by string after trim — vault.ts has its own canonicalisation176  // when saving, so the dedupe here is a UX-level "don't show the button if177  // we'd just add a duplicate" rather than a security check.178  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {179    const c = candidate.trim();180    if (!c) return true;181    if (primary.trim() === c) return true;182    return others.some((u) => u.trim() === c);183  }184185  function addCurrentSite(url: string) {186    if (!url) return;187    if (!draft().trim()) {188      setDraft(url);189      return;190    }191    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;192    setAdditionalUrlsDraft((arr) => [...arr, url]);193  }194195  async function saveField() {196    const field = editingField();197    const entry = props.entry;198    if (!field || !entry) return;199    // Don't trim passwords — spaces may be intentional200    let value = field === "password" ? draft() : draft().trim();201    if (field === "totp" && value.startsWith("otpauth://")) {202      const parsed = parseOtpauthURI(value);203      value = parsed?.secret ?? value;204    }205    if (field === "password" && entry.type !== "note" && !value) {206      return;207    }208    setSaving(true);209    try {210      const patch: Partial<VaultEntry> = { [field]: value };211      if (field === "url") {212        // Sanitization layer in vault.ts trims, dedupes, and drops empties.213        patch.additionalUrls = additionalUrlsDraft();214      }215      await sendMessage({216        type: "UPDATE_ENTRY",217        payload: { id: entry.id, patch },218      });219      triggerRing();220      setEditingField(null);221      setShowGenerator(false);222      await props.onFieldSaved();223    } catch {224      // silent — leave editing open225    } finally {226      setSaving(false);227    }228  }229230  function startEditTags() {231    setTagsDraft([...(props.entry?.tags ?? [])]);232    setTagInput("");233    setEditingTags(true);234  }235236  function addTag(value: string) {237    const n = value.toLowerCase().trim();238    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;239    setTagsDraft([...tagsDraft(), n]);240    setTagInput("");241  }242243  async function saveTags() {244    if (tagInput()) addTag(tagInput());245    const entry = props.entry;246    if (!entry) return;247    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;248    setEditingTags(false);249    setSaving(true);250    try {251      await sendMessage({252        type: "UPDATE_ENTRY",253        payload: { id: entry.id, patch: { tags: next } },254      });255      triggerRing();256      await props.onFieldSaved();257    } catch {258      // silent259    } finally {260      setSaving(false);261    }262  }263264  function cancelEdit() {265    setEditingField(null);266    setDraft("");267    setShowGenerator(false);268  }269270  // Switch from one identity slot to the other.271  //272  // Migration source is the SAVED entry value, never the live draft — using273  // the draft caused data loss when the user typed or cleared the input274  // before clicking +add (the migration would skip and a later Save would275  // overwrite the original email-shape value with whatever was in the input).276  //277  // Drafts only get committed when they are non-empty AND actually changed,278  // so an empty input never accidentally clears a saved field.279  async function addOtherIdentity(target: "username" | "email") {280    const entry = props.entry;281    if (!entry) return;282    const current = editingField();283    const draftValue = draft().trim();284    const savedUsername = entry.username ?? "";285    const savedEmail = entry.email ?? "";286287    if (current === "username" && target === "username") {288      // Legacy fix-up: saved username is email-shaped and no separate email289      // exists yet. Move it to entry.email and free the username slot.290      if (savedUsername.includes("@") && !entry.email) {291        setSaving(true);292        try {293          await sendMessage({294            type: "UPDATE_ENTRY",295            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },296          });297          await props.onFieldSaved();298        } finally {299          setSaving(false);300        }301      }302      startEdit("username", "");303      return;304    }305306    if (current === "username" && target === "email") {307      if (draftValue && draftValue !== savedUsername) {308        setSaving(true);309        try {310          await sendMessage({311            type: "UPDATE_ENTRY",312            payload: { id: entry.id, patch: { username: draftValue } },313          });314          await props.onFieldSaved();315        } finally {316          setSaving(false);317        }318      }319      startEdit("email", "");320      return;321    }322323    if (current === "email" && target === "username") {324      if (draftValue && draftValue !== savedEmail) {325        setSaving(true);326        try {327          await sendMessage({328            type: "UPDATE_ENTRY",329            payload: { id: entry.id, patch: { email: draftValue } },330          });331          await props.onFieldSaved();332        } finally {333          setSaving(false);334        }335      }336      startEdit("username", "");337      return;338    }339  }340341  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {342    switch (field) {343      case "title":344        return entry.title;345      case "url":346        return entry.url ?? "";347      case "username":348        return entry.username ?? "";349      case "email":350        return entry.email ?? "";351      case "password":352        return entry.password ?? "";353      case "notes":354        return entry.notes ?? "";355      case "totp":356        return entry.totp ?? "";357    }358  }359360  function handleEditorFocusOut(361    field: Exclude<EditableField, null>,362    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }363  ) {364    const nextTarget = event.relatedTarget;365    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;366367    const entry = props.entry;368    if (!entry) return;369370    const currentValue = fieldValue(entry, field);371    const nextValue = field === "password" ? draft() : draft().trim();372    if (nextValue === currentValue) {373      cancelEdit();374    }375  }376377  async function savePassword(password: string) {378    const entry = props.entry;379    if (!entry) return;380    setSaving(true);381    try {382      await sendMessage({383        type: "UPDATE_ENTRY",384        payload: { id: entry.id, patch: { password } },385      });386      triggerRing();387      setShowGenerator(false);388      await props.onFieldSaved();389    } catch {390      // silent391    } finally {392      setSaving(false);393    }394  }395396  // ── Copy ─────────────────────────────────────────────────────────397398  async function copyField(text: string, label: string, button?: HTMLButtonElement) {399    const ok = await popupCopy(text, label);400    if (ok) {401      const entry = props.entry;402      if (entry?.canary?.webhookUrl) {403        const trigger =404          label === "password"405            ? "password_copied"406            : label === "username"407              ? "username_copied"408              : undefined;409        if (trigger) {410          void sendMessage({411            type: "FIRE_CANARY_PING",412            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },413          });414        }415      }416      triggerRing();417      triggerPipPulse();418      setCopied(null);419      requestAnimationFrame(() => setCopied(label));420      if (button) {421        button.classList.remove("is-copied", "is-retreat");422        void button.offsetWidth;423        const drain = document.createElement("span");424        drain.className = "peach-copy-drain";425        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";426        drain.appendChild(bar);427        button.appendChild(drain);428        window.setTimeout(() => {429          button.classList.remove("is-copied");430          button.classList.add("is-retreat");431          drain.remove();432          window.setTimeout(() => button.classList.remove("is-retreat"), 160);433        }, 1800);434      }435      window.setTimeout(() => setCopied(null), 1800);436    }437  }438439  // ── Keyboard shortcuts ────────────────────────────────────────────440441  createEffect(() => {442    if (!props.entry) return;443    const handler = (e: KeyboardEvent) => {444      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;445      if (inInput) return;446      switch (e.key) {447        case "c": e.preventDefault();448          if (props.entry?.password) void copyField(props.entry.password, "password"); break;449        case "u": e.preventDefault();450          if (props.entry?.username) void copyField(props.entry.username, "username"); break;451        case "Escape": e.preventDefault(); props.onEscape?.(); break;452      }453    };454    window.addEventListener("keydown", handler);455    onCleanup(() => window.removeEventListener("keydown", handler));456  });457458  // ── Input ref focus helper ────────────────────────────────────────459460  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {461    requestAnimationFrame(() => { el.focus(); el.select?.(); });462  }463464  function onEditKeyDown(e: KeyboardEvent) {465    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {466      e.preventDefault();467      void saveField();468    }469    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }470  }471472  return (473    <>474      <div475        class="peach-ghost"476        aria-hidden="true"477        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;"478      >479        {props.entry?.title || "empty"}480      </div>481482      <div class="peach-stage" style="position:relative;">483        <Show when={props.entry} fallback={<EmptyVaultState />}>484          {(entry) => (485            <>486              {/* ── Breach strip ─────────────────────────────────── */}487              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>488                <div class="peach-breach-badge">489                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">490                    <path d="M6 1L11 10H1L6 1z" />491                    <line x1="6" y1="5" x2="6" y2="7.5" />492                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />493                  </svg>494                  <span style="flex:1">found in a known data breach</span>495                  <button496                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"497                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}498                  >499                    Change password500                  </button>501                </div>502              </Show>503504              {/* ── Title + trash ─────────────────────────────────── */}505              <div class="peach-entry-header">506                <div class="peach-entry-header-name">507                  <Show508                    when={editingField() === "title"}509                    fallback={510                      <button511                        class="peach-entry-name-button"512                        title="Click to edit title"513                        onClick={() => startEdit("title", entry().title)}514                      >515                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>516                          <span class="peach-entry-name-text">517                            <Show518                              when={useAnimatedTitle()}519                              fallback={entry().title}520                            >521                              <For each={entry().title.split("")}>522                                {(ch, i) => (523                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>524                                    {ch === " " ? "\u00A0" : ch}525                                  </span>526                                )}527                              </For>528                            </Show>529                          </span>530                        </h1>531                      </button>532                    }533                  >534                    <div535                      class="peach-inline-title-wrap"536                      onFocusOut={(event) => handleEditorFocusOut("title", event)}537                    >538                      <input539                        class="peach-inline-title-input"540                        value={draft()}541                        onInput={(e) => setDraft(e.currentTarget.value)}542                        onKeyDown={onEditKeyDown}543                        ref={autoFocus}544                        disabled={saving()}545                      />546                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />547                    </div>548                  </Show>549                </div>550551                {/* Subtle trash icon — hidden while editing title */}552                <Show when={editingField() !== "title"}>553                  <Show554                    when={confirmDelete()}555                    fallback={556                      <button557                        class="peach-entry-trash-btn"558                        onClick={() => setConfirmDelete(true)}559                        aria-label="Move to trash"560                        title="Delete entry"561                      >562                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">563                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />564                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />565                        </svg>566                      </button>567                    }568                  >569                    <div class="peach-entry-trash-confirm">570                      <span class="peach-entry-trash-confirm-label">trash?</span>571                      <button572                        class="peach-btn peach-btn--sm peach-btn--danger"573                        style="font-size:10px;padding:2px 8px;min-height:22px;"574                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}575                      >576                        yes577                      </button>578                      <button579                        class="peach-btn peach-btn--ghost peach-btn--sm"580                        style="font-size:10px;padding:2px 8px;min-height:22px;"581                        onClick={() => setConfirmDelete(false)}582                      >583                        no584                      </button>585                    </div>586                  </Show>587                </Show>588              </div>589590              {/* ── Domain / URL ─────────────────────────────────── */}591              <Show when={entry().type !== "note" && entry().type !== "card"}>592                <div class="peach-domain" style="position:relative;z-index:1;">593                  <Show594                    when={editingField() === "url"}595                    fallback={596                      <div597                        class="peach-domain-row peach-inline-clickable"598                        title="Click to edit URL"599                        onClick={() => startEdit("url", entry().url ?? "")}600                      >601                        <Show when={faviconSrc()}>602                          <img603                            src={faviconSrc()!}604                            width="16"605                            height="16"606                            alt=""607                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"608                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}609                          />610                        </Show>611                        <span class="peach-domain-value">612                          {displayDomain() || (entry().url ? entry().url : "add a website url")}613                        </span>614                        <span class="peach-inline-edit-hint">edit</span>615                      </div>616                    }617                  >618                    <div619                      class="peach-inline-field-wrap peach-inline-field-wrap--url"620                      onFocusOut={(event) => handleEditorFocusOut("url", event)}621                    >622                      <div class="peach-inline-field-row">623                        <input624                          class="peach-inline-input"625                          type="url"626                          placeholder="https://"627                          value={draft()}628                          onInput={(e) => setDraft(e.currentTarget.value)}629                          onKeyDown={onEditKeyDown}630                          ref={autoFocus}631                          disabled={saving()}632                        />633                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />634                      </div>635                      <div class="peach-additional-urls">636                        <Show when={additionalUrlsDraft().length > 0}>637                          <span class="peach-label peach-additional-urls-label">also matches</span>638                        </Show>639                        <For each={additionalUrlsDraft()}>640                          {(value, i) => (641                            <div class="peach-additional-url-row">642                              <input643                                class="peach-inline-input"644                                type="url"645                                placeholder="https://"646                                value={value}647                                onInput={(e) => setAdditionalUrlsDraft((arr) => {648                                  const next = [...arr];649                                  next[i()] = e.currentTarget.value;650                                  return next;651                                })}652                                disabled={saving()}653                              />654                              <button655                                class="peach-additional-url-remove"656                                type="button"657                                aria-label="Remove URL"658                                onMouseDown={(e) => e.preventDefault()}659                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}660                                disabled={saving()}661                              >×</button>662                            </div>663                          )}664                        </For>665                        <button666                          class="peach-add-field-link peach-additional-urls-add"667                          type="button"668                          onMouseDown={(e) => e.preventDefault()}669                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}670                          disabled={saving()}671                        >672                          + add another url673                        </button>674                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>675                          <button676                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"677                            type="button"678                            title={props.currentTabUrl}679                            onMouseDown={(e) => e.preventDefault()}680                            onClick={() => addCurrentSite(props.currentTabUrl!)}681                            disabled={saving()}682                          >683                            + add current site684                          </button>685                        </Show>686                      </div>687                    </div>688                  </Show>689                </div>690              </Show>691692              {/* ── Tags — read-only chips (editing state lives at bottom) */}693              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>694                <div695                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"696                  onClick={startEditTags}697                  title="Click to edit tags"698                >699                  <For each={entry().tags!}>700                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}701                  </For>702                </div>703              </Show>704705              {/* ── Note toolbar + inline editor ─────────────────── */}706              <Show when={entry().type === "note"}>707                <div class="peach-entry-note-toolbar-row">708                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />709                </div>710                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">711                  <NoteEditorInline712                    entryKey={entry().id}713                    initialContent={entry().notes ?? ""}714                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}715                    onBlur={saveNoteInline}716                    autoFocus={false}717                  />718                </section>719              </Show>720721              {/* ── Card detail view ─────────────────────────────── */}722              <Show when={entry().type === "card"}>723                <section class="peach-fields" style="position:relative;z-index:1;">724                  {/* Card subtitle: •••• last4 */}725                  <Show when={entry().last4}>726                    <div class="peach-field-card" style="padding-bottom:4px;">727                      <span class="peach-label">728                        <Show when={entry().network && entry().network !== "unknown"}>729                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>730                        </Show>731                        {entry().cardNickname || `•••• ${entry().last4}`}732                      </span>733                    </div>734                  </Show>735736                  {/* Cardholder name */}737                  <div class="peach-field-card">738                    <span class="peach-label">cardholder name</span>739                    <div class="peach-field-value-row">740                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>741                      <Show when={entry().cardholderName}>742                        <button743                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}744                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}745                          aria-label="Copy cardholder name"746                        >747                          <CopyGlyph /><span class="peach-copy-sheen" />748                        </button>749                      </Show>750                    </div>751                  </div>752753                  {/* Card number */}754                  <div class="peach-field-card">755                    <span class="peach-label">card number</span>756                    <div class="peach-field-value-row">757                      <span class={`peach-field-value peach-card-number`}>758                        <Show when={showCardNumber()} fallback={759                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>760                        }>761                          <span>{entry().cardNumber ?? ""}</span>762                        </Show>763                      </span>764                      <div class="peach-field-actions">765                        <button766                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"767                          onClick={() => setShowCardNumber(!showCardNumber())}768                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}769                        >770                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>771                            <EyeClosedGlyph />772                          </Show>773                        </button>774                        <Show when={entry().cardNumber}>775                          <button776                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}777                            onClick={(e) => {778                              const cardNum = entry().cardNumber!;779                              if (entry().canary?.webhookUrl) {780                                void sendMessage({781                                  type: "FIRE_CANARY_PING",782                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },783                                });784                              }785                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);786                            }}787                            aria-label="Copy card number"788                          >789                            <CopyGlyph /><span class="peach-copy-sheen" />790                          </button>791                        </Show>792                      </div>793                    </div>794                  </div>795796                  {/* Expiry */}797                  <Show when={entry().expMonth || entry().expYear}>798                    <div class="peach-field-card">799                      <span class="peach-label">expiry</span>800                      <div class="peach-field-value-row">801                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>802                        <button803                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}804                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}805                          aria-label="Copy expiry"806                        >807                          <CopyGlyph /><span class="peach-copy-sheen" />808                        </button>809                      </div>810                    </div>811                  </Show>812813                  {/* CVC */}814                  <Show when={entry().cvc}>815                    <div class="peach-field-card">816                      <span class="peach-label">CVC</span>817                      <div class="peach-field-value-row">818                        <span class="peach-field-value">819                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>820                            <span>{entry().cvc}</span>821                          </Show>822                        </span>823                        <div class="peach-field-actions">824                          <button825                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"826                            onClick={() => setShowCardCvc(!showCardCvc())}827                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}828                          >829                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>830                              <EyeClosedGlyph />831                            </Show>832                          </button>833                          <button834                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}835                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}836                            aria-label="Copy CVC"837                          >838                            <CopyGlyph /><span class="peach-copy-sheen" />839                          </button>840                        </div>841                      </div>842                    </div>843                  </Show>844                </section>845              </Show>846847              {/* ── Login fields ─────────────────────────────────── */}848              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>849                <section class="peach-fields" style="position:relative;z-index:1;">850851                  {/* Username — primary identity slot. Auto-labels as "email"852                      when the stored value is email-shaped and no separate853                      email field exists (legacy data path). */}854                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>855                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>856                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>857                      <Show858                        when={editingField() === "username"}859                        fallback={860                          <div class="peach-field-value-row">861                            <span862                              class="peach-field-value peach-inline-clickable"863                              title="Click to edit"864                              onClick={() => startEdit("username", entry().username ?? "")}865                            >866                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}867                            </span>868                            <Show when={entry().username}>869                              <button870                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}871                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}872                                aria-label="Copy username"873                              >874                                <CopyGlyph /><span class="peach-copy-sheen" />875                              </button>876                            </Show>877                          </div>878                        }879                      >880                        <div881                          class="peach-inline-field-wrap"882                          onFocusOut={(event) => handleEditorFocusOut("username", event)}883                        >884                          <input885                            class="peach-inline-input"886                            value={draft()}887                            onInput={(e) => setDraft(e.currentTarget.value)}888                            onKeyDown={onEditKeyDown}889                            ref={autoFocus}890                            disabled={saving()}891                            autocomplete="off"892                          />893                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />894                          {/* +add the OTHER identity. If the slot is acting as895                              email (value has @, no separate email), offer896                              "+ add username" — clicking migrates the email897                              into entry.email. Otherwise offer "+ add email". */}898                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>899                            <button900                              class="peach-add-field-btn"901                              type="button"902                              onMouseDown={(e) => e.preventDefault()}903                              onClick={() => {904                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);905                                const hasSeparateEmail = !!entry().email;906                                if (isEmailShape && !hasSeparateEmail) {907                                  void addOtherIdentity("username");908                                } else {909                                  void addOtherIdentity("email");910                                }911                              }}912                            >913                              {(() => {914                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);915                                const hasSeparateEmail = !!entry().email;916                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";917                              })()}918                            </button>919                          </Show>920                        </div>921                      </Show>922                    </div>923                  </Show>924925                  {/* Email — secondary identity slot. Visible whenever a926                      separate email field is set, or while it is being edited. */}927                  <Show when={entry().email || editingField() === "email"}>928                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>929                      <span class="peach-label">email</span>930                      <Show931                        when={editingField() === "email"}932                        fallback={933                          <div class="peach-field-value-row">934                            <span935                              class="peach-field-value peach-inline-clickable"936                              title="Click to edit"937                              onClick={() => startEdit("email", entry().email ?? "")}938                            >939                              {entry().email}940                            </span>941                            <Show when={entry().email}>942                              <button943                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}944                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}945                                aria-label="Copy email"946                              >947                                <CopyGlyph /><span class="peach-copy-sheen" />948                              </button>949                            </Show>950                          </div>951                        }952                      >953                        <div954                          class="peach-inline-field-wrap"955                          onFocusOut={(event) => handleEditorFocusOut("email", event)}956                        >957                          <input958                            class="peach-inline-input"959                            value={draft()}960                            onInput={(e) => setDraft(e.currentTarget.value)}961                            onKeyDown={onEditKeyDown}962                            ref={autoFocus}963                            disabled={saving()}964                            autocomplete="email"965                            type="email"966                          />967                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />968                          <Show when={!entry().username || entry().username?.includes("@")}>969                            <button970                              class="peach-add-field-btn"971                              type="button"972                              onMouseDown={(e) => e.preventDefault()}973                              onClick={() => void addOtherIdentity("username")}974                            >975                              + add username976                            </button>977                          </Show>978                        </div>979                      </Show>980                    </div>981                  </Show>982983                  {/* Password */}984                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>985                    <span class="peach-label">password</span>986                    <Show987                      when={editingField() === "password"}988                      fallback={989                        <div class="peach-field-value-row">990                          <span991                            class="peach-password-value"992                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}993                            style="cursor:pointer;"994                            title="Click to edit password"995                            onClick={() => startEdit("password", entry().password ?? "")}996                          >997                            <Show998                              when={showPassword()}999                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1000                            >1001                              <span class="peach-password-text">{entry().password ?? ""}</span>1002                            </Show>1003                          </span>1004                          <div class="peach-field-actions">1005                            <button1006                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1007                              onClick={() => setShowPassword(!showPassword())}1008                              aria-label="Reveal password"1009                            >1010                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1011                                <EyeClosedGlyph />1012                              </Show>1013                            </button>1014                            <button1015                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1016                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1017                              aria-label="Copy password"1018                            >1019                              <CopyGlyph /><span class="peach-copy-sheen" />1020                            </button>1021                          </div>1022                        </div>1023                      }1024                    >1025                      {/* ── Edit mode: type / paste / generate ── */}1026                      <div1027                        class="peach-inline-field-wrap"1028                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1029                      >1030                        <div class="peach-password-edit-row">1031                          <input1032                            class="peach-inline-input"1033                            type={showPassword() ? "text" : "password"}1034                            placeholder="paste or type a password"1035                            value={draft()}1036                            onInput={(e) => setDraft(e.currentTarget.value)}1037                            onKeyDown={(e) => {1038                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1039                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1040                            }}1041                            ref={autoFocus}1042                            disabled={saving()}1043                            autocomplete="new-password"1044                          />1045                          <button1046                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1047                            type="button"1048                            onClick={() => setShowPassword(!showPassword())}1049                            aria-label={showPassword() ? "Hide" : "Show"}1050                          >1051                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1052                              <EyeClosedGlyph />1053                            </Show>1054                          </button>1055                        </div>1056                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1057                        <button1058                          class="peach-generate-toggle-link"1059                          type="button"1060                          onClick={() => setShowGenerator(!showGenerator())}1061                        >1062                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1063                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1064                            <path d="M9.5 2.5H11V1" />1065                          </svg>1066                          {showGenerator() ? "hide generator" : "generate"}1067                        </button>1068                        <Show when={showGenerator()}>1069                          <GeneratorDrawer1070                            open={true}1071                            compact={true}1072                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1073                          />1074                        </Show>1075                      </div>1076                    </Show>1077                  </div>10781079                </section>1080              </Show>10811082              {/* ── Notes (on login entries) ──────────────────────── */}1083              <Show when={entry().type !== "note" && entry().type !== "card"}>1084                <Show1085                  when={entry().notes || editingField() === "notes"}1086                  fallback={1087                    <button1088                      class="peach-add-field-link peach-entry-add-link"1089                      onClick={() => startEdit("notes", "")}1090                    >1091                      + notes1092                    </button>1093                  }1094                >1095                  <div style="position:relative;z-index:1;margin-top:6px;">1096                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1097                    <Show1098                      when={editingField() === "notes"}1099                      fallback={1100                        <div1101                          class="peach-note-content peach-inline-clickable"1102                          style="cursor:text;white-space:pre-wrap;"1103                          onClick={() => startEdit("notes", entry().notes ?? "")}1104                        >1105                          {entry().notes}1106                        </div>1107                      }1108                    >1109                      <div1110                        class="peach-inline-field-wrap"1111                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1112                      >1113                        <textarea1114                          class="peach-inline-textarea"1115                          value={draft()}1116                          onInput={(e) => setDraft(e.currentTarget.value)}1117                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1118                          ref={(el) => requestAnimationFrame(() => el?.focus())}1119                          rows={4}1120                          disabled={saving()}1121                        />1122                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1123                      </div>1124                    </Show>1125                  </div>1126                </Show>1127              </Show>11281129              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1130              <Show1131                when={editingTags()}1132                fallback={1133                  <Show when={!entry().tags || entry().tags!.length === 0}>1134                    <button1135                      class="peach-add-field-link peach-entry-add-link"1136                      onClick={startEditTags}1137                    >1138                      + tags1139                    </button>1140                  </Show>1141                }1142              >1143                <div1144                  class="peach-inline-tag-editor"1145                  onFocusOut={(e) => {1146                    const next = e.relatedTarget;1147                    if (next instanceof Node && e.currentTarget.contains(next)) return;1148                    void saveTags();1149                  }}1150                >1151                  <span class="peach-label">tags</span>1152                  <div class="peach-tag-input-wrap">1153                    <For each={tagsDraft()}>1154                      {(tag) => (1155                        <span class="peach-tag-chip">1156                          {tag}1157                          <button1158                            class="peach-tag-chip-remove"1159                            onMouseDown={(e) => e.preventDefault()}1160                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1161                            aria-label={`Remove ${tag}`}1162                          >×</button>1163                        </span>1164                      )}1165                    </For>1166                    <input1167                      class="peach-tag-input"1168                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1169                      value={tagInput()}1170                      onInput={(e) => setTagInput(e.currentTarget.value)}1171                      onKeyDown={(e) => {1172                        if (e.key === "Enter" || e.key === ",") {1173                          e.preventDefault();1174                          addTag(tagInput());1175                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1176                          setTagsDraft(tagsDraft().slice(0, -1));1177                        } else if (e.key === "Escape") {1178                          e.preventDefault();1179                          setEditingTags(false);1180                          setTagInput("");1181                        }1182                      }}1183                      ref={(el) => requestAnimationFrame(() => el?.focus())}1184                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1185                    />1186                  </div>1187                </div>1188              </Show>11891190              {/* ── TOTP ─────────────────────────────────────────── */}1191              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1192                <Show1193                  when={totpState() || editingField() === "totp"}1194                  fallback={1195                    <button1196                      class="peach-add-field-link peach-entry-add-link"1197                      onClick={() => startEdit("totp", "")}1198                    >1199                      + 2FA1200                    </button>1201                  }1202                >1203                  <section class="peach-totp-band">1204                    <Show1205                      when={editingField() === "totp"}1206                      fallback={1207                        <Show when={totpState()}>1208                          {(state) => (1209                            <>1210                              <div1211                                class="peach-totp-main"1212                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1213                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1214                                title="Click to copy · double-click to edit"1215                              >1216                                <span class="peach-label">2-factor</span>1217                                <div class="peach-totp-code-row">1218                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1219                                    <span class="peach-totp-code-text">1220                                      {state().code.slice(0, 3)} {state().code.slice(3)}1221                                    </span>1222                                  </p>1223                                </div>1224                              </div>1225                              <div class="peach-totp-meta">1226                                <span class="peach-label">expires</span>1227                                <div class="peach-totp-countdown">{state().remaining}s</div>1228                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1229                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1230                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1231                                    stroke-dasharray="106.8"1232                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1233                                  />1234                                </svg>1235                              </div>1236                            </>1237                          )}1238                        </Show>1239                      }1240                    >1241                      <div1242                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1243                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1244                      >1245                        <span class="peach-label">2-factor</span>1246                        <input1247                          class="peach-inline-input"1248                          value={draft()}1249                          onInput={(e) => setDraft(e.currentTarget.value)}1250                          onKeyDown={onEditKeyDown}1251                          ref={autoFocus}1252                          disabled={saving()}1253                          placeholder="otpauth://... or base32 secret"1254                          autocomplete="off"1255                        />1256                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1257                      </div>1258                    </Show>1259                  </section>1260                </Show>1261              </Show>12621263              {/* ── Advanced (canary + passkey) ───────────────────── */}1264              <Show when={entry().type === "login"}>1265                <button1266                  class="peach-add-field-link peach-entry-add-link"1267                  onClick={() => setShowAdvanced((v) => !v)}1268                  aria-expanded={showAdvanced()}1269                >1270                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1271                </button>1272                <Show when={showAdvanced()}>1273                  <Show when={!isPasskeyOnly(entry())}>1274                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1275                  </Show>12761277                  <Show when={entry().passkey}>1278                    {(passkey) => {1279                      const currentBrowser = detectBrowser();1280                      const crossBrowser =1281                        passkey().browser &&1282                        currentBrowser !== "unknown" &&1283                        passkey().browser !== currentBrowser;1284                      const algorithmLabel =1285                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1286                      const formatDate = (ts?: number) =>1287                        ts ? new Date(ts).toLocaleDateString() : "Never";1288                      return (1289                        <section1290                          class="peach-fields peach-passkey-section"1291                          style="position:relative;z-index:1;margin-top:6px;"1292                        >1293                          <div class="peach-field-card">1294                            <span class="peach-label">passkey · {passkey().rpId}</span>1295                            <div class="peach-field-value-row">1296                              <span class="peach-field-value">{passkey().userName}</span>1297                              <span class="peach-passkey-badge">passkey</span>1298                            </div>1299                          </div>13001301                          <div class="peach-field-card">1302                            <span class="peach-label">algorithm</span>1303                            <div class="peach-field-value-row">1304                              <span class="peach-field-value">{algorithmLabel}</span>1305                            </div>1306                          </div>13071308                          <div class="peach-field-card">1309                            <span class="peach-label">activity</span>1310                            <div class="peach-field-value-row peach-passkey-activity">1311                              <span class="peach-field-value">1312                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1313                              </span>1314                              <span class="peach-passkey-meta">1315                                created {formatDate(passkey().createdAt)}1316                              </span>1317                              <span class="peach-passkey-meta">1318                                last used {formatDate(passkey().lastUsedAt)}1319                              </span>1320                            </div>1321                          </div>13221323                          <Show when={crossBrowser}>1324                            <div class="peach-info-notice peach-info-notice--warn">1325                              {`This passkey was created on ${1326                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1327                              }. To use it on ${1328                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1329                              }, re-register it on this device.`}1330                            </div>1331                          </Show>13321333                          <div class="peach-info-notice">1334                            {passkey().browser === "firefox"1335                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1336                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1337                          </div>13381339                          <button1340                            class="peach-add-field-link peach-entry-add-link"1341                            style="color:rgba(220,38,38,0.7);"1342                            onClick={async () => {1343                              const id = entry().id;1344                              await sendMessage({1345                                type: "UPDATE_ENTRY",1346                                payload: { id, patch: { passkey: undefined } },1347                              });1348                              await props.onFieldSaved();1349                            }}1350                          >1351                            − remove passkey1352                          </button>1353                        </section>1354                      );1355                    }}1356                  </Show>1357                </Show>1358              </Show>13591360              {/* ── Bottom spacer ─────────────────────────────────── */}1361              <div style="height:4px;" />1362            </>1363          )}1364        </Show>1365      </div>13661367    </>1368  );1369}13701371// ── Sub-components ────────────────────────────────────────────────────13721373function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1374  return (1375    <div class="peach-inline-actions">1376      <button1377        class="peach-inline-save-btn"1378        onClick={props.onSave}1379        disabled={props.saving}1380        aria-label="Save"1381        title="Save (Enter)"1382      >1383        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1384          <path d="M1 4.5L4 7.5L10 1.5" />1385        </svg>1386      </button>1387      <button1388        class="peach-inline-cancel-btn"1389        onClick={props.onCancel}1390        disabled={props.saving}1391        aria-label="Cancel"1392        title="Cancel (Esc)"1393      >1394        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1395          <path d="M1 1L8 8M8 1L1 8" />1396        </svg>1397      </button>1398    </div>1399  );1400}14011402function EmptyVaultState() {1403  return (1404    <>1405      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1406        <div class="peach-entry-name">1407          <span class="peach-entry-name-text">1408            <span class="peach-entry-name-char" style="--char-index:0">e</span>1409            <span class="peach-entry-name-char" style="--char-index:1">m</span>1410            <span class="peach-entry-name-char" style="--char-index:2">p</span>1411            <span class="peach-entry-name-char" style="--char-index:3">t</span>1412            <span class="peach-entry-name-char" style="--char-index:4">y</span>1413          </span>1414        </div>1415      </div>1416      <div class="peach-empty-copy">1417        <span class="peach-label">vault</span>1418        <p>Your vault is ready for its first entry.</p>1419      </div>1420    </>1421  );1422}14231424function CopyGlyph() {1425  return (1426    <span class="peach-copy-icon">1427      <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">1428        <rect x="4" y="4" width="8" height="9" rx="1.5" />1429        <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" />1430      </svg>1431    </span>1432  );1433}14341435function EyeClosedGlyph() {1436  return (1437    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1438      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1439      <path d="M3.5 5.5L2.5 7.5" />1440      <path d="M7 6.5V8.5" />1441      <path d="M10.5 5.5L11.5 7.5" />1442    </svg>1443  );1444}14451446function EyeOpenGlyph() {1447  return (1448    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1449      <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" />1450      <circle cx="7" cy="5" r="1.75" />1451    </svg>1452  );1453}14541455function ChevronGlyph(props: { open: boolean }) {1456  return (1457    <svg1458      width="8"1459      height="8"1460      viewBox="0 0 8 8"1461      fill="none"1462      stroke="currentColor"1463      stroke-width="1.3"1464      stroke-linecap="round"1465      stroke-linejoin="round"1466      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1467    >1468      <path d="M2 1L6 4L2 7" />1469    </svg>1470  );1471}14721473function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1474  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1475  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1476  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1477  const [testResult, setTestResult] = createSignal<string | null>(null);1478  const [testing, setTesting] = createSignal(false);14791480  async function save() {1481    const url = webhookUrl().trim();1482    const nextLabel = label().trim() || undefined;1483    const canary = enabled() && url1484      ? {1485          webhookUrl: url,1486          label: nextLabel,1487          lastPinged: props.entry.canary?.lastPinged,1488          pingCount: props.entry.canary?.pingCount,1489        }1490      : undefined;1491    await sendMessage({1492      type: "UPDATE_ENTRY",1493      payload: { id: props.entry.id, patch: { canary } },1494    });1495    await props.onSaved();1496  }14971498  async function testPing() {1499    const url = webhookUrl().trim();1500    if (!url) return;1501    setTesting(true);1502    setTestResult(null);1503    const res = await sendMessage({1504      type: "FIRE_CANARY_PING",1505      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1506    }) as { ok: boolean; error?: string };1507    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1508    setTesting(false);1509  }15101511  return (1512    <div class="peach-canary-section">1513      <div class="peach-field-value-row">1514        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1515        <button1516          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1517          onClick={() => { setEnabled(!enabled()); void save(); }}1518          aria-label="Toggle canary"1519        >1520          <span class="peach-toggle-knob" />1521        </button>1522      </div>1523      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1524        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1525      </p>1526      <Show when={enabled()}>1527        <div class="peach-inline-field-wrap" style="margin-top:8px;">1528          <input1529            class="peach-inline-input"1530            placeholder="https://hooks.zapier.com/..."1531            value={webhookUrl()}1532            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1533            onBlur={() => void save()}1534          />1535          <input1536            class="peach-inline-input"1537            placeholder="label (optional, e.g. AWS decoy)"1538            value={label()}1539            onInput={(e) => setLabel(e.currentTarget.value)}1540            onBlur={() => void save()}1541          />1542          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1543            <button1544              class="peach-btn peach-btn--ghost peach-btn--sm"1545              onClick={() => void testPing()}1546              disabled={testing() || !webhookUrl().trim()}1547            >1548              {testing() ? "sending..." : "test ping"}1549            </button>1550            <Show when={testResult()}>1551              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1552            </Show>1553          </div>1554          <Show when={props.entry.canary?.lastPinged}>1555            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1556              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1557              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1558            </p>1559          </Show>1560        </div>1561      </Show>1562    </div>1563  );1564}