Entry.tsx

69.7 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    // addOtherIdentity / startEdit may have already switched editingField before370    // focusout fires (SolidJS removes the old editor node synchronously, which371    // triggers blur with relatedTarget=null). Don't cancel the new edit.372    if (editingField() !== field) return;373374    const entry = props.entry;375    if (!entry) return;376377    const currentValue = fieldValue(entry, field);378    const nextValue = field === "password" ? draft() : draft().trim();379    if (nextValue === currentValue) {380      cancelEdit();381    }382  }383384  async function savePassword(password: string) {385    const entry = props.entry;386    if (!entry) return;387    setSaving(true);388    try {389      await sendMessage({390        type: "UPDATE_ENTRY",391        payload: { id: entry.id, patch: { password } },392      });393      triggerRing();394      setShowGenerator(false);395      await props.onFieldSaved();396    } catch {397      // silent398    } finally {399      setSaving(false);400    }401  }402403  // ── Copy ─────────────────────────────────────────────────────────404405  async function copyField(text: string, label: string, button?: HTMLButtonElement) {406    const ok = await popupCopy(text, label);407    if (ok) {408      const entry = props.entry;409      if (entry?.canary?.webhookUrl) {410        const trigger =411          label === "password"412            ? "password_copied"413            : label === "username"414              ? "username_copied"415              : undefined;416        if (trigger) {417          void sendMessage({418            type: "FIRE_CANARY_PING",419            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },420          });421        }422      }423      triggerRing();424      triggerPipPulse();425      setCopied(null);426      requestAnimationFrame(() => setCopied(label));427      if (button) {428        button.classList.remove("is-copied", "is-retreat");429        void button.offsetWidth;430        const drain = document.createElement("span");431        drain.className = "peach-copy-drain";432        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";433        drain.appendChild(bar);434        button.appendChild(drain);435        window.setTimeout(() => {436          button.classList.remove("is-copied");437          button.classList.add("is-retreat");438          drain.remove();439          window.setTimeout(() => button.classList.remove("is-retreat"), 160);440        }, 1800);441      }442      window.setTimeout(() => setCopied(null), 1800);443    }444  }445446  // ── Keyboard shortcuts ────────────────────────────────────────────447448  createEffect(() => {449    if (!props.entry) return;450    const handler = (e: KeyboardEvent) => {451      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;452      if (inInput) return;453      switch (e.key) {454        case "c": e.preventDefault();455          if (props.entry?.password) void copyField(props.entry.password, "password"); break;456        case "u": e.preventDefault();457          if (props.entry?.username) void copyField(props.entry.username, "username"); break;458        case "Escape": e.preventDefault(); props.onEscape?.(); break;459      }460    };461    window.addEventListener("keydown", handler);462    onCleanup(() => window.removeEventListener("keydown", handler));463  });464465  // ── Input ref focus helper ────────────────────────────────────────466467  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {468    requestAnimationFrame(() => { el.focus(); el.select?.(); });469  }470471  function onEditKeyDown(e: KeyboardEvent) {472    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {473      e.preventDefault();474      void saveField();475    }476    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }477  }478479  return (480    <>481      <div482        class="peach-ghost"483        aria-hidden="true"484        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;"485      >486        {props.entry?.title || "empty"}487      </div>488489      <div class="peach-stage" style="position:relative;">490        <Show when={props.entry} fallback={<EmptyVaultState />}>491          {(entry) => (492            <>493              {/* ── Breach strip ─────────────────────────────────── */}494              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>495                <div class="peach-breach-badge">496                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">497                    <path d="M6 1L11 10H1L6 1z" />498                    <line x1="6" y1="5" x2="6" y2="7.5" />499                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />500                  </svg>501                  <span style="flex:1">found in a known data breach</span>502                  <button503                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"504                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}505                  >506                    Change password507                  </button>508                </div>509              </Show>510511              {/* ── Title + trash ─────────────────────────────────── */}512              <div class="peach-entry-header">513                <div class="peach-entry-header-name">514                  <Show515                    when={editingField() === "title"}516                    fallback={517                      <button518                        class="peach-entry-name-button"519                        title="Click to edit title"520                        onClick={() => startEdit("title", entry().title)}521                      >522                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>523                          <span class="peach-entry-name-text">524                            <Show525                              when={useAnimatedTitle()}526                              fallback={entry().title}527                            >528                              <For each={entry().title.split("")}>529                                {(ch, i) => (530                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>531                                    {ch === " " ? "\u00A0" : ch}532                                  </span>533                                )}534                              </For>535                            </Show>536                          </span>537                        </h1>538                      </button>539                    }540                  >541                    <div542                      class="peach-inline-title-wrap"543                      onFocusOut={(event) => handleEditorFocusOut("title", event)}544                    >545                      <input546                        class="peach-inline-title-input"547                        value={draft()}548                        onInput={(e) => setDraft(e.currentTarget.value)}549                        onKeyDown={onEditKeyDown}550                        ref={autoFocus}551                        disabled={saving()}552                      />553                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />554                    </div>555                  </Show>556                </div>557558                {/* Subtle trash icon — hidden while editing title */}559                <Show when={editingField() !== "title"}>560                  <Show561                    when={confirmDelete()}562                    fallback={563                      <button564                        class="peach-entry-trash-btn"565                        onClick={() => setConfirmDelete(true)}566                        aria-label="Move to trash"567                        title="Delete entry"568                      >569                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">570                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />571                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />572                        </svg>573                      </button>574                    }575                  >576                    <div class="peach-entry-trash-confirm">577                      <span class="peach-entry-trash-confirm-label">trash?</span>578                      <button579                        class="peach-btn peach-btn--sm peach-btn--danger"580                        style="font-size:10px;padding:2px 8px;min-height:22px;"581                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}582                      >583                        yes584                      </button>585                      <button586                        class="peach-btn peach-btn--ghost peach-btn--sm"587                        style="font-size:10px;padding:2px 8px;min-height:22px;"588                        onClick={() => setConfirmDelete(false)}589                      >590                        no591                      </button>592                    </div>593                  </Show>594                </Show>595              </div>596597              {/* ── Domain / URL ─────────────────────────────────── */}598              <Show when={entry().type !== "note" && entry().type !== "card"}>599                <div class="peach-domain" style="position:relative;z-index:1;">600                  <Show601                    when={editingField() === "url"}602                    fallback={603                      <div604                        class="peach-domain-row peach-inline-clickable"605                        title="Click to edit URL"606                        onClick={() => startEdit("url", entry().url ?? "")}607                      >608                        <Show when={faviconSrc()}>609                          <img610                            src={faviconSrc()!}611                            width="16"612                            height="16"613                            alt=""614                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"615                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}616                          />617                        </Show>618                        <span class="peach-domain-value">619                          {displayDomain() || (entry().url ? entry().url : "add a website url")}620                        </span>621                        <span class="peach-inline-edit-hint">edit</span>622                      </div>623                    }624                  >625                    <div626                      class="peach-inline-field-wrap peach-inline-field-wrap--url"627                      onFocusOut={(event) => handleEditorFocusOut("url", event)}628                    >629                      <div class="peach-inline-field-row">630                        <input631                          class="peach-inline-input"632                          type="url"633                          placeholder="https://"634                          value={draft()}635                          onInput={(e) => setDraft(e.currentTarget.value)}636                          onKeyDown={onEditKeyDown}637                          ref={autoFocus}638                          disabled={saving()}639                        />640                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />641                      </div>642                      <div class="peach-additional-urls">643                        <Show when={additionalUrlsDraft().length > 0}>644                          <span class="peach-label peach-additional-urls-label">also matches</span>645                        </Show>646                        <For each={additionalUrlsDraft()}>647                          {(value, i) => (648                            <div class="peach-additional-url-row">649                              <input650                                class="peach-inline-input"651                                type="url"652                                placeholder="https://"653                                value={value}654                                onInput={(e) => setAdditionalUrlsDraft((arr) => {655                                  const next = [...arr];656                                  next[i()] = e.currentTarget.value;657                                  return next;658                                })}659                                disabled={saving()}660                              />661                              <button662                                class="peach-additional-url-remove"663                                type="button"664                                aria-label="Remove URL"665                                onMouseDown={(e) => e.preventDefault()}666                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}667                                disabled={saving()}668                              >×</button>669                            </div>670                          )}671                        </For>672                        <button673                          class="peach-add-field-link peach-additional-urls-add"674                          type="button"675                          onMouseDown={(e) => e.preventDefault()}676                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}677                          disabled={saving()}678                        >679                          + add another url680                        </button>681                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>682                          <button683                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"684                            type="button"685                            title={props.currentTabUrl}686                            onMouseDown={(e) => e.preventDefault()}687                            onClick={() => addCurrentSite(props.currentTabUrl!)}688                            disabled={saving()}689                          >690                            + add current site691                          </button>692                        </Show>693                      </div>694                    </div>695                  </Show>696                </div>697              </Show>698699              {/* ── Tags — read-only chips (editing state lives at bottom) */}700              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>701                <div702                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"703                  onClick={startEditTags}704                  title="Click to edit tags"705                >706                  <For each={entry().tags!}>707                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}708                  </For>709                </div>710              </Show>711712              {/* ── Note toolbar + inline editor ─────────────────── */}713              <Show when={entry().type === "note"}>714                <div class="peach-entry-note-toolbar-row">715                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />716                </div>717                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">718                  <NoteEditorInline719                    entryKey={entry().id}720                    initialContent={entry().notes ?? ""}721                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}722                    onBlur={saveNoteInline}723                    autoFocus={false}724                  />725                </section>726              </Show>727728              {/* ── Card detail view ─────────────────────────────── */}729              <Show when={entry().type === "card"}>730                <section class="peach-fields" style="position:relative;z-index:1;">731                  {/* Card subtitle: •••• last4 */}732                  <Show when={entry().last4}>733                    <div class="peach-field-card" style="padding-bottom:4px;">734                      <span class="peach-label">735                        <Show when={entry().network && entry().network !== "unknown"}>736                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>737                        </Show>738                        {entry().cardNickname || `•••• ${entry().last4}`}739                      </span>740                    </div>741                  </Show>742743                  {/* Cardholder name */}744                  <div class="peach-field-card">745                    <span class="peach-label">cardholder name</span>746                    <div class="peach-field-value-row">747                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>748                      <Show when={entry().cardholderName}>749                        <button750                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}751                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}752                          aria-label="Copy cardholder name"753                        >754                          <CopyGlyph /><span class="peach-copy-sheen" />755                        </button>756                      </Show>757                    </div>758                  </div>759760                  {/* Card number */}761                  <div class="peach-field-card">762                    <span class="peach-label">card number</span>763                    <div class="peach-field-value-row">764                      <span class={`peach-field-value peach-card-number`}>765                        <Show when={showCardNumber()} fallback={766                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>767                        }>768                          <span>{entry().cardNumber ?? ""}</span>769                        </Show>770                      </span>771                      <div class="peach-field-actions">772                        <button773                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"774                          onClick={() => setShowCardNumber(!showCardNumber())}775                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}776                        >777                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>778                            <EyeClosedGlyph />779                          </Show>780                        </button>781                        <Show when={entry().cardNumber}>782                          <button783                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}784                            onClick={(e) => {785                              const cardNum = entry().cardNumber!;786                              if (entry().canary?.webhookUrl) {787                                void sendMessage({788                                  type: "FIRE_CANARY_PING",789                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },790                                });791                              }792                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);793                            }}794                            aria-label="Copy card number"795                          >796                            <CopyGlyph /><span class="peach-copy-sheen" />797                          </button>798                        </Show>799                      </div>800                    </div>801                  </div>802803                  {/* Expiry */}804                  <Show when={entry().expMonth || entry().expYear}>805                    <div class="peach-field-card">806                      <span class="peach-label">expiry</span>807                      <div class="peach-field-value-row">808                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>809                        <button810                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}811                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}812                          aria-label="Copy expiry"813                        >814                          <CopyGlyph /><span class="peach-copy-sheen" />815                        </button>816                      </div>817                    </div>818                  </Show>819820                  {/* CVC */}821                  <Show when={entry().cvc}>822                    <div class="peach-field-card">823                      <span class="peach-label">CVC</span>824                      <div class="peach-field-value-row">825                        <span class="peach-field-value">826                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>827                            <span>{entry().cvc}</span>828                          </Show>829                        </span>830                        <div class="peach-field-actions">831                          <button832                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"833                            onClick={() => setShowCardCvc(!showCardCvc())}834                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}835                          >836                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>837                              <EyeClosedGlyph />838                            </Show>839                          </button>840                          <button841                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}842                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}843                            aria-label="Copy CVC"844                          >845                            <CopyGlyph /><span class="peach-copy-sheen" />846                          </button>847                        </div>848                      </div>849                    </div>850                  </Show>851                </section>852              </Show>853854              {/* ── Login fields ─────────────────────────────────── */}855              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>856                <section class="peach-fields" style="position:relative;z-index:1;">857858                  {/* Username — primary identity slot. Auto-labels as "email"859                      when the stored value is email-shaped and no separate860                      email field exists (legacy data path). */}861                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>862                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>863                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>864                      <Show865                        when={editingField() === "username"}866                        fallback={867                          <div class="peach-field-value-row">868                            <span869                              class="peach-field-value peach-inline-clickable"870                              title="Click to edit"871                              onClick={() => startEdit("username", entry().username ?? "")}872                            >873                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}874                            </span>875                            <Show when={entry().username}>876                              <button877                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}878                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}879                                aria-label="Copy username"880                              >881                                <CopyGlyph /><span class="peach-copy-sheen" />882                              </button>883                            </Show>884                          </div>885                        }886                      >887                        <div888                          class="peach-inline-field-wrap"889                          onFocusOut={(event) => handleEditorFocusOut("username", event)}890                        >891                          <input892                            class="peach-inline-input"893                            value={draft()}894                            onInput={(e) => setDraft(e.currentTarget.value)}895                            onKeyDown={onEditKeyDown}896                            ref={autoFocus}897                            disabled={saving()}898                            autocomplete="off"899                          />900                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />901                          {/* +add the OTHER identity. If the slot is acting as902                              email (value has @, no separate email), offer903                              "+ add username" — clicking migrates the email904                              into entry.email. Otherwise offer "+ add email". */}905                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>906                            <button907                              class="peach-add-field-btn"908                              type="button"909                              onMouseDown={(e) => e.preventDefault()}910                              onClick={() => {911                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);912                                const hasSeparateEmail = !!entry().email;913                                if (isEmailShape && !hasSeparateEmail) {914                                  void addOtherIdentity("username");915                                } else {916                                  void addOtherIdentity("email");917                                }918                              }}919                            >920                              {(() => {921                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);922                                const hasSeparateEmail = !!entry().email;923                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";924                              })()}925                            </button>926                          </Show>927                        </div>928                      </Show>929                    </div>930                  </Show>931932                  {/* Email — secondary identity slot. Visible whenever a933                      separate email field is set, or while it is being edited. */}934                  <Show when={entry().email || editingField() === "email"}>935                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>936                      <span class="peach-label">email</span>937                      <Show938                        when={editingField() === "email"}939                        fallback={940                          <div class="peach-field-value-row">941                            <span942                              class="peach-field-value peach-inline-clickable"943                              title="Click to edit"944                              onClick={() => startEdit("email", entry().email ?? "")}945                            >946                              {entry().email}947                            </span>948                            <Show when={entry().email}>949                              <button950                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}951                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}952                                aria-label="Copy email"953                              >954                                <CopyGlyph /><span class="peach-copy-sheen" />955                              </button>956                            </Show>957                          </div>958                        }959                      >960                        <div961                          class="peach-inline-field-wrap"962                          onFocusOut={(event) => handleEditorFocusOut("email", event)}963                        >964                          <input965                            class="peach-inline-input"966                            value={draft()}967                            onInput={(e) => setDraft(e.currentTarget.value)}968                            onKeyDown={onEditKeyDown}969                            ref={autoFocus}970                            disabled={saving()}971                            autocomplete="email"972                            type="email"973                          />974                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />975                          <Show when={!entry().username || entry().username?.includes("@")}>976                            <button977                              class="peach-add-field-btn"978                              type="button"979                              onMouseDown={(e) => e.preventDefault()}980                              onClick={() => void addOtherIdentity("username")}981                            >982                              + add username983                            </button>984                          </Show>985                        </div>986                      </Show>987                    </div>988                  </Show>989990                  {/* Password */}991                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>992                    <span class="peach-label">password</span>993                    <Show994                      when={editingField() === "password"}995                      fallback={996                        <div class="peach-field-value-row">997                          <span998                            class="peach-password-value"999                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1000                            style="cursor:pointer;"1001                            title="Click to edit password"1002                            onClick={() => startEdit("password", entry().password ?? "")}1003                          >1004                            <Show1005                              when={showPassword()}1006                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1007                            >1008                              <span class="peach-password-text">{entry().password ?? ""}</span>1009                            </Show>1010                          </span>1011                          <div class="peach-field-actions">1012                            <button1013                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1014                              onClick={() => setShowPassword(!showPassword())}1015                              aria-label="Reveal password"1016                            >1017                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1018                                <EyeClosedGlyph />1019                              </Show>1020                            </button>1021                            <button1022                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1023                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1024                              aria-label="Copy password"1025                            >1026                              <CopyGlyph /><span class="peach-copy-sheen" />1027                            </button>1028                          </div>1029                        </div>1030                      }1031                    >1032                      {/* ── Edit mode: type / paste / generate ── */}1033                      <div1034                        class="peach-inline-field-wrap"1035                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1036                      >1037                        <div class="peach-password-edit-row">1038                          <input1039                            class="peach-inline-input"1040                            type={showPassword() ? "text" : "password"}1041                            placeholder="paste or type a password"1042                            value={draft()}1043                            onInput={(e) => setDraft(e.currentTarget.value)}1044                            onKeyDown={(e) => {1045                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1046                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1047                            }}1048                            ref={autoFocus}1049                            disabled={saving()}1050                            autocomplete="new-password"1051                          />1052                          <button1053                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1054                            type="button"1055                            onClick={() => setShowPassword(!showPassword())}1056                            aria-label={showPassword() ? "Hide" : "Show"}1057                          >1058                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1059                              <EyeClosedGlyph />1060                            </Show>1061                          </button>1062                        </div>1063                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1064                        <button1065                          class="peach-generate-toggle-link"1066                          type="button"1067                          onClick={() => setShowGenerator(!showGenerator())}1068                        >1069                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1070                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1071                            <path d="M9.5 2.5H11V1" />1072                          </svg>1073                          {showGenerator() ? "hide generator" : "generate"}1074                        </button>1075                        <Show when={showGenerator()}>1076                          <GeneratorDrawer1077                            open={true}1078                            compact={true}1079                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1080                          />1081                        </Show>1082                      </div>1083                    </Show>1084                  </div>10851086                </section>1087              </Show>10881089              {/* ── Notes (on login entries) ──────────────────────── */}1090              <Show when={entry().type !== "note" && entry().type !== "card"}>1091                <Show1092                  when={entry().notes || editingField() === "notes"}1093                  fallback={1094                    <button1095                      class="peach-add-field-link peach-entry-add-link"1096                      onClick={() => startEdit("notes", "")}1097                    >1098                      + notes1099                    </button>1100                  }1101                >1102                  <div style="position:relative;z-index:1;margin-top:6px;">1103                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1104                    <Show1105                      when={editingField() === "notes"}1106                      fallback={1107                        <div1108                          class="peach-note-content peach-inline-clickable"1109                          style="cursor:text;white-space:pre-wrap;"1110                          onClick={() => startEdit("notes", entry().notes ?? "")}1111                        >1112                          {entry().notes}1113                        </div>1114                      }1115                    >1116                      <div1117                        class="peach-inline-field-wrap"1118                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1119                      >1120                        <textarea1121                          class="peach-inline-textarea"1122                          value={draft()}1123                          onInput={(e) => setDraft(e.currentTarget.value)}1124                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1125                          ref={(el) => requestAnimationFrame(() => el?.focus())}1126                          rows={4}1127                          disabled={saving()}1128                        />1129                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1130                      </div>1131                    </Show>1132                  </div>1133                </Show>1134              </Show>11351136              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1137              <Show1138                when={editingTags()}1139                fallback={1140                  <Show when={!entry().tags || entry().tags!.length === 0}>1141                    <button1142                      class="peach-add-field-link peach-entry-add-link"1143                      onClick={startEditTags}1144                    >1145                      + tags1146                    </button>1147                  </Show>1148                }1149              >1150                <div1151                  class="peach-inline-tag-editor"1152                  onFocusOut={(e) => {1153                    const next = e.relatedTarget;1154                    if (next instanceof Node && e.currentTarget.contains(next)) return;1155                    void saveTags();1156                  }}1157                >1158                  <span class="peach-label">tags</span>1159                  <div class="peach-tag-input-wrap">1160                    <For each={tagsDraft()}>1161                      {(tag) => (1162                        <span class="peach-tag-chip">1163                          {tag}1164                          <button1165                            class="peach-tag-chip-remove"1166                            onMouseDown={(e) => e.preventDefault()}1167                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1168                            aria-label={`Remove ${tag}`}1169                          >×</button>1170                        </span>1171                      )}1172                    </For>1173                    <input1174                      class="peach-tag-input"1175                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1176                      value={tagInput()}1177                      onInput={(e) => setTagInput(e.currentTarget.value)}1178                      onKeyDown={(e) => {1179                        if (e.key === "Enter" || e.key === ",") {1180                          e.preventDefault();1181                          addTag(tagInput());1182                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1183                          setTagsDraft(tagsDraft().slice(0, -1));1184                        } else if (e.key === "Escape") {1185                          e.preventDefault();1186                          setEditingTags(false);1187                          setTagInput("");1188                        }1189                      }}1190                      ref={(el) => requestAnimationFrame(() => el?.focus())}1191                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1192                    />1193                  </div>1194                </div>1195              </Show>11961197              {/* ── TOTP ─────────────────────────────────────────── */}1198              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1199                <Show1200                  when={totpState() || editingField() === "totp"}1201                  fallback={1202                    <button1203                      class="peach-add-field-link peach-entry-add-link"1204                      onClick={() => startEdit("totp", "")}1205                    >1206                      + 2FA1207                    </button>1208                  }1209                >1210                  <section class="peach-totp-band">1211                    <Show1212                      when={editingField() === "totp"}1213                      fallback={1214                        <Show when={totpState()}>1215                          {(state) => (1216                            <>1217                              <div1218                                class="peach-totp-main"1219                                onClick={() => {1220                                  // Delay copy so a double-click can cancel it before1221                                  // it overwrites the clipboard the user wants to paste from.1222                                  if (totpClickTimer) return;1223                                  totpClickTimer = setTimeout(() => {1224                                    totpClickTimer = null;1225                                    void copyField(state().code.replace(" ", ""), "totp");1226                                  }, 220);1227                                }}1228                                onDblClick={() => {1229                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1230                                  startEdit("totp", entry().totp ?? "");1231                                }}1232                                title="Click to copy · double-click to edit"1233                              >1234                                <span class="peach-label">2-factor</span>1235                                <div class="peach-totp-code-row">1236                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1237                                    <span class="peach-totp-code-text">1238                                      {state().code.slice(0, 3)} {state().code.slice(3)}1239                                    </span>1240                                  </p>1241                                </div>1242                              </div>1243                              <div class="peach-totp-meta">1244                                <span class="peach-label">expires</span>1245                                <div class="peach-totp-countdown">{state().remaining}s</div>1246                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1247                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1248                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1249                                    stroke-dasharray="106.8"1250                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1251                                  />1252                                </svg>1253                              </div>1254                            </>1255                          )}1256                        </Show>1257                      }1258                    >1259                      <div1260                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1261                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1262                      >1263                        <span class="peach-label">2-factor</span>1264                        <input1265                          class="peach-inline-input"1266                          value={draft()}1267                          onInput={(e) => setDraft(e.currentTarget.value)}1268                          onKeyDown={onEditKeyDown}1269                          ref={autoFocus}1270                          disabled={saving()}1271                          placeholder="otpauth://... or base32 secret"1272                          autocomplete="off"1273                        />1274                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1275                      </div>1276                    </Show>1277                  </section>1278                </Show>1279              </Show>12801281              {/* ── Advanced (canary + passkey) ───────────────────── */}1282              <Show when={entry().type === "login"}>1283                <button1284                  class="peach-add-field-link peach-entry-add-link"1285                  onClick={() => setShowAdvanced((v) => !v)}1286                  aria-expanded={showAdvanced()}1287                >1288                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1289                </button>1290                <Show when={showAdvanced()}>1291                  <Show when={!isPasskeyOnly(entry())}>1292                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1293                  </Show>12941295                  <Show when={entry().passkey}>1296                    {(passkey) => {1297                      const currentBrowser = detectBrowser();1298                      const crossBrowser =1299                        passkey().browser &&1300                        currentBrowser !== "unknown" &&1301                        passkey().browser !== currentBrowser;1302                      const algorithmLabel =1303                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1304                      const formatDate = (ts?: number) =>1305                        ts ? new Date(ts).toLocaleDateString() : "Never";1306                      return (1307                        <section1308                          class="peach-fields peach-passkey-section"1309                          style="position:relative;z-index:1;margin-top:6px;"1310                        >1311                          <div class="peach-field-card">1312                            <span class="peach-label">passkey · {passkey().rpId}</span>1313                            <div class="peach-field-value-row">1314                              <span class="peach-field-value">{passkey().userName}</span>1315                              <span class="peach-passkey-badge">passkey</span>1316                            </div>1317                          </div>13181319                          <div class="peach-field-card">1320                            <span class="peach-label">algorithm</span>1321                            <div class="peach-field-value-row">1322                              <span class="peach-field-value">{algorithmLabel}</span>1323                            </div>1324                          </div>13251326                          <div class="peach-field-card">1327                            <span class="peach-label">activity</span>1328                            <div class="peach-field-value-row peach-passkey-activity">1329                              <span class="peach-field-value">1330                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1331                              </span>1332                              <span class="peach-passkey-meta">1333                                created {formatDate(passkey().createdAt)}1334                              </span>1335                              <span class="peach-passkey-meta">1336                                last used {formatDate(passkey().lastUsedAt)}1337                              </span>1338                            </div>1339                          </div>13401341                          <Show when={crossBrowser}>1342                            <div class="peach-info-notice peach-info-notice--warn">1343                              {`This passkey was created on ${1344                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1345                              }. To use it on ${1346                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1347                              }, re-register it on this device.`}1348                            </div>1349                          </Show>13501351                          <div class="peach-info-notice">1352                            {passkey().browser === "firefox"1353                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1354                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1355                          </div>13561357                          <button1358                            class="peach-add-field-link peach-entry-add-link"1359                            style="color:rgba(220,38,38,0.7);"1360                            onClick={async () => {1361                              const id = entry().id;1362                              await sendMessage({1363                                type: "UPDATE_ENTRY",1364                                payload: { id, patch: { passkey: undefined } },1365                              });1366                              await props.onFieldSaved();1367                            }}1368                          >1369                            − remove passkey1370                          </button>1371                        </section>1372                      );1373                    }}1374                  </Show>1375                </Show>1376              </Show>13771378              {/* ── Bottom spacer ─────────────────────────────────── */}1379              <div style="height:4px;" />1380            </>1381          )}1382        </Show>1383      </div>13841385    </>1386  );1387}13881389// ── Sub-components ────────────────────────────────────────────────────13901391function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1392  return (1393    <div class="peach-inline-actions">1394      <button1395        class="peach-inline-save-btn"1396        onClick={props.onSave}1397        disabled={props.saving}1398        aria-label="Save"1399        title="Save (Enter)"1400      >1401        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1402          <path d="M1 4.5L4 7.5L10 1.5" />1403        </svg>1404      </button>1405      <button1406        class="peach-inline-cancel-btn"1407        onClick={props.onCancel}1408        disabled={props.saving}1409        aria-label="Cancel"1410        title="Cancel (Esc)"1411      >1412        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1413          <path d="M1 1L8 8M8 1L1 8" />1414        </svg>1415      </button>1416    </div>1417  );1418}14191420function EmptyVaultState() {1421  return (1422    <>1423      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1424        <div class="peach-entry-name">1425          <span class="peach-entry-name-text">1426            <span class="peach-entry-name-char" style="--char-index:0">e</span>1427            <span class="peach-entry-name-char" style="--char-index:1">m</span>1428            <span class="peach-entry-name-char" style="--char-index:2">p</span>1429            <span class="peach-entry-name-char" style="--char-index:3">t</span>1430            <span class="peach-entry-name-char" style="--char-index:4">y</span>1431          </span>1432        </div>1433      </div>1434      <div class="peach-empty-copy">1435        <span class="peach-label">vault</span>1436        <p>Your vault is ready for its first entry.</p>1437      </div>1438    </>1439  );1440}14411442function CopyGlyph() {1443  return (1444    <span class="peach-copy-icon">1445      <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">1446        <rect x="4" y="4" width="8" height="9" rx="1.5" />1447        <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" />1448      </svg>1449    </span>1450  );1451}14521453function EyeClosedGlyph() {1454  return (1455    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1456      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1457      <path d="M3.5 5.5L2.5 7.5" />1458      <path d="M7 6.5V8.5" />1459      <path d="M10.5 5.5L11.5 7.5" />1460    </svg>1461  );1462}14631464function EyeOpenGlyph() {1465  return (1466    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1467      <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" />1468      <circle cx="7" cy="5" r="1.75" />1469    </svg>1470  );1471}14721473function ChevronGlyph(props: { open: boolean }) {1474  return (1475    <svg1476      width="8"1477      height="8"1478      viewBox="0 0 8 8"1479      fill="none"1480      stroke="currentColor"1481      stroke-width="1.3"1482      stroke-linecap="round"1483      stroke-linejoin="round"1484      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1485    >1486      <path d="M2 1L6 4L2 7" />1487    </svg>1488  );1489}14901491function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1492  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1493  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1494  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1495  const [testResult, setTestResult] = createSignal<string | null>(null);1496  const [testing, setTesting] = createSignal(false);14971498  async function save() {1499    const url = webhookUrl().trim();1500    const nextLabel = label().trim() || undefined;1501    const canary = enabled() && url1502      ? {1503          webhookUrl: url,1504          label: nextLabel,1505          lastPinged: props.entry.canary?.lastPinged,1506          pingCount: props.entry.canary?.pingCount,1507        }1508      : undefined;1509    await sendMessage({1510      type: "UPDATE_ENTRY",1511      payload: { id: props.entry.id, patch: { canary } },1512    });1513    await props.onSaved();1514  }15151516  async function testPing() {1517    const url = webhookUrl().trim();1518    if (!url) return;1519    setTesting(true);1520    setTestResult(null);1521    const res = await sendMessage({1522      type: "FIRE_CANARY_PING",1523      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1524    }) as { ok: boolean; error?: string };1525    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1526    setTesting(false);1527  }15281529  return (1530    <div class="peach-canary-section">1531      <div class="peach-field-value-row">1532        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1533        <button1534          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1535          onClick={() => { setEnabled(!enabled()); void save(); }}1536          aria-label="Toggle canary"1537        >1538          <span class="peach-toggle-knob" />1539        </button>1540      </div>1541      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1542        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1543      </p>1544      <Show when={enabled()}>1545        <div class="peach-inline-field-wrap" style="margin-top:8px;">1546          <input1547            class="peach-inline-input"1548            placeholder="https://hooks.zapier.com/..."1549            value={webhookUrl()}1550            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1551            onBlur={() => void save()}1552          />1553          <input1554            class="peach-inline-input"1555            placeholder="label (optional, e.g. AWS decoy)"1556            value={label()}1557            onInput={(e) => setLabel(e.currentTarget.value)}1558            onBlur={() => void save()}1559          />1560          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1561            <button1562              class="peach-btn peach-btn--ghost peach-btn--sm"1563              onClick={() => void testPing()}1564              disabled={testing() || !webhookUrl().trim()}1565            >1566              {testing() ? "sending..." : "test ping"}1567            </button>1568            <Show when={testResult()}>1569              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1570            </Show>1571          </div>1572          <Show when={props.entry.canary?.lastPinged}>1573            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1574              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1575              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1576            </p>1577          </Show>1578        </div>1579      </Show>1580    </div>1581  );1582}