Entry.tsx

81.5 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { ProFeature, VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { detectHomograph, getDisplayDomain } from "../../shared/domain";6import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516// Compact "time since" label for the password-history list. Coarse on purpose —17// the user wants "roughly when", not a precise duration.18function timeSince(when: number): string {19  const secs = Math.max(0, Math.floor((Date.now() - when) / 1000));20  if (secs < 60) return "just now";21  const mins = Math.floor(secs / 60);22  if (mins < 60) return `${mins}m ago`;23  const hours = Math.floor(mins / 60);24  if (hours < 24) return `${hours}h ago`;25  const days = Math.floor(hours / 24);26  if (days < 30) return `${days}d ago`;27  const months = Math.floor(days / 30);28  if (months < 12) return `${months}mo ago`;29  return `${Math.floor(months / 12)}y ago`;30}3132interface EntryProps {33  entry: VaultEntry | null;34  /**35   * Current active tab's full URL (https only). Empty string when unavailable36   * — an extension page or a chrome:///about: URL. The URL editor uses this37   * to render an "add current site" shortcut.38   */39  currentTabUrl?: string;40  onDelete: () => void;41  /** Called after any inline save so App can reload entries */42  onFieldSaved: () => void | Promise<void>;43  /** Whether this mounted view should own entry keyboard shortcuts. */44  keyboardEnabled?: boolean;45  onEscape?: () => void;46  entries?: VaultEntry[];47  /** Whether the user holds an active Peach Pro license. Drives Pro gates on48   *  canary entries (see CanarySection). */49  isPro?: boolean;50  /** Invoked when a Pro-gated control is tapped on free tier. App owns the51   *  upgrade navigation + highlight state. */52  onProGate?: (feature: ProFeature) => void;53}5455// Inline 16×16 favicon for the domain row — shows coloured initials until a56// real favicon is confirmed (same globe-detection logic as the vault list).57function DomainFavicon(props: { domain: string; title: string }) {58  const src = props.domain ? faviconUrl(props.domain) : null;59  const [isReal, setIsReal] = createSignal(false);60  const label = () => titleInitials(props.title || props.domain);61  const bg = () => titleBackground(props.title || props.domain);62  const chipStyle = () =>63    `display:inline-flex;align-items:center;justify-content:center;` +64    `width:16px;height:16px;border-radius:3px;background:${bg()};` +65    `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +66    `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;67  return (68    <Show69      when={isReal()}70      fallback={71        <>72          <span style={chipStyle()} aria-hidden="true">{label()}</span>73          {src && (74            <img75              src={src}76              style="display:none"77              onError={() => {}}78              onLoad={async (e) => {79                const img = e.currentTarget as HTMLImageElement;80                if (img.naturalWidth <= 16) return;81                const sig = faviconImgSig(img);82                if (sig !== null) {83                  const gSig = await globeFaviconSig();84                  if (gSig !== null && sig === gSig) return;85                }86                setIsReal(true);87              }}88            />89          )}90        </>91      }92    >93      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />94    </Show>95  );96}9798export function EntryView(props: EntryProps) {99  const [editingField, setEditingField] = createSignal<EditableField>(null);100  const [draft, setDraft] = createSignal("");101  const [saving, setSaving] = createSignal(false);102  const [showPassword, setShowPassword] = createSignal(false);103  const [showGenerator, setShowGenerator] = createSignal(false);104  const [showHistory, setShowHistory] = createSignal(false);105  const [revealedHistory, setRevealedHistory] = createSignal<Set<number>>(new Set());106  const [copied, setCopied] = createSignal<string | null>(null);107  // Local draft for the additional URLs list, edited only while the URL field108  // is in inline-edit mode. Saved alongside `url` when the user confirms.109  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);110  const [editingTags, setEditingTags] = createSignal(false);111  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);112  const [tagInput, setTagInput] = createSignal("");113  const [showCardNumber, setShowCardNumber] = createSignal(false);114  const [showCardCvc, setShowCardCvc] = createSignal(false);115  const [totpTick, setTotpTick] = createSignal(0);116  const [confirmDelete, setConfirmDelete] = createSignal(false);117  const [totpHot, setTotpHot] = createSignal(false);118  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);119  // Auto-open advanced when entry already has passkey or canary configured120  const [showAdvanced, setShowAdvanced] = createSignal(false);121  const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);122  let usernameInlineInput: HTMLInputElement | undefined;123  let emailInlineInput: HTMLInputElement | undefined;124  let usernameIdentityEl: HTMLDivElement | undefined;125  let emailIdentityEl: HTMLDivElement | undefined;126  let lastSavedNote: string | null = null;127  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;128  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });129130  function noteExec(cmd: string, val?: string) {131    document.execCommand(cmd, false, val);132    noteEditorEl()?.focus();133  }134135  async function saveNoteInline(html: string) {136    const entry = props.entry;137    if (!entry) return;138    const currentStored = entry.notes ?? "";139    if (html === currentStored) return;140    if (lastSavedNote !== null && html === lastSavedNote) return;141    lastSavedNote = html;142    setSaving(true);143    try {144      await sendMessage({145        type: "UPDATE_ENTRY",146        payload: { id: entry.id, patch: { notes: html } },147      });148      triggerRing();149      await props.onFieldSaved();150    } catch {151      lastSavedNote = null;152    } finally {153      setSaving(false);154    }155  }156157  const allIdentities = createMemo(() => {158    const emailMap = new Map<string, number>();159    const usernameMap = new Map<string, number>();160    for (const e of props.entries ?? []) {161      const uname = e.username?.trim() ?? "";162      const em = e.email?.trim() ?? "";163      if (uname) {164        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);165        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);166      }167      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);168    }169    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];170    const seen = new Set<string>();171    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {172      if (seen.has(v)) continue;173      seen.add(v);174      const ec = emailMap.get(v) ?? 0;175      const uc = usernameMap.get(v) ?? 0;176      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });177    }178    return result.sort((a, b) => b.count - a.count);179  });180181  const identitySuggestions = createMemo(() => {182    const field = editingField();183    if (field !== "username" && field !== "email") return [];184    return allIdentities()185      // Inline editing starts with the saved value selected. Keep the full186      // identity list visible so editing an existing value is a picker, not a187      // one-row exact-match result. AddView still filters while typing.188      .filter(s => s.type === field)189      .slice(0, 8);190  });191192  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);193  onCleanup(() => window.clearInterval(interval));194195  const displayDomain = createMemo(() =>196    props.entry?.url ? getDisplayDomain(props.entry.url) : ""197  );198199  // Reset transient prompts (trash confirmation, inline edits, advanced panel)200  // when the selected entry changes so they don't carry over between entries.201  // Track the last id so a same-entry refresh (e.g. after saving a field)202  // doesn't blow away in-flight edit state — that race used to cancel203  // startEdit calls that ran right after onFieldSaved.204  let lastEntryId: string | null = null;205  createEffect(() => {206    const id = props.entry?.id ?? null;207    if (id === lastEntryId) return;208    lastEntryId = id;209    setConfirmDelete(false);210    setEditingField(null);211    setEditingTags(false);212    setShowAdvanced(false);213  });214215  const totpState = createMemo(() => {216    void totpTick();217    if (!props.entry?.totp) return null;218    return generateTOTP(219      props.entry.totp,220      props.entry.totpAlgorithm,221      props.entry.totpDigits,222      props.entry.totpPeriod,223    );224  });225226  let prevTotpCode: string | undefined;227  createEffect(() => {228    const state = totpState();229    if (!state) { prevTotpCode = undefined; return; }230    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {231      setTotpHot(true);232      window.setTimeout(() => setTotpHot(false), 3000);233    }234    prevTotpCode = state.code;235  });236237  const totpProgress = createMemo(() => {238    const state = totpState();239    if (!state) return 0;240    return ((state.remaining - 1) / 29) * 100;241  });242243  createEffect(() => {244    const shell = document.getElementById("hd-shell");245    if (!shell) return;246247    const state = totpState();248    if (!state) {249      delete shell.dataset.totpActive;250      shell.style.removeProperty("--peach-totp-progress");251      return;252    }253254    shell.dataset.totpActive = "true";255    shell.style.setProperty(256      "--peach-totp-progress",257      `${Math.max(0, totpProgress())}%`258    );259260    onCleanup(() => {261      delete shell.dataset.totpActive;262      shell.style.removeProperty("--peach-totp-progress");263    });264  });265266  const titleSizeClass = createMemo(() => {267    const length = props.entry?.title.length ?? 0;268    if (length >= 28) return "peach-entry-name--xxl";269    if (length >= 20) return "peach-entry-name--xl";270    if (length >= 14) return "peach-entry-name--lg";271    return "";272  });273  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);274275  // ── Inline editing ────────────────────────────────────────────────276277  function startEdit(field: EditableField, value: string) {278    setEditingField(field);279    setDraft(value);280    setIdentityHelperSuppressed(false);281    setShowGenerator(false);282    setShowHistory(false);283    setRevealedHistory(new Set<number>());284    setConfirmDelete(false);285    if (field === "url") {286      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);287    }288  }289290  // Compare URLs by string after trim — vault.ts has its own canonicalisation291  // when saving, so the dedupe here is a UX-level "don't show the button if292  // we'd just add a duplicate" rather than a security check.293  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {294    const c = candidate.trim();295    if (!c) return true;296    if (primary.trim() === c) return true;297    return others.some((u) => u.trim() === c);298  }299300  function addCurrentSite(url: string) {301    if (!url) return;302    if (!draft().trim()) {303      setDraft(url);304      return;305    }306    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;307    setAdditionalUrlsDraft((arr) => [...arr, url]);308  }309310  async function saveField() {311    const field = editingField();312    const entry = props.entry;313    if (!field || !entry) return;314    // Don't trim passwords — spaces may be intentional315    let value = field === "password" ? draft() : draft().trim();316    if (field === "totp" && value.startsWith("otpauth://")) {317      const parsed = parseOtpauthURI(value);318      value = parsed?.secret ?? value;319    }320    if (field === "password" && entry.type !== "note" && !value) {321      return;322    }323    setSaving(true);324    try {325      const patch: Partial<VaultEntry> = { [field]: value };326      if (field === "url") {327        // Sanitization layer in vault.ts trims, dedupes, and drops empties.328        patch.additionalUrls = additionalUrlsDraft();329      }330      await sendMessage({331        type: "UPDATE_ENTRY",332        payload: { id: entry.id, patch },333      });334      triggerRing();335      setEditingField(null);336      setShowGenerator(false);337      setShowHistory(false);338      setRevealedHistory(new Set<number>());339      await props.onFieldSaved();340    } catch {341      // silent — leave editing open342    } finally {343      setSaving(false);344    }345  }346347  function startEditTags() {348    setTagsDraft([...(props.entry?.tags ?? [])]);349    setTagInput("");350    setEditingTags(true);351  }352353  function addTag(value: string) {354    const n = value.toLowerCase().trim();355    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;356    setTagsDraft([...tagsDraft(), n]);357    setTagInput("");358  }359360  async function saveTags() {361    if (tagInput()) addTag(tagInput());362    const entry = props.entry;363    if (!entry) return;364    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;365    setEditingTags(false);366    setSaving(true);367    try {368      await sendMessage({369        type: "UPDATE_ENTRY",370        payload: { id: entry.id, patch: { tags: next } },371      });372      triggerRing();373      await props.onFieldSaved();374    } catch {375      // silent376    } finally {377      setSaving(false);378    }379  }380381  function cancelEdit() {382    setEditingField(null);383    setDraft("");384    setShowGenerator(false);385    setShowHistory(false);386    setRevealedHistory(new Set<number>());387  }388389  // Switch from one identity slot to the other.390  //391  // Migration source is the SAVED entry value, never the live draft — using392  // the draft caused data loss when the user typed or cleared the input393  // before clicking +add (the migration would skip and a later Save would394  // overwrite the original email-shape value with whatever was in the input).395  //396  // Drafts only get committed when they are non-empty AND actually changed,397  // so an empty input never accidentally clears a saved field.398  async function addOtherIdentity(target: "username" | "email") {399    const entry = props.entry;400    if (!entry) return;401    const current = editingField();402    const draftValue = draft().trim();403    const savedUsername = entry.username ?? "";404    const savedEmail = entry.email ?? "";405406    if (current === "username" && target === "username") {407      // Legacy fix-up: saved username is email-shaped and no separate email408      // exists yet. Move it to entry.email and free the username slot.409      if (savedUsername.includes("@") && !entry.email) {410        setSaving(true);411        try {412          await sendMessage({413            type: "UPDATE_ENTRY",414            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },415          });416          await props.onFieldSaved();417        } finally {418          setSaving(false);419        }420      }421      startEdit("username", "");422      return;423    }424425    if (current === "username" && target === "email") {426      if (draftValue && draftValue !== savedUsername) {427        setSaving(true);428        try {429          await sendMessage({430            type: "UPDATE_ENTRY",431            payload: { id: entry.id, patch: { username: draftValue } },432          });433          await props.onFieldSaved();434        } finally {435          setSaving(false);436        }437      }438      startEdit("email", "");439      return;440    }441442    if (current === "email" && target === "username") {443      if (draftValue && draftValue !== savedEmail) {444        setSaving(true);445        try {446          await sendMessage({447            type: "UPDATE_ENTRY",448            payload: { id: entry.id, patch: { email: draftValue } },449          });450          await props.onFieldSaved();451        } finally {452          setSaving(false);453        }454      }455      startEdit("username", "");456      return;457    }458  }459460  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {461    switch (field) {462      case "title":463        return entry.title;464      case "url":465        return entry.url ?? "";466      case "username":467        return entry.username ?? "";468      case "email":469        return entry.email ?? "";470      case "password":471        return entry.password ?? "";472      case "notes":473        return entry.notes ?? "";474      case "totp":475        return entry.totp ?? "";476    }477  }478479  function handleEditorFocusOut(480    field: Exclude<EditableField, null>,481    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }482  ) {483    const nextTarget = event.relatedTarget;484    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;485486    // addOtherIdentity / startEdit may have already switched editingField before487    // focusout fires (SolidJS removes the old editor node synchronously, which488    // triggers blur with relatedTarget=null). Don't cancel the new edit.489    if (editingField() !== field) return;490491    const entry = props.entry;492    if (!entry) return;493494    const currentValue = fieldValue(entry, field);495    const nextValue = field === "password" ? draft() : draft().trim();496    if (nextValue === currentValue) {497      cancelEdit();498    }499  }500501  // ── Copy ─────────────────────────────────────────────────────────502503  async function copyField(text: string, label: string, button?: HTMLButtonElement) {504    const entry = props.entry;505    const ok = await popupCopy(506      text,507      label,508      entry?.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,509    );510    if (ok) {511      triggerRing();512      triggerPipPulse();513      setCopied(null);514      requestAnimationFrame(() => setCopied(label));515      if (button) {516        button.classList.remove("is-copied", "is-retreat");517        void button.offsetWidth;518        const drain = document.createElement("span");519        drain.className = "peach-copy-drain";520        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";521        drain.appendChild(bar);522        button.appendChild(drain);523        window.setTimeout(() => {524          button.classList.remove("is-copied");525          button.classList.add("is-retreat");526          drain.remove();527          window.setTimeout(() => button.classList.remove("is-retreat"), 160);528        }, 1800);529      }530      window.setTimeout(() => setCopied(null), 1800);531    }532  }533534535  // The entry view owns its direct keyboard fallback; modified copy keys must536  // remain native clipboard shortcuts and never copy a password.537  createEffect(() => {538    if (!props.entry || props.keyboardEnabled === false) return;539    const handler = (e: KeyboardEvent) => {540      if (props.keyboardEnabled === false) return;541      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;542      if (inInput || e.ctrlKey || e.metaKey || e.altKey) return;543      switch (e.key) {544        case "c": e.preventDefault();545          if (props.entry?.password) void copyField(props.entry.password, "password"); break;546        case "u": e.preventDefault();547          if (props.entry?.username) void copyField(props.entry.username, "username"); break;548        case "Escape": e.preventDefault(); props.onEscape?.(); break;549      }550    };551    window.addEventListener("keydown", handler);552    onCleanup(() => window.removeEventListener("keydown", handler));553  });554555  // ── Input ref focus helper ────────────────────────────────────────556557  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {558    requestAnimationFrame(() => { el.focus(); el.select?.(); });559  }560561  function onEditKeyDown(e: KeyboardEvent) {562    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {563      e.preventDefault();564      void saveField();565    }566    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }567  }568569  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {570    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;571    if (e.key === "ArrowDown" && listEl) {572      e.preventDefault();573      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();574      return;575    }576    onEditKeyDown(e);577  }578579  return (580    <>581      <div582        class="peach-ghost"583        aria-hidden="true"584        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:var(--peach-ghost-opacity,0.03);pointer-events:none;line-height:1.25;overflow:hidden;white-space:nowrap;z-index:0;"585      >586        {props.entry?.title || "empty"}587      </div>588589      <div class="peach-stage" style="position:relative;">590        <Show when={props.entry} fallback={<EmptyVaultState />}>591          {(entry) => (592            <>593              {/* ── Breach strip ─────────────────────────────────── */}594              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>595                <div class="peach-breach-badge">596                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">597                    <path d="M6 1L11 10H1L6 1z" />598                    <line x1="6" y1="5" x2="6" y2="7.5" />599                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />600                  </svg>601                  <span style="flex:1">found in a known data breach</span>602                </div>603              </Show>604605              {/* ── Title + trash ─────────────────────────────────── */}606              <div class="peach-entry-header">607                <div class="peach-entry-header-name">608                  <Show609                    when={editingField() === "title"}610                    fallback={611                      <button612                        class="peach-entry-name-button"613                        title="Click to edit title"614                        onClick={() => startEdit("title", entry().title)}615                      >616                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>617                          <span class="peach-entry-name-text">618                            <Show619                              when={useAnimatedTitle()}620                              fallback={entry().title}621                            >622                              <For each={entry().title.split("")}>623                                {(ch, i) => (624                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>625                                    {ch === " " ? "\u00A0" : ch}626                                  </span>627                                )}628                              </For>629                            </Show>630                          </span>631                        </h1>632                      </button>633                    }634                  >635                    <div636                      class="peach-inline-title-wrap"637                      onFocusOut={(event) => handleEditorFocusOut("title", event)}638                    >639                      <input640                        class="peach-inline-title-input"641                        value={draft()}642                        onInput={(e) => setDraft(e.currentTarget.value)}643                        onKeyDown={onEditKeyDown}644                        ref={autoFocus}645                        disabled={saving()}646                      />647                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />648                    </div>649                  </Show>650                </div>651652                {/* Subtle trash icon — hidden while editing title */}653                <Show when={editingField() !== "title"}>654                  <Show655                    when={confirmDelete()}656                    fallback={657                      <button658                        class="peach-entry-trash-btn"659                        onClick={() => setConfirmDelete(true)}660                        aria-label="Move to trash"661                        title="Delete entry"662                      >663                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">664                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />665                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />666                        </svg>667                      </button>668                    }669                  >670                    <div class="peach-entry-trash-confirm">671                      <span class="peach-entry-trash-confirm-label">trash?</span>672                      <button673                        class="peach-btn peach-btn--sm peach-btn--danger"674                        style="font-size:10px;padding:2px 8px;min-height:22px;"675                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}676                      >677                        yes678                      </button>679                      <button680                        class="peach-btn peach-btn--ghost peach-btn--sm"681                        style="font-size:10px;padding:2px 8px;min-height:22px;"682                        onClick={() => setConfirmDelete(false)}683                      >684                        no685                      </button>686                    </div>687                  </Show>688                </Show>689              </div>690691              {/* ── Domain / URL ─────────────────────────────────── */}692              <Show when={entry().type !== "note" && entry().type !== "card"}>693                <div class="peach-domain" style="position:relative;z-index:1;">694                  <Show695                    when={editingField() === "url"}696                    fallback={697                      <div698                        class="peach-domain-row peach-inline-clickable"699                        title="Click to edit URL"700                        onClick={() => startEdit("url", entry().url ?? "")}701                      >702                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />703                        <span class="peach-domain-value">704                          {displayDomain() || (entry().url ? entry().url : "add a website url")}705                        </span>706                        <span class="peach-inline-edit-hint">edit</span>707                      </div>708                    }709                  >710                    <div711                      class="peach-inline-field-wrap peach-inline-field-wrap--url"712                      onFocusOut={(event) => handleEditorFocusOut("url", event)}713                    >714                      <div class="peach-inline-field-row">715                        <input716                          class="peach-inline-input"717                          type="url"718                          placeholder="https://"719                          value={draft()}720                          onInput={(e) => setDraft(e.currentTarget.value)}721                          onKeyDown={onEditKeyDown}722                          ref={autoFocus}723                          disabled={saving()}724                        />725                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />726                      </div>727                      <Show728                        when={729                          detectHomograph(draft()) ||730                          additionalUrlsDraft().some((u) => detectHomograph(u))731                        }732                      >733                        {/* UI-8 / LV-2: non-blocking homograph warning. */}734                        <p class="peach-homograph-warning">735                          ⚠ This URL contains non-ASCII or punycode characters.736                          Verify it&apos;s the site you intend.737                        </p>738                      </Show>739                      <div class="peach-additional-urls">740                        <Show when={additionalUrlsDraft().length > 0}>741                          <span class="peach-label peach-additional-urls-label">also matches</span>742                        </Show>743                        <For each={additionalUrlsDraft()}>744                          {(value, i) => (745                            <div class="peach-additional-url-row">746                              <input747                                class="peach-inline-input"748                                type="url"749                                placeholder="https://"750                                value={value}751                                onInput={(e) => setAdditionalUrlsDraft((arr) => {752                                  const next = [...arr];753                                  next[i()] = e.currentTarget.value;754                                  return next;755                                })}756                                disabled={saving()}757                              />758                              <button759                                class="peach-additional-url-remove"760                                type="button"761                                aria-label="Remove URL"762                                onMouseDown={(e) => e.preventDefault()}763                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}764                                disabled={saving()}765                              >×</button>766                            </div>767                          )}768                        </For>769                        <button770                          class="peach-add-field-link peach-additional-urls-add"771                          type="button"772                          onMouseDown={(e) => e.preventDefault()}773                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}774                          disabled={saving()}775                        >776                          + add another url777                        </button>778                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>779                          <button780                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"781                            type="button"782                            title={props.currentTabUrl}783                            onMouseDown={(e) => e.preventDefault()}784                            onClick={() => addCurrentSite(props.currentTabUrl!)}785                            disabled={saving()}786                          >787                            + add current site788                          </button>789                        </Show>790                      </div>791                    </div>792                  </Show>793                </div>794              </Show>795796              {/* ── Tags — read-only chips (editing state lives at bottom) */}797              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>798                <div799                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"800                  onClick={startEditTags}801                  title="Click to edit tags"802                >803                  <For each={entry().tags!}>804                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}805                  </For>806                </div>807              </Show>808809              {/* ── Note toolbar + inline editor ─────────────────── */}810              <Show when={entry().type === "note"}>811                <div class="peach-entry-note-toolbar-row">812                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />813                </div>814                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">815                  <NoteEditorInline816                    entryKey={entry().id}817                    initialContent={entry().notes ?? ""}818                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}819                    onBlur={saveNoteInline}820                    autoFocus={false}821                  />822                </section>823              </Show>824825              {/* ── Card detail view ─────────────────────────────── */}826              <Show when={entry().type === "card"}>827                <section class="peach-fields" style="position:relative;z-index:1;">828                  {/* Card subtitle: •••• last4 */}829                  <Show when={entry().last4}>830                    <div class="peach-field-card" style="padding-bottom:4px;">831                      <span class="peach-label">832                        <Show when={entry().network && entry().network !== "unknown"}>833                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>834                        </Show>835                        {entry().cardNickname || `•••• ${entry().last4}`}836                      </span>837                    </div>838                  </Show>839840                  {/* Cardholder name */}841                  <div class="peach-field-card">842                    <span class="peach-label">cardholder name</span>843                    <div class="peach-field-value-row">844                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>845                      <Show when={entry().cardholderName}>846                        <button847                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}848                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}849                          aria-label="Copy cardholder name"850                        >851                          <CopyGlyph /><span class="peach-copy-sheen" />852                        </button>853                      </Show>854                    </div>855                  </div>856857                  {/* Card number */}858                  <div class="peach-field-card">859                    <span class="peach-label">card number</span>860                    <div class="peach-field-value-row">861                      <span class={`peach-field-value peach-card-number`}>862                        <Show when={showCardNumber()} fallback={863                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>864                        }>865                          <span>{entry().cardNumber ?? ""}</span>866                        </Show>867                      </span>868                      <div class="peach-field-actions">869                        <button870                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"871                          onClick={() => setShowCardNumber(!showCardNumber())}872                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}873                        >874                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>875                            <EyeClosedGlyph />876                          </Show>877                        </button>878                        <Show when={entry().cardNumber}>879                          <button880                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}881                            onClick={(e) => {882                              const cardNum = entry().cardNumber!;883                              if (entry().canary?.webhookUrl) {884                                void sendMessage({885                                  type: "FIRE_CANARY_PING",886                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },887                                });888                              }889                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);890                            }}891                            aria-label="Copy card number"892                          >893                            <CopyGlyph /><span class="peach-copy-sheen" />894                          </button>895                        </Show>896                      </div>897                    </div>898                  </div>899900                  {/* Expiry */}901                  <Show when={entry().expMonth || entry().expYear}>902                    <div class="peach-field-card">903                      <span class="peach-label">expiry</span>904                      <div class="peach-field-value-row">905                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>906                        <button907                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}908                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}909                          aria-label="Copy expiry"910                        >911                          <CopyGlyph /><span class="peach-copy-sheen" />912                        </button>913                      </div>914                    </div>915                  </Show>916917                  {/* CVC */}918                  <Show when={entry().cvc}>919                    <div class="peach-field-card">920                      <span class="peach-label">CVC</span>921                      <div class="peach-field-value-row">922                        <span class="peach-field-value">923                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>924                            <span>{entry().cvc}</span>925                          </Show>926                        </span>927                        <div class="peach-field-actions">928                          <button929                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"930                            onClick={() => setShowCardCvc(!showCardCvc())}931                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}932                          >933                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>934                              <EyeClosedGlyph />935                            </Show>936                          </button>937                          <button938                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}939                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}940                            aria-label="Copy CVC"941                          >942                            <CopyGlyph /><span class="peach-copy-sheen" />943                          </button>944                        </div>945                      </div>946                    </div>947                  </Show>948                </section>949              </Show>950951              {/* ── Login fields ─────────────────────────────────── */}952              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>953                <section class="peach-fields" style="position:relative;z-index:1;">954955                  {/* Username — primary identity slot. Auto-labels as "email"956                      when the stored value is email-shaped and no separate957                      email field exists (legacy data path). */}958                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>959                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>960                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>961                      <Show962                        when={editingField() === "username"}963                        fallback={964                          <div class="peach-field-value-row">965                            <span966                              class="peach-field-value peach-inline-clickable"967                              title="Click to edit"968                              onClick={() => startEdit("username", entry().username ?? "")}969                            >970                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}971                            </span>972                            <Show when={entry().username}>973                              <button974                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}975                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}976                                aria-label="Copy username"977                              >978                                <CopyGlyph /><span class="peach-copy-sheen" />979                              </button>980                            </Show>981                          </div>982                        }983                      >984                        <div985                          class="peach-inline-field-wrap"986                          onFocusOut={(event) => handleEditorFocusOut("username", event)}987                        >988                          <input989                            class="peach-inline-input"990                            value={draft()}991                            onInput={(e) => setDraft(e.currentTarget.value)}992                            onKeyDown={(e) => identityKeyDown(e, "username")}993                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}994                            disabled={saving()}995                            autocomplete="off"996                          />997                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />998                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>999                            <IdentitySuggestList1000                              listRef={(el) => (usernameIdentityEl = el)}1001                              inputRef={() => usernameInlineInput}1002                              suggestions={identitySuggestions()}1003                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1004                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1005                            />1006                          </Show>1007                          {/* +add the OTHER identity. If the slot is acting as1008                              email (value has @, no separate email), offer1009                              "+ add username" — clicking migrates the email1010                              into entry.email. Otherwise offer "+ add email". */}1011                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1012                            <button1013                              class="peach-add-field-btn"1014                              type="button"1015                              onMouseDown={(e) => e.preventDefault()}1016                              onClick={() => {1017                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1018                                const hasSeparateEmail = !!entry().email;1019                                if (isEmailShape && !hasSeparateEmail) {1020                                  void addOtherIdentity("username");1021                                } else {1022                                  void addOtherIdentity("email");1023                                }1024                              }}1025                            >1026                              {(() => {1027                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1028                                const hasSeparateEmail = !!entry().email;1029                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1030                              })()}1031                            </button>1032                          </Show>1033                        </div>1034                      </Show>1035                    </div>1036                  </Show>10371038                  {/* Email — secondary identity slot. Visible whenever a1039                      separate email field is set, or while it is being edited. */}1040                  <Show when={entry().email || editingField() === "email"}>1041                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1042                      <span class="peach-label">email</span>1043                      <Show1044                        when={editingField() === "email"}1045                        fallback={1046                          <div class="peach-field-value-row">1047                            <span1048                              class="peach-field-value peach-inline-clickable"1049                              title="Click to edit"1050                              onClick={() => startEdit("email", entry().email ?? "")}1051                            >1052                              {entry().email}1053                            </span>1054                            <Show when={entry().email}>1055                              <button1056                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1057                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1058                                aria-label="Copy email"1059                              >1060                                <CopyGlyph /><span class="peach-copy-sheen" />1061                              </button>1062                            </Show>1063                          </div>1064                        }1065                      >1066                        <div1067                          class="peach-inline-field-wrap"1068                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1069                        >1070                          <input1071                            class="peach-inline-input"1072                            value={draft()}1073                            onInput={(e) => setDraft(e.currentTarget.value)}1074                            onKeyDown={(e) => identityKeyDown(e, "email")}1075                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1076                            disabled={saving()}1077                            autocomplete="email"1078                            type="email"1079                          />1080                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1081                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1082                            <IdentitySuggestList1083                              listRef={(el) => (emailIdentityEl = el)}1084                              inputRef={() => emailInlineInput}1085                              suggestions={identitySuggestions()}1086                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1087                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1088                            />1089                          </Show>1090                          <Show when={!entry().username || entry().username?.includes("@")}>1091                            <button1092                              class="peach-add-field-btn"1093                              type="button"1094                              onMouseDown={(e) => e.preventDefault()}1095                              onClick={() => void addOtherIdentity("username")}1096                            >1097                              + add username1098                            </button>1099                          </Show>1100                        </div>1101                      </Show>1102                    </div>1103                  </Show>11041105                  {/* Password */}1106                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1107                    <span class="peach-label">password</span>1108                    <Show1109                      when={editingField() === "password"}1110                      fallback={1111                        <div class="peach-field-value-row">1112                          <span1113                            class="peach-password-value"1114                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1115                            style="cursor:pointer;"1116                            title="Click to edit password"1117                            onClick={() => startEdit("password", entry().password ?? "")}1118                          >1119                            <Show1120                              when={showPassword()}1121                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1122                            >1123                              <span class="peach-password-text">{entry().password ?? ""}</span>1124                            </Show>1125                          </span>1126                          <div class="peach-field-actions">1127                            <button1128                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1129                              onClick={() => setShowPassword(!showPassword())}1130                              aria-label="Reveal password"1131                            >1132                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1133                                <EyeClosedGlyph />1134                              </Show>1135                            </button>1136                            <button1137                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1138                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1139                              aria-label="Copy password"1140                            >1141                              <CopyGlyph /><span class="peach-copy-sheen" />1142                            </button>1143                          </div>1144                        </div>1145                      }1146                    >1147                      {/* ── Edit mode: type / paste / generate ── */}1148                      <div1149                        class="peach-inline-field-wrap"1150                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1151                      >1152                        <div class="peach-password-edit-row">1153                          <input1154                            class="peach-inline-input"1155                            type={showPassword() ? "text" : "password"}1156                            placeholder="paste or type a password"1157                            value={draft()}1158                            onInput={(e) => setDraft(e.currentTarget.value)}1159                            onKeyDown={(e) => {1160                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1161                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1162                            }}1163                            ref={autoFocus}1164                            disabled={saving()}1165                            autocomplete="new-password"1166                          />1167                          <button1168                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1169                            type="button"1170                            onClick={() => setShowPassword(!showPassword())}1171                            aria-label={showPassword() ? "Hide" : "Show"}1172                          >1173                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1174                              <EyeClosedGlyph />1175                            </Show>1176                          </button>1177                        </div>1178                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1179                        <div class="peach-password-edit-links">1180                          <button1181                            class="peach-generate-toggle-link"1182                            type="button"1183                            onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1184                          >1185                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1186                              <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1187                              <path d="M9.5 2.5H11V1" />1188                            </svg>1189                            {showGenerator() ? "hide generator" : "generate"}1190                          </button>1191                          <button1192                            class="peach-generate-toggle-link"1193                            type="button"1194                            onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1195                          >1196                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1197                              <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1198                              <path d="M6.5 3.5V6.5L8.5 8" />1199                              <path d="M1 1.5V4H3.5" />1200                            </svg>1201                            {showHistory()1202                              ? "hide history"1203                              : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1204                          </button>1205                        </div>1206                        <Show when={showGenerator()}>1207                          <GeneratorDrawer1208                            open={true}1209                            compact={true}1210                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1211                          />1212                        </Show>1213                        <Show when={showHistory()}>1214                          <div class="peach-password-history">1215                            <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1216                              <div class="peach-history-empty">1217                                No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1218                              </div>1219                            </Show>1220                            <For each={entry().passwordHistory ?? []}>1221                              {(item, i) => (1222                                <div class="peach-history-row">1223                                  <div class="peach-history-main">1224                                    <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1225                                      <Show1226                                        when={revealedHistory().has(i())}1227                                        fallback={<span class="peach-password-mask">••••••••••••</span>}1228                                      >1229                                        {item.password}1230                                      </Show>1231                                    </span>1232                                    <span class="peach-history-age">{timeSince(item.changedAt)}</span>1233                                  </div>1234                                  <div class="peach-history-actions">1235                                    <button1236                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1237                                      type="button"1238                                      title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1239                                      onClick={() => setRevealedHistory((prev) => {1240                                        const next = new Set(prev);1241                                        if (next.has(i())) next.delete(i()); else next.add(i());1242                                        return next;1243                                      })}1244                                    >1245                                      <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1246                                        <EyeClosedGlyph />1247                                      </Show>1248                                    </button>1249                                    <button1250                                      class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1251                                      type="button"1252                                      title="Copy"1253                                      onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1254                                    >1255                                      <CopyGlyph /><span class="peach-copy-sheen" />1256                                    </button>1257                                    <button1258                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1259                                      type="button"1260                                      title="Use this password again"1261                                      onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1262                                    >1263                                      use1264                                    </button>1265                                  </div>1266                                </div>1267                              )}1268                            </For>1269                          </div>1270                        </Show>1271                      </div>1272                    </Show>1273                  </div>12741275                </section>1276              </Show>12771278              {/* ── Notes (on login entries) ──────────────────────── */}1279              <Show when={entry().type !== "note" && entry().type !== "card"}>1280                <Show1281                  when={entry().notes || editingField() === "notes"}1282                  fallback={1283                    <button1284                      class="peach-add-field-link peach-entry-add-link"1285                      onClick={() => startEdit("notes", "")}1286                    >1287                      + notes1288                    </button>1289                  }1290                >1291                  <div style="position:relative;z-index:1;margin-top:6px;">1292                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1293                    <Show1294                      when={editingField() === "notes"}1295                      fallback={1296                        <div1297                          class="peach-note-content peach-inline-clickable"1298                          style="cursor:text;white-space:pre-wrap;"1299                          onClick={() => startEdit("notes", entry().notes ?? "")}1300                        >1301                          {entry().notes}1302                        </div>1303                      }1304                    >1305                      <div1306                        class="peach-inline-field-wrap"1307                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1308                      >1309                        <textarea1310                          class="peach-inline-textarea"1311                          value={draft()}1312                          onInput={(e) => setDraft(e.currentTarget.value)}1313                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1314                          ref={(el) => requestAnimationFrame(() => el?.focus())}1315                          rows={4}1316                          disabled={saving()}1317                        />1318                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1319                      </div>1320                    </Show>1321                  </div>1322                </Show>1323              </Show>13241325              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1326              <Show1327                when={editingTags()}1328                fallback={1329                  <Show when={!entry().tags || entry().tags!.length === 0}>1330                    <button1331                      class="peach-add-field-link peach-entry-add-link"1332                      onClick={startEditTags}1333                    >1334                      + tags1335                    </button>1336                  </Show>1337                }1338              >1339                <div1340                  class="peach-inline-tag-editor"1341                  onFocusOut={(e) => {1342                    const next = e.relatedTarget;1343                    if (next instanceof Node && e.currentTarget.contains(next)) return;1344                    void saveTags();1345                  }}1346                >1347                  <span class="peach-label">tags</span>1348                  <div class="peach-tag-input-wrap">1349                    <For each={tagsDraft()}>1350                      {(tag) => (1351                        <span class="peach-tag-chip">1352                          {tag}1353                          <button1354                            class="peach-tag-chip-remove"1355                            onMouseDown={(e) => e.preventDefault()}1356                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1357                            aria-label={`Remove ${tag}`}1358                          >×</button>1359                        </span>1360                      )}1361                    </For>1362                    <input1363                      class="peach-tag-input"1364                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1365                      value={tagInput()}1366                      onInput={(e) => setTagInput(e.currentTarget.value)}1367                      onKeyDown={(e) => {1368                        if (e.key === "Enter" || e.key === ",") {1369                          e.preventDefault();1370                          addTag(tagInput());1371                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1372                          setTagsDraft(tagsDraft().slice(0, -1));1373                        } else if (e.key === "Escape") {1374                          e.preventDefault();1375                          setEditingTags(false);1376                          setTagInput("");1377                        }1378                      }}1379                      ref={(el) => requestAnimationFrame(() => el?.focus())}1380                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1381                    />1382                  </div>1383                </div>1384              </Show>13851386              {/* ── TOTP ─────────────────────────────────────────── */}1387              <Show when={entry().type === "login"}>1388                <Show1389                  when={totpState() || editingField() === "totp"}1390                  fallback={1391                    <button1392                      class="peach-add-field-link peach-entry-add-link"1393                      onClick={() => startEdit("totp", "")}1394                    >1395                      + 2FA1396                    </button>1397                  }1398                >1399                  <section class="peach-totp-band">1400                    <Show1401                      when={editingField() === "totp"}1402                      fallback={1403                        <Show when={totpState()}>1404                          {(state) => (1405                            <>1406                              <div1407                                class="peach-totp-main"1408                                onClick={() => {1409                                  // Delay copy so a double-click can cancel it before1410                                  // it overwrites the clipboard the user wants to paste from.1411                                  if (totpClickTimer) return;1412                                  totpClickTimer = setTimeout(() => {1413                                    totpClickTimer = null;1414                                    void copyField(state().code.replace(" ", ""), "totp");1415                                  }, 220);1416                                }}1417                                onDblClick={() => {1418                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1419                                  startEdit("totp", entry().totp ?? "");1420                                }}1421                                title="Click to copy · double-click to edit"1422                              >1423                                <span class="peach-label">2-factor</span>1424                                <div class="peach-totp-code-row">1425                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1426                                    <span class="peach-totp-code-text">1427                                      {state().code.slice(0, 3)} {state().code.slice(3)}1428                                    </span>1429                                  </p>1430                                </div>1431                              </div>1432                              <div class="peach-totp-meta">1433                                <span class="peach-label">expires</span>1434                                <div class="peach-totp-countdown">{state().remaining}s</div>1435                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1436                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1437                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1438                                    stroke-dasharray="106.8"1439                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1440                                  />1441                                </svg>1442                              </div>1443                            </>1444                          )}1445                        </Show>1446                      }1447                    >1448                      <div1449                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1450                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1451                      >1452                        <span class="peach-label">2-factor</span>1453                        <input1454                          class="peach-inline-input"1455                          value={draft()}1456                          onInput={(e) => setDraft(e.currentTarget.value)}1457                          onKeyDown={onEditKeyDown}1458                          ref={autoFocus}1459                          disabled={saving()}1460                          placeholder="otpauth://... or base32 secret"1461                          autocomplete="off"1462                        />1463                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1464                      </div>1465                    </Show>1466                  </section>1467                </Show>1468              </Show>14691470              {/* ── Advanced (canary + passkey) ───────────────────── */}1471              <Show when={entry().type !== "note" && entry().type !== "card"}>1472                <button1473                  class="peach-add-field-link peach-entry-add-link"1474                  onClick={() => setShowAdvanced((v) => !v)}1475                  aria-expanded={showAdvanced()}1476                >1477                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1478                </button>1479                <Show when={showAdvanced()}>1480                  <Show when={!isPasskeyOnly(entry())}>1481                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1482                  </Show>14831484                  <Show when={entry().passkey}>1485                    {(passkey) => {1486                      const currentBrowser = detectBrowser();1487                      const crossBrowser =1488                        passkey().browser &&1489                        currentBrowser !== "unknown" &&1490                        passkey().browser !== currentBrowser;1491                      const algorithmLabel =1492                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1493                      const formatDate = (ts?: number) =>1494                        ts ? new Date(ts).toLocaleDateString() : "Never";1495                      return (1496                        <section1497                          class="peach-fields peach-passkey-section"1498                          style="position:relative;z-index:1;margin-top:6px;"1499                        >1500                          <div class="peach-field-card">1501                            <span class="peach-label">passkey · {passkey().rpId}</span>1502                            <div class="peach-field-value-row">1503                              <span class="peach-field-value">{passkey().userName}</span>1504                              <span class="peach-passkey-badge">passkey</span>1505                            </div>1506                          </div>15071508                          <div class="peach-field-card">1509                            <span class="peach-label">algorithm</span>1510                            <div class="peach-field-value-row">1511                              <span class="peach-field-value">{algorithmLabel}</span>1512                            </div>1513                          </div>15141515                          <div class="peach-field-card">1516                            <span class="peach-label">activity</span>1517                            <div class="peach-field-value-row peach-passkey-activity">1518                              <span class="peach-field-value">1519                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1520                              </span>1521                              <span class="peach-passkey-meta">1522                                created {formatDate(passkey().createdAt)}1523                              </span>1524                              <span class="peach-passkey-meta">1525                                last used {formatDate(passkey().lastUsedAt)}1526                              </span>1527                            </div>1528                          </div>15291530                          <Show when={crossBrowser}>1531                            <div class="peach-info-notice peach-info-notice--warn">1532                              {`This passkey was created on ${1533                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1534                              }. To use it on ${1535                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1536                              }, re-register it on this device.`}1537                            </div>1538                          </Show>15391540                          <div class="peach-info-notice">1541                            {passkey().browser === "firefox"1542                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1543                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1544                          </div>15451546                          <button1547                            class="peach-add-field-link peach-entry-add-link"1548                            style="color:rgba(220,38,38,0.7);"1549                            onClick={async () => {1550                              const id = entry().id;1551                              await sendMessage({1552                                type: "UPDATE_ENTRY",1553                                payload: { id, patch: { passkey: undefined } },1554                              });1555                              await props.onFieldSaved();1556                            }}1557                          >1558                            − remove passkey1559                          </button>1560                        </section>1561                      );1562                    }}1563                  </Show>1564                </Show>1565              </Show>15661567              {/* ── Bottom spacer ─────────────────────────────────── */}1568              <div style="height:4px;" />1569            </>1570          )}1571        </Show>1572      </div>15731574    </>1575  );1576}15771578// ── Sub-components ────────────────────────────────────────────────────15791580function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1581  return (1582    <div class="peach-inline-actions">1583      <button1584        class="peach-inline-save-btn"1585        onClick={props.onSave}1586        disabled={props.saving}1587        aria-label="Save"1588        title="Save (Enter)"1589      >1590        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1591          <path d="M1 4.5L4 7.5L10 1.5" />1592        </svg>1593      </button>1594      <button1595        class="peach-inline-cancel-btn"1596        onClick={props.onCancel}1597        disabled={props.saving}1598        aria-label="Cancel"1599        title="Cancel (Esc)"1600      >1601        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1602          <path d="M1 1L8 8M8 1L1 8" />1603        </svg>1604      </button>1605    </div>1606  );1607}16081609function EmptyVaultState() {1610  return (1611    <>1612      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1613        <div class="peach-entry-name">1614          <span class="peach-entry-name-text">1615            <span class="peach-entry-name-char" style="--char-index:0">e</span>1616            <span class="peach-entry-name-char" style="--char-index:1">m</span>1617            <span class="peach-entry-name-char" style="--char-index:2">p</span>1618            <span class="peach-entry-name-char" style="--char-index:3">t</span>1619            <span class="peach-entry-name-char" style="--char-index:4">y</span>1620          </span>1621        </div>1622      </div>1623      <div class="peach-empty-copy">1624        <span class="peach-label">vault</span>1625        <p>Your vault is ready for its first entry.</p>1626      </div>1627    </>1628  );1629}16301631function CopyGlyph() {1632  return (1633    <span class="peach-copy-icon">1634      <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">1635        <rect x="4" y="4" width="8" height="9" rx="1.5" />1636        <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" />1637      </svg>1638    </span>1639  );1640}16411642function EyeClosedGlyph() {1643  return (1644    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1645      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1646      <path d="M3.5 5.5L2.5 7.5" />1647      <path d="M7 6.5V8.5" />1648      <path d="M10.5 5.5L11.5 7.5" />1649    </svg>1650  );1651}16521653function EyeOpenGlyph() {1654  return (1655    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1656      <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" />1657      <circle cx="7" cy="5" r="1.75" />1658    </svg>1659  );1660}16611662function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1663  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1664  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1665  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1666  const [testResult, setTestResult] = createSignal<string | null>(null);1667  const [testing, setTesting] = createSignal(false);16681669  // Pro gate entry-point for canary creation. Free users cannot toggle the1670  // canary on; the server-side UPDATE_ENTRY handler is the single hard1671  // enforcement point, but this branch avoids the bad UX of letting the1672  // toggle slip into "on" before the save rejects. Existing canary entries1673  // (e.g. retained after a license deactivation, future-proofing) remain1674  // togglable off — gating only applies to enabling a new canary.1675  const isProGated = () => !props.isPro && !props.entry.canary;16761677  async function save() {1678    const url = webhookUrl().trim();1679    const nextLabel = label().trim() || undefined;1680    const canary = enabled() && url1681      ? {1682          webhookUrl: url,1683          label: nextLabel,1684          lastPinged: props.entry.canary?.lastPinged,1685          pingCount: props.entry.canary?.pingCount,1686        }1687      : undefined;1688    await sendMessage({1689      type: "UPDATE_ENTRY",1690      payload: { id: props.entry.id, patch: { canary } },1691    });1692    await props.onSaved();1693  }16941695  async function testPing() {1696    const url = webhookUrl().trim();1697    if (!url) return;1698    setTesting(true);1699    setTestResult(null);1700    const res = await sendMessage({1701      type: "FIRE_CANARY_PING",1702      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1703    }) as { ok: boolean; error?: string };1704    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1705    setTesting(false);1706  }17071708  return (1709    <div class="peach-canary-section">1710      <div class="peach-field-value-row">1711        <span class="peach-field-value" style="font-size:12px;">1712          canary alert1713          <Show when={isProGated()}>1714            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1715          </Show>1716        </span>1717        <button1718          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1719          onClick={() => {1720            if (isProGated()) {1721              // Pro gate: tap on free tier navigates to the upgrade screen1722              // with Canary Entries highlighted. The toggle never flips on1723              // for a free user, regardless of click count.1724              props.onProGate?.("canary_entry");1725              return;1726            }1727            setEnabled(!enabled());1728            void save();1729          }}1730          aria-label="Toggle canary"1731          aria-disabled={isProGated() ? "true" : "false"}1732        >1733          <span class="peach-toggle-knob" />1734        </button>1735      </div>1736      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1737        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1738      </p>1739      <Show when={enabled()}>1740        <div class="peach-inline-field-wrap" style="margin-top:8px;">1741          <input1742            class="peach-inline-input"1743            placeholder="https://hooks.zapier.com/..."1744            value={webhookUrl()}1745            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1746            onBlur={() => void save()}1747          />1748          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1749            When triggered, the entry title is sent to this URL. Use an endpoint you control.1750          </p>1751          <input1752            class="peach-inline-input"1753            placeholder="label (optional, e.g. AWS decoy)"1754            value={label()}1755            onInput={(e) => setLabel(e.currentTarget.value)}1756            onBlur={() => void save()}1757          />1758          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1759            <button1760              class="peach-btn peach-btn--ghost peach-btn--sm"1761              onClick={() => void testPing()}1762              disabled={testing() || !webhookUrl().trim()}1763            >1764              {testing() ? "sending..." : "test ping"}1765            </button>1766            <Show when={testResult()}>1767              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1768            </Show>1769          </div>1770          <Show when={props.entry.canary?.lastPinged}>1771            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1772              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1773              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1774            </p>1775          </Show>1776        </div>1777      </Show>1778    </div>1779  );1780}