Entry.tsx

69.4 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;52  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;53  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });5455  function noteExec(cmd: string, val?: string) {56    document.execCommand(cmd, false, val);57    noteEditorEl()?.focus();58  }5960  async function saveNoteInline(html: string) {61    const entry = props.entry;62    if (!entry) return;63    const currentStored = entry.notes ?? "";64    if (html === currentStored) return;65    if (lastSavedNote !== null && html === lastSavedNote) return;66    lastSavedNote = html;67    setSaving(true);68    try {69      await sendMessage({70        type: "UPDATE_ENTRY",71        payload: { id: entry.id, patch: { notes: html } },72      });73      triggerRing();74      await props.onFieldSaved();75    } catch {76      lastSavedNote = null;77    } finally {78      setSaving(false);79    }80  }8182  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);83  onCleanup(() => window.clearInterval(interval));8485  const displayDomain = createMemo(() =>86    props.entry?.url ? getDisplayDomain(props.entry.url) : ""87  );8889  createEffect(() => {90    const domain = displayDomain();91    setFaviconSrc(domain ? faviconUrl(domain) : null);92  });9394  // Reset transient prompts (trash confirmation, inline edits, advanced panel)95  // when the selected entry changes so they don't carry over between entries.96  // Track the last id so a same-entry refresh (e.g. after saving a field)97  // doesn't blow away in-flight edit state — that race used to cancel98  // startEdit calls that ran right after onFieldSaved.99  let lastEntryId: string | null = null;100  createEffect(() => {101    const id = props.entry?.id ?? null;102    if (id === lastEntryId) return;103    lastEntryId = id;104    setConfirmDelete(false);105    setEditingField(null);106    setEditingTags(false);107    setShowAdvanced(false);108  });109110  const totpState = createMemo(() => {111    void totpTick();112    if (!props.entry?.totp) return null;113    return generateTOTP(props.entry.totp);114  });115116  let prevTotpCode: string | undefined;117  createEffect(() => {118    const state = totpState();119    if (!state) { prevTotpCode = undefined; return; }120    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {121      setTotpHot(true);122      window.setTimeout(() => setTotpHot(false), 3000);123    }124    prevTotpCode = state.code;125  });126127  const totpProgress = createMemo(() => {128    const state = totpState();129    if (!state) return 0;130    return ((state.remaining - 1) / 29) * 100;131  });132133  createEffect(() => {134    const shell = document.getElementById("hd-shell");135    if (!shell) return;136137    const state = totpState();138    if (!state) {139      delete shell.dataset.totpActive;140      shell.style.removeProperty("--peach-totp-progress");141      return;142    }143144    shell.dataset.totpActive = "true";145    shell.style.setProperty(146      "--peach-totp-progress",147      `${Math.max(0, totpProgress())}%`148    );149150    onCleanup(() => {151      delete shell.dataset.totpActive;152      shell.style.removeProperty("--peach-totp-progress");153    });154  });155156  const titleSizeClass = createMemo(() => {157    const length = props.entry?.title.length ?? 0;158    if (length >= 28) return "peach-entry-name--xxl";159    if (length >= 20) return "peach-entry-name--xl";160    if (length >= 14) return "peach-entry-name--lg";161    return "";162  });163  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);164165  // ── Inline editing ────────────────────────────────────────────────166167  function startEdit(field: EditableField, value: string) {168    setEditingField(field);169    setDraft(value);170    setShowGenerator(false);171    setConfirmDelete(false);172    if (field === "url") {173      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);174    }175  }176177  // Compare URLs by string after trim — vault.ts has its own canonicalisation178  // when saving, so the dedupe here is a UX-level "don't show the button if179  // we'd just add a duplicate" rather than a security check.180  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {181    const c = candidate.trim();182    if (!c) return true;183    if (primary.trim() === c) return true;184    return others.some((u) => u.trim() === c);185  }186187  function addCurrentSite(url: string) {188    if (!url) return;189    if (!draft().trim()) {190      setDraft(url);191      return;192    }193    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;194    setAdditionalUrlsDraft((arr) => [...arr, url]);195  }196197  async function saveField() {198    const field = editingField();199    const entry = props.entry;200    if (!field || !entry) return;201    // Don't trim passwords — spaces may be intentional202    let value = field === "password" ? draft() : draft().trim();203    if (field === "totp" && value.startsWith("otpauth://")) {204      const parsed = parseOtpauthURI(value);205      value = parsed?.secret ?? value;206    }207    if (field === "password" && entry.type !== "note" && !value) {208      return;209    }210    setSaving(true);211    try {212      const patch: Partial<VaultEntry> = { [field]: value };213      if (field === "url") {214        // Sanitization layer in vault.ts trims, dedupes, and drops empties.215        patch.additionalUrls = additionalUrlsDraft();216      }217      await sendMessage({218        type: "UPDATE_ENTRY",219        payload: { id: entry.id, patch },220      });221      triggerRing();222      setEditingField(null);223      setShowGenerator(false);224      await props.onFieldSaved();225    } catch {226      // silent — leave editing open227    } finally {228      setSaving(false);229    }230  }231232  function startEditTags() {233    setTagsDraft([...(props.entry?.tags ?? [])]);234    setTagInput("");235    setEditingTags(true);236  }237238  function addTag(value: string) {239    const n = value.toLowerCase().trim();240    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;241    setTagsDraft([...tagsDraft(), n]);242    setTagInput("");243  }244245  async function saveTags() {246    if (tagInput()) addTag(tagInput());247    const entry = props.entry;248    if (!entry) return;249    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;250    setEditingTags(false);251    setSaving(true);252    try {253      await sendMessage({254        type: "UPDATE_ENTRY",255        payload: { id: entry.id, patch: { tags: next } },256      });257      triggerRing();258      await props.onFieldSaved();259    } catch {260      // silent261    } finally {262      setSaving(false);263    }264  }265266  function cancelEdit() {267    setEditingField(null);268    setDraft("");269    setShowGenerator(false);270  }271272  // Switch from one identity slot to the other.273  //274  // Migration source is the SAVED entry value, never the live draft — using275  // the draft caused data loss when the user typed or cleared the input276  // before clicking +add (the migration would skip and a later Save would277  // overwrite the original email-shape value with whatever was in the input).278  //279  // Drafts only get committed when they are non-empty AND actually changed,280  // so an empty input never accidentally clears a saved field.281  async function addOtherIdentity(target: "username" | "email") {282    const entry = props.entry;283    if (!entry) return;284    const current = editingField();285    const draftValue = draft().trim();286    const savedUsername = entry.username ?? "";287    const savedEmail = entry.email ?? "";288289    if (current === "username" && target === "username") {290      // Legacy fix-up: saved username is email-shaped and no separate email291      // exists yet. Move it to entry.email and free the username slot.292      if (savedUsername.includes("@") && !entry.email) {293        setSaving(true);294        try {295          await sendMessage({296            type: "UPDATE_ENTRY",297            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },298          });299          await props.onFieldSaved();300        } finally {301          setSaving(false);302        }303      }304      startEdit("username", "");305      return;306    }307308    if (current === "username" && target === "email") {309      if (draftValue && draftValue !== savedUsername) {310        setSaving(true);311        try {312          await sendMessage({313            type: "UPDATE_ENTRY",314            payload: { id: entry.id, patch: { username: draftValue } },315          });316          await props.onFieldSaved();317        } finally {318          setSaving(false);319        }320      }321      startEdit("email", "");322      return;323    }324325    if (current === "email" && target === "username") {326      if (draftValue && draftValue !== savedEmail) {327        setSaving(true);328        try {329          await sendMessage({330            type: "UPDATE_ENTRY",331            payload: { id: entry.id, patch: { email: draftValue } },332          });333          await props.onFieldSaved();334        } finally {335          setSaving(false);336        }337      }338      startEdit("username", "");339      return;340    }341  }342343  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {344    switch (field) {345      case "title":346        return entry.title;347      case "url":348        return entry.url ?? "";349      case "username":350        return entry.username ?? "";351      case "email":352        return entry.email ?? "";353      case "password":354        return entry.password ?? "";355      case "notes":356        return entry.notes ?? "";357      case "totp":358        return entry.totp ?? "";359    }360  }361362  function handleEditorFocusOut(363    field: Exclude<EditableField, null>,364    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }365  ) {366    const nextTarget = event.relatedTarget;367    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;368369    const entry = props.entry;370    if (!entry) return;371372    const currentValue = fieldValue(entry, field);373    const nextValue = field === "password" ? draft() : draft().trim();374    if (nextValue === currentValue) {375      cancelEdit();376    }377  }378379  async function savePassword(password: string) {380    const entry = props.entry;381    if (!entry) return;382    setSaving(true);383    try {384      await sendMessage({385        type: "UPDATE_ENTRY",386        payload: { id: entry.id, patch: { password } },387      });388      triggerRing();389      setShowGenerator(false);390      await props.onFieldSaved();391    } catch {392      // silent393    } finally {394      setSaving(false);395    }396  }397398  // ── Copy ─────────────────────────────────────────────────────────399400  async function copyField(text: string, label: string, button?: HTMLButtonElement) {401    const ok = await popupCopy(text, label);402    if (ok) {403      const entry = props.entry;404      if (entry?.canary?.webhookUrl) {405        const trigger =406          label === "password"407            ? "password_copied"408            : label === "username"409              ? "username_copied"410              : undefined;411        if (trigger) {412          void sendMessage({413            type: "FIRE_CANARY_PING",414            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },415          });416        }417      }418      triggerRing();419      triggerPipPulse();420      setCopied(null);421      requestAnimationFrame(() => setCopied(label));422      if (button) {423        button.classList.remove("is-copied", "is-retreat");424        void button.offsetWidth;425        const drain = document.createElement("span");426        drain.className = "peach-copy-drain";427        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";428        drain.appendChild(bar);429        button.appendChild(drain);430        window.setTimeout(() => {431          button.classList.remove("is-copied");432          button.classList.add("is-retreat");433          drain.remove();434          window.setTimeout(() => button.classList.remove("is-retreat"), 160);435        }, 1800);436      }437      window.setTimeout(() => setCopied(null), 1800);438    }439  }440441  // ── Keyboard shortcuts ────────────────────────────────────────────442443  createEffect(() => {444    if (!props.entry) return;445    const handler = (e: KeyboardEvent) => {446      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;447      if (inInput) return;448      switch (e.key) {449        case "c": e.preventDefault();450          if (props.entry?.password) void copyField(props.entry.password, "password"); break;451        case "u": e.preventDefault();452          if (props.entry?.username) void copyField(props.entry.username, "username"); break;453        case "Escape": e.preventDefault(); props.onEscape?.(); break;454      }455    };456    window.addEventListener("keydown", handler);457    onCleanup(() => window.removeEventListener("keydown", handler));458  });459460  // ── Input ref focus helper ────────────────────────────────────────461462  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {463    requestAnimationFrame(() => { el.focus(); el.select?.(); });464  }465466  function onEditKeyDown(e: KeyboardEvent) {467    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {468      e.preventDefault();469      void saveField();470    }471    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }472  }473474  return (475    <>476      <div477        class="peach-ghost"478        aria-hidden="true"479        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;"480      >481        {props.entry?.title || "empty"}482      </div>483484      <div class="peach-stage" style="position:relative;">485        <Show when={props.entry} fallback={<EmptyVaultState />}>486          {(entry) => (487            <>488              {/* ── Breach strip ─────────────────────────────────── */}489              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>490                <div class="peach-breach-badge">491                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">492                    <path d="M6 1L11 10H1L6 1z" />493                    <line x1="6" y1="5" x2="6" y2="7.5" />494                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />495                  </svg>496                  <span style="flex:1">found in a known data breach</span>497                  <button498                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"499                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}500                  >501                    Change password502                  </button>503                </div>504              </Show>505506              {/* ── Title + trash ─────────────────────────────────── */}507              <div class="peach-entry-header">508                <div class="peach-entry-header-name">509                  <Show510                    when={editingField() === "title"}511                    fallback={512                      <button513                        class="peach-entry-name-button"514                        title="Click to edit title"515                        onClick={() => startEdit("title", entry().title)}516                      >517                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>518                          <span class="peach-entry-name-text">519                            <Show520                              when={useAnimatedTitle()}521                              fallback={entry().title}522                            >523                              <For each={entry().title.split("")}>524                                {(ch, i) => (525                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>526                                    {ch === " " ? "\u00A0" : ch}527                                  </span>528                                )}529                              </For>530                            </Show>531                          </span>532                        </h1>533                      </button>534                    }535                  >536                    <div537                      class="peach-inline-title-wrap"538                      onFocusOut={(event) => handleEditorFocusOut("title", event)}539                    >540                      <input541                        class="peach-inline-title-input"542                        value={draft()}543                        onInput={(e) => setDraft(e.currentTarget.value)}544                        onKeyDown={onEditKeyDown}545                        ref={autoFocus}546                        disabled={saving()}547                      />548                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />549                    </div>550                  </Show>551                </div>552553                {/* Subtle trash icon — hidden while editing title */}554                <Show when={editingField() !== "title"}>555                  <Show556                    when={confirmDelete()}557                    fallback={558                      <button559                        class="peach-entry-trash-btn"560                        onClick={() => setConfirmDelete(true)}561                        aria-label="Move to trash"562                        title="Delete entry"563                      >564                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">565                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />566                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />567                        </svg>568                      </button>569                    }570                  >571                    <div class="peach-entry-trash-confirm">572                      <span class="peach-entry-trash-confirm-label">trash?</span>573                      <button574                        class="peach-btn peach-btn--sm peach-btn--danger"575                        style="font-size:10px;padding:2px 8px;min-height:22px;"576                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}577                      >578                        yes579                      </button>580                      <button581                        class="peach-btn peach-btn--ghost peach-btn--sm"582                        style="font-size:10px;padding:2px 8px;min-height:22px;"583                        onClick={() => setConfirmDelete(false)}584                      >585                        no586                      </button>587                    </div>588                  </Show>589                </Show>590              </div>591592              {/* ── Domain / URL ─────────────────────────────────── */}593              <Show when={entry().type !== "note" && entry().type !== "card"}>594                <div class="peach-domain" style="position:relative;z-index:1;">595                  <Show596                    when={editingField() === "url"}597                    fallback={598                      <div599                        class="peach-domain-row peach-inline-clickable"600                        title="Click to edit URL"601                        onClick={() => startEdit("url", entry().url ?? "")}602                      >603                        <Show when={faviconSrc()}>604                          <img605                            src={faviconSrc()!}606                            width="16"607                            height="16"608                            alt=""609                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"610                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}611                          />612                        </Show>613                        <span class="peach-domain-value">614                          {displayDomain() || (entry().url ? entry().url : "add a website url")}615                        </span>616                        <span class="peach-inline-edit-hint">edit</span>617                      </div>618                    }619                  >620                    <div621                      class="peach-inline-field-wrap peach-inline-field-wrap--url"622                      onFocusOut={(event) => handleEditorFocusOut("url", event)}623                    >624                      <div class="peach-inline-field-row">625                        <input626                          class="peach-inline-input"627                          type="url"628                          placeholder="https://"629                          value={draft()}630                          onInput={(e) => setDraft(e.currentTarget.value)}631                          onKeyDown={onEditKeyDown}632                          ref={autoFocus}633                          disabled={saving()}634                        />635                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />636                      </div>637                      <div class="peach-additional-urls">638                        <Show when={additionalUrlsDraft().length > 0}>639                          <span class="peach-label peach-additional-urls-label">also matches</span>640                        </Show>641                        <For each={additionalUrlsDraft()}>642                          {(value, i) => (643                            <div class="peach-additional-url-row">644                              <input645                                class="peach-inline-input"646                                type="url"647                                placeholder="https://"648                                value={value}649                                onInput={(e) => setAdditionalUrlsDraft((arr) => {650                                  const next = [...arr];651                                  next[i()] = e.currentTarget.value;652                                  return next;653                                })}654                                disabled={saving()}655                              />656                              <button657                                class="peach-additional-url-remove"658                                type="button"659                                aria-label="Remove URL"660                                onMouseDown={(e) => e.preventDefault()}661                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}662                                disabled={saving()}663                              >×</button>664                            </div>665                          )}666                        </For>667                        <button668                          class="peach-add-field-link peach-additional-urls-add"669                          type="button"670                          onMouseDown={(e) => e.preventDefault()}671                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}672                          disabled={saving()}673                        >674                          + add another url675                        </button>676                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>677                          <button678                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"679                            type="button"680                            title={props.currentTabUrl}681                            onMouseDown={(e) => e.preventDefault()}682                            onClick={() => addCurrentSite(props.currentTabUrl!)}683                            disabled={saving()}684                          >685                            + add current site686                          </button>687                        </Show>688                      </div>689                    </div>690                  </Show>691                </div>692              </Show>693694              {/* ── Tags — read-only chips (editing state lives at bottom) */}695              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>696                <div697                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"698                  onClick={startEditTags}699                  title="Click to edit tags"700                >701                  <For each={entry().tags!}>702                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}703                  </For>704                </div>705              </Show>706707              {/* ── Note toolbar + inline editor ─────────────────── */}708              <Show when={entry().type === "note"}>709                <div class="peach-entry-note-toolbar-row">710                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />711                </div>712                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">713                  <NoteEditorInline714                    entryKey={entry().id}715                    initialContent={entry().notes ?? ""}716                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}717                    onBlur={saveNoteInline}718                    autoFocus={false}719                  />720                </section>721              </Show>722723              {/* ── Card detail view ─────────────────────────────── */}724              <Show when={entry().type === "card"}>725                <section class="peach-fields" style="position:relative;z-index:1;">726                  {/* Card subtitle: •••• last4 */}727                  <Show when={entry().last4}>728                    <div class="peach-field-card" style="padding-bottom:4px;">729                      <span class="peach-label">730                        <Show when={entry().network && entry().network !== "unknown"}>731                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>732                        </Show>733                        {entry().cardNickname || `•••• ${entry().last4}`}734                      </span>735                    </div>736                  </Show>737738                  {/* Cardholder name */}739                  <div class="peach-field-card">740                    <span class="peach-label">cardholder name</span>741                    <div class="peach-field-value-row">742                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>743                      <Show when={entry().cardholderName}>744                        <button745                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}746                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}747                          aria-label="Copy cardholder name"748                        >749                          <CopyGlyph /><span class="peach-copy-sheen" />750                        </button>751                      </Show>752                    </div>753                  </div>754755                  {/* Card number */}756                  <div class="peach-field-card">757                    <span class="peach-label">card number</span>758                    <div class="peach-field-value-row">759                      <span class={`peach-field-value peach-card-number`}>760                        <Show when={showCardNumber()} fallback={761                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>762                        }>763                          <span>{entry().cardNumber ?? ""}</span>764                        </Show>765                      </span>766                      <div class="peach-field-actions">767                        <button768                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"769                          onClick={() => setShowCardNumber(!showCardNumber())}770                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}771                        >772                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>773                            <EyeClosedGlyph />774                          </Show>775                        </button>776                        <Show when={entry().cardNumber}>777                          <button778                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}779                            onClick={(e) => {780                              const cardNum = entry().cardNumber!;781                              if (entry().canary?.webhookUrl) {782                                void sendMessage({783                                  type: "FIRE_CANARY_PING",784                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },785                                });786                              }787                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);788                            }}789                            aria-label="Copy card number"790                          >791                            <CopyGlyph /><span class="peach-copy-sheen" />792                          </button>793                        </Show>794                      </div>795                    </div>796                  </div>797798                  {/* Expiry */}799                  <Show when={entry().expMonth || entry().expYear}>800                    <div class="peach-field-card">801                      <span class="peach-label">expiry</span>802                      <div class="peach-field-value-row">803                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>804                        <button805                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}806                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}807                          aria-label="Copy expiry"808                        >809                          <CopyGlyph /><span class="peach-copy-sheen" />810                        </button>811                      </div>812                    </div>813                  </Show>814815                  {/* CVC */}816                  <Show when={entry().cvc}>817                    <div class="peach-field-card">818                      <span class="peach-label">CVC</span>819                      <div class="peach-field-value-row">820                        <span class="peach-field-value">821                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>822                            <span>{entry().cvc}</span>823                          </Show>824                        </span>825                        <div class="peach-field-actions">826                          <button827                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"828                            onClick={() => setShowCardCvc(!showCardCvc())}829                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}830                          >831                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>832                              <EyeClosedGlyph />833                            </Show>834                          </button>835                          <button836                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}837                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}838                            aria-label="Copy CVC"839                          >840                            <CopyGlyph /><span class="peach-copy-sheen" />841                          </button>842                        </div>843                      </div>844                    </div>845                  </Show>846                </section>847              </Show>848849              {/* ── Login fields ─────────────────────────────────── */}850              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>851                <section class="peach-fields" style="position:relative;z-index:1;">852853                  {/* Username — primary identity slot. Auto-labels as "email"854                      when the stored value is email-shaped and no separate855                      email field exists (legacy data path). */}856                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>857                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>858                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>859                      <Show860                        when={editingField() === "username"}861                        fallback={862                          <div class="peach-field-value-row">863                            <span864                              class="peach-field-value peach-inline-clickable"865                              title="Click to edit"866                              onClick={() => startEdit("username", entry().username ?? "")}867                            >868                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}869                            </span>870                            <Show when={entry().username}>871                              <button872                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}873                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}874                                aria-label="Copy username"875                              >876                                <CopyGlyph /><span class="peach-copy-sheen" />877                              </button>878                            </Show>879                          </div>880                        }881                      >882                        <div883                          class="peach-inline-field-wrap"884                          onFocusOut={(event) => handleEditorFocusOut("username", event)}885                        >886                          <input887                            class="peach-inline-input"888                            value={draft()}889                            onInput={(e) => setDraft(e.currentTarget.value)}890                            onKeyDown={onEditKeyDown}891                            ref={autoFocus}892                            disabled={saving()}893                            autocomplete="off"894                          />895                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />896                          {/* +add the OTHER identity. If the slot is acting as897                              email (value has @, no separate email), offer898                              "+ add username" — clicking migrates the email899                              into entry.email. Otherwise offer "+ add email". */}900                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>901                            <button902                              class="peach-add-field-btn"903                              type="button"904                              onMouseDown={(e) => e.preventDefault()}905                              onClick={() => {906                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);907                                const hasSeparateEmail = !!entry().email;908                                if (isEmailShape && !hasSeparateEmail) {909                                  void addOtherIdentity("username");910                                } else {911                                  void addOtherIdentity("email");912                                }913                              }}914                            >915                              {(() => {916                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);917                                const hasSeparateEmail = !!entry().email;918                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";919                              })()}920                            </button>921                          </Show>922                        </div>923                      </Show>924                    </div>925                  </Show>926927                  {/* Email — secondary identity slot. Visible whenever a928                      separate email field is set, or while it is being edited. */}929                  <Show when={entry().email || editingField() === "email"}>930                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>931                      <span class="peach-label">email</span>932                      <Show933                        when={editingField() === "email"}934                        fallback={935                          <div class="peach-field-value-row">936                            <span937                              class="peach-field-value peach-inline-clickable"938                              title="Click to edit"939                              onClick={() => startEdit("email", entry().email ?? "")}940                            >941                              {entry().email}942                            </span>943                            <Show when={entry().email}>944                              <button945                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}946                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}947                                aria-label="Copy email"948                              >949                                <CopyGlyph /><span class="peach-copy-sheen" />950                              </button>951                            </Show>952                          </div>953                        }954                      >955                        <div956                          class="peach-inline-field-wrap"957                          onFocusOut={(event) => handleEditorFocusOut("email", event)}958                        >959                          <input960                            class="peach-inline-input"961                            value={draft()}962                            onInput={(e) => setDraft(e.currentTarget.value)}963                            onKeyDown={onEditKeyDown}964                            ref={autoFocus}965                            disabled={saving()}966                            autocomplete="email"967                            type="email"968                          />969                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />970                          <Show when={!entry().username || entry().username?.includes("@")}>971                            <button972                              class="peach-add-field-btn"973                              type="button"974                              onMouseDown={(e) => e.preventDefault()}975                              onClick={() => void addOtherIdentity("username")}976                            >977                              + add username978                            </button>979                          </Show>980                        </div>981                      </Show>982                    </div>983                  </Show>984985                  {/* Password */}986                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>987                    <span class="peach-label">password</span>988                    <Show989                      when={editingField() === "password"}990                      fallback={991                        <div class="peach-field-value-row">992                          <span993                            class="peach-password-value"994                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}995                            style="cursor:pointer;"996                            title="Click to edit password"997                            onClick={() => startEdit("password", entry().password ?? "")}998                          >999                            <Show1000                              when={showPassword()}1001                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1002                            >1003                              <span class="peach-password-text">{entry().password ?? ""}</span>1004                            </Show>1005                          </span>1006                          <div class="peach-field-actions">1007                            <button1008                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1009                              onClick={() => setShowPassword(!showPassword())}1010                              aria-label="Reveal password"1011                            >1012                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1013                                <EyeClosedGlyph />1014                              </Show>1015                            </button>1016                            <button1017                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1018                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1019                              aria-label="Copy password"1020                            >1021                              <CopyGlyph /><span class="peach-copy-sheen" />1022                            </button>1023                          </div>1024                        </div>1025                      }1026                    >1027                      {/* ── Edit mode: type / paste / generate ── */}1028                      <div1029                        class="peach-inline-field-wrap"1030                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1031                      >1032                        <div class="peach-password-edit-row">1033                          <input1034                            class="peach-inline-input"1035                            type={showPassword() ? "text" : "password"}1036                            placeholder="paste or type a password"1037                            value={draft()}1038                            onInput={(e) => setDraft(e.currentTarget.value)}1039                            onKeyDown={(e) => {1040                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1041                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1042                            }}1043                            ref={autoFocus}1044                            disabled={saving()}1045                            autocomplete="new-password"1046                          />1047                          <button1048                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1049                            type="button"1050                            onClick={() => setShowPassword(!showPassword())}1051                            aria-label={showPassword() ? "Hide" : "Show"}1052                          >1053                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1054                              <EyeClosedGlyph />1055                            </Show>1056                          </button>1057                        </div>1058                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1059                        <button1060                          class="peach-generate-toggle-link"1061                          type="button"1062                          onClick={() => setShowGenerator(!showGenerator())}1063                        >1064                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1065                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1066                            <path d="M9.5 2.5H11V1" />1067                          </svg>1068                          {showGenerator() ? "hide generator" : "generate"}1069                        </button>1070                        <Show when={showGenerator()}>1071                          <GeneratorDrawer1072                            open={true}1073                            compact={true}1074                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1075                          />1076                        </Show>1077                      </div>1078                    </Show>1079                  </div>10801081                </section>1082              </Show>10831084              {/* ── Notes (on login entries) ──────────────────────── */}1085              <Show when={entry().type !== "note" && entry().type !== "card"}>1086                <Show1087                  when={entry().notes || editingField() === "notes"}1088                  fallback={1089                    <button1090                      class="peach-add-field-link peach-entry-add-link"1091                      onClick={() => startEdit("notes", "")}1092                    >1093                      + notes1094                    </button>1095                  }1096                >1097                  <div style="position:relative;z-index:1;margin-top:6px;">1098                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1099                    <Show1100                      when={editingField() === "notes"}1101                      fallback={1102                        <div1103                          class="peach-note-content peach-inline-clickable"1104                          style="cursor:text;white-space:pre-wrap;"1105                          onClick={() => startEdit("notes", entry().notes ?? "")}1106                        >1107                          {entry().notes}1108                        </div>1109                      }1110                    >1111                      <div1112                        class="peach-inline-field-wrap"1113                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1114                      >1115                        <textarea1116                          class="peach-inline-textarea"1117                          value={draft()}1118                          onInput={(e) => setDraft(e.currentTarget.value)}1119                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1120                          ref={(el) => requestAnimationFrame(() => el?.focus())}1121                          rows={4}1122                          disabled={saving()}1123                        />1124                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1125                      </div>1126                    </Show>1127                  </div>1128                </Show>1129              </Show>11301131              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1132              <Show1133                when={editingTags()}1134                fallback={1135                  <Show when={!entry().tags || entry().tags!.length === 0}>1136                    <button1137                      class="peach-add-field-link peach-entry-add-link"1138                      onClick={startEditTags}1139                    >1140                      + tags1141                    </button>1142                  </Show>1143                }1144              >1145                <div1146                  class="peach-inline-tag-editor"1147                  onFocusOut={(e) => {1148                    const next = e.relatedTarget;1149                    if (next instanceof Node && e.currentTarget.contains(next)) return;1150                    void saveTags();1151                  }}1152                >1153                  <span class="peach-label">tags</span>1154                  <div class="peach-tag-input-wrap">1155                    <For each={tagsDraft()}>1156                      {(tag) => (1157                        <span class="peach-tag-chip">1158                          {tag}1159                          <button1160                            class="peach-tag-chip-remove"1161                            onMouseDown={(e) => e.preventDefault()}1162                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1163                            aria-label={`Remove ${tag}`}1164                          >×</button>1165                        </span>1166                      )}1167                    </For>1168                    <input1169                      class="peach-tag-input"1170                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1171                      value={tagInput()}1172                      onInput={(e) => setTagInput(e.currentTarget.value)}1173                      onKeyDown={(e) => {1174                        if (e.key === "Enter" || e.key === ",") {1175                          e.preventDefault();1176                          addTag(tagInput());1177                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1178                          setTagsDraft(tagsDraft().slice(0, -1));1179                        } else if (e.key === "Escape") {1180                          e.preventDefault();1181                          setEditingTags(false);1182                          setTagInput("");1183                        }1184                      }}1185                      ref={(el) => requestAnimationFrame(() => el?.focus())}1186                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1187                    />1188                  </div>1189                </div>1190              </Show>11911192              {/* ── TOTP ─────────────────────────────────────────── */}1193              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1194                <Show1195                  when={totpState() || editingField() === "totp"}1196                  fallback={1197                    <button1198                      class="peach-add-field-link peach-entry-add-link"1199                      onClick={() => startEdit("totp", "")}1200                    >1201                      + 2FA1202                    </button>1203                  }1204                >1205                  <section class="peach-totp-band">1206                    <Show1207                      when={editingField() === "totp"}1208                      fallback={1209                        <Show when={totpState()}>1210                          {(state) => (1211                            <>1212                              <div1213                                class="peach-totp-main"1214                                onClick={() => {1215                                  // Delay copy so a double-click can cancel it before1216                                  // it overwrites the clipboard the user wants to paste from.1217                                  if (totpClickTimer) return;1218                                  totpClickTimer = setTimeout(() => {1219                                    totpClickTimer = null;1220                                    void copyField(state().code.replace(" ", ""), "totp");1221                                  }, 220);1222                                }}1223                                onDblClick={() => {1224                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1225                                  startEdit("totp", entry().totp ?? "");1226                                }}1227                                title="Click to copy · double-click to edit"1228                              >1229                                <span class="peach-label">2-factor</span>1230                                <div class="peach-totp-code-row">1231                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1232                                    <span class="peach-totp-code-text">1233                                      {state().code.slice(0, 3)} {state().code.slice(3)}1234                                    </span>1235                                  </p>1236                                </div>1237                              </div>1238                              <div class="peach-totp-meta">1239                                <span class="peach-label">expires</span>1240                                <div class="peach-totp-countdown">{state().remaining}s</div>1241                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1242                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1243                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1244                                    stroke-dasharray="106.8"1245                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1246                                  />1247                                </svg>1248                              </div>1249                            </>1250                          )}1251                        </Show>1252                      }1253                    >1254                      <div1255                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1256                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1257                      >1258                        <span class="peach-label">2-factor</span>1259                        <input1260                          class="peach-inline-input"1261                          value={draft()}1262                          onInput={(e) => setDraft(e.currentTarget.value)}1263                          onKeyDown={onEditKeyDown}1264                          ref={autoFocus}1265                          disabled={saving()}1266                          placeholder="otpauth://... or base32 secret"1267                          autocomplete="off"1268                        />1269                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1270                      </div>1271                    </Show>1272                  </section>1273                </Show>1274              </Show>12751276              {/* ── Advanced (canary + passkey) ───────────────────── */}1277              <Show when={entry().type === "login"}>1278                <button1279                  class="peach-add-field-link peach-entry-add-link"1280                  onClick={() => setShowAdvanced((v) => !v)}1281                  aria-expanded={showAdvanced()}1282                >1283                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1284                </button>1285                <Show when={showAdvanced()}>1286                  <Show when={!isPasskeyOnly(entry())}>1287                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1288                  </Show>12891290                  <Show when={entry().passkey}>1291                    {(passkey) => {1292                      const currentBrowser = detectBrowser();1293                      const crossBrowser =1294                        passkey().browser &&1295                        currentBrowser !== "unknown" &&1296                        passkey().browser !== currentBrowser;1297                      const algorithmLabel =1298                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1299                      const formatDate = (ts?: number) =>1300                        ts ? new Date(ts).toLocaleDateString() : "Never";1301                      return (1302                        <section1303                          class="peach-fields peach-passkey-section"1304                          style="position:relative;z-index:1;margin-top:6px;"1305                        >1306                          <div class="peach-field-card">1307                            <span class="peach-label">passkey · {passkey().rpId}</span>1308                            <div class="peach-field-value-row">1309                              <span class="peach-field-value">{passkey().userName}</span>1310                              <span class="peach-passkey-badge">passkey</span>1311                            </div>1312                          </div>13131314                          <div class="peach-field-card">1315                            <span class="peach-label">algorithm</span>1316                            <div class="peach-field-value-row">1317                              <span class="peach-field-value">{algorithmLabel}</span>1318                            </div>1319                          </div>13201321                          <div class="peach-field-card">1322                            <span class="peach-label">activity</span>1323                            <div class="peach-field-value-row peach-passkey-activity">1324                              <span class="peach-field-value">1325                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1326                              </span>1327                              <span class="peach-passkey-meta">1328                                created {formatDate(passkey().createdAt)}1329                              </span>1330                              <span class="peach-passkey-meta">1331                                last used {formatDate(passkey().lastUsedAt)}1332                              </span>1333                            </div>1334                          </div>13351336                          <Show when={crossBrowser}>1337                            <div class="peach-info-notice peach-info-notice--warn">1338                              {`This passkey was created on ${1339                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1340                              }. To use it on ${1341                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1342                              }, re-register it on this device.`}1343                            </div>1344                          </Show>13451346                          <div class="peach-info-notice">1347                            {passkey().browser === "firefox"1348                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1349                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1350                          </div>13511352                          <button1353                            class="peach-add-field-link peach-entry-add-link"1354                            style="color:rgba(220,38,38,0.7);"1355                            onClick={async () => {1356                              const id = entry().id;1357                              await sendMessage({1358                                type: "UPDATE_ENTRY",1359                                payload: { id, patch: { passkey: undefined } },1360                              });1361                              await props.onFieldSaved();1362                            }}1363                          >1364                            − remove passkey1365                          </button>1366                        </section>1367                      );1368                    }}1369                  </Show>1370                </Show>1371              </Show>13721373              {/* ── Bottom spacer ─────────────────────────────────── */}1374              <div style="height:4px;" />1375            </>1376          )}1377        </Show>1378      </div>13791380    </>1381  );1382}13831384// ── Sub-components ────────────────────────────────────────────────────13851386function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1387  return (1388    <div class="peach-inline-actions">1389      <button1390        class="peach-inline-save-btn"1391        onClick={props.onSave}1392        disabled={props.saving}1393        aria-label="Save"1394        title="Save (Enter)"1395      >1396        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1397          <path d="M1 4.5L4 7.5L10 1.5" />1398        </svg>1399      </button>1400      <button1401        class="peach-inline-cancel-btn"1402        onClick={props.onCancel}1403        disabled={props.saving}1404        aria-label="Cancel"1405        title="Cancel (Esc)"1406      >1407        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1408          <path d="M1 1L8 8M8 1L1 8" />1409        </svg>1410      </button>1411    </div>1412  );1413}14141415function EmptyVaultState() {1416  return (1417    <>1418      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1419        <div class="peach-entry-name">1420          <span class="peach-entry-name-text">1421            <span class="peach-entry-name-char" style="--char-index:0">e</span>1422            <span class="peach-entry-name-char" style="--char-index:1">m</span>1423            <span class="peach-entry-name-char" style="--char-index:2">p</span>1424            <span class="peach-entry-name-char" style="--char-index:3">t</span>1425            <span class="peach-entry-name-char" style="--char-index:4">y</span>1426          </span>1427        </div>1428      </div>1429      <div class="peach-empty-copy">1430        <span class="peach-label">vault</span>1431        <p>Your vault is ready for its first entry.</p>1432      </div>1433    </>1434  );1435}14361437function CopyGlyph() {1438  return (1439    <span class="peach-copy-icon">1440      <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">1441        <rect x="4" y="4" width="8" height="9" rx="1.5" />1442        <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" />1443      </svg>1444    </span>1445  );1446}14471448function EyeClosedGlyph() {1449  return (1450    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1451      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1452      <path d="M3.5 5.5L2.5 7.5" />1453      <path d="M7 6.5V8.5" />1454      <path d="M10.5 5.5L11.5 7.5" />1455    </svg>1456  );1457}14581459function EyeOpenGlyph() {1460  return (1461    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1462      <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" />1463      <circle cx="7" cy="5" r="1.75" />1464    </svg>1465  );1466}14671468function ChevronGlyph(props: { open: boolean }) {1469  return (1470    <svg1471      width="8"1472      height="8"1473      viewBox="0 0 8 8"1474      fill="none"1475      stroke="currentColor"1476      stroke-width="1.3"1477      stroke-linecap="round"1478      stroke-linejoin="round"1479      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1480    >1481      <path d="M2 1L6 4L2 7" />1482    </svg>1483  );1484}14851486function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1487  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1488  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1489  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1490  const [testResult, setTestResult] = createSignal<string | null>(null);1491  const [testing, setTesting] = createSignal(false);14921493  async function save() {1494    const url = webhookUrl().trim();1495    const nextLabel = label().trim() || undefined;1496    const canary = enabled() && url1497      ? {1498          webhookUrl: url,1499          label: nextLabel,1500          lastPinged: props.entry.canary?.lastPinged,1501          pingCount: props.entry.canary?.pingCount,1502        }1503      : undefined;1504    await sendMessage({1505      type: "UPDATE_ENTRY",1506      payload: { id: props.entry.id, patch: { canary } },1507    });1508    await props.onSaved();1509  }15101511  async function testPing() {1512    const url = webhookUrl().trim();1513    if (!url) return;1514    setTesting(true);1515    setTestResult(null);1516    const res = await sendMessage({1517      type: "FIRE_CANARY_PING",1518      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1519    }) as { ok: boolean; error?: string };1520    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1521    setTesting(false);1522  }15231524  return (1525    <div class="peach-canary-section">1526      <div class="peach-field-value-row">1527        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1528        <button1529          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1530          onClick={() => { setEnabled(!enabled()); void save(); }}1531          aria-label="Toggle canary"1532        >1533          <span class="peach-toggle-knob" />1534        </button>1535      </div>1536      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1537        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1538      </p>1539      <Show when={enabled()}>1540        <div class="peach-inline-field-wrap" style="margin-top:8px;">1541          <input1542            class="peach-inline-input"1543            placeholder="https://hooks.zapier.com/..."1544            value={webhookUrl()}1545            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1546            onBlur={() => void save()}1547          />1548          <input1549            class="peach-inline-input"1550            placeholder="label (optional, e.g. AWS decoy)"1551            value={label()}1552            onInput={(e) => setLabel(e.currentTarget.value)}1553            onBlur={() => void save()}1554          />1555          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1556            <button1557              class="peach-btn peach-btn--ghost peach-btn--sm"1558              onClick={() => void testPing()}1559              disabled={testing() || !webhookUrl().trim()}1560            >1561              {testing() ? "sending..." : "test ping"}1562            </button>1563            <Show when={testResult()}>1564              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1565            </Show>1566          </div>1567          <Show when={props.entry.canary?.lastPinged}>1568            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1569              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1570              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1571            </p>1572          </Show>1573        </div>1574      </Show>1575    </div>1576  );1577}