Entry.tsx

81.3 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   * — Capacitor, an extension page, or a chrome:///about: URL. The URL37   * editor uses this 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  onEscape?: () => void;44  entries?: VaultEntry[];45  /** Whether the user holds an active Peach Pro license. Drives Pro gates on46   *  canary entries (see CanarySection). */47  isPro?: boolean;48  /** Invoked when a Pro-gated control is tapped on free tier. App owns the49   *  upgrade navigation + highlight state. */50  onProGate?: (feature: ProFeature) => void;51}5253// Inline 16×16 favicon for the domain row — shows coloured initials until a54// real favicon is confirmed (same globe-detection logic as the vault list).55function DomainFavicon(props: { domain: string; title: string }) {56  const src = props.domain ? faviconUrl(props.domain) : null;57  const [isReal, setIsReal] = createSignal(false);58  const label = () => titleInitials(props.title || props.domain);59  const bg = () => titleBackground(props.title || props.domain);60  const chipStyle = () =>61    `display:inline-flex;align-items:center;justify-content:center;` +62    `width:16px;height:16px;border-radius:3px;background:${bg()};` +63    `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +64    `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;65  return (66    <Show67      when={isReal()}68      fallback={69        <>70          <span style={chipStyle()} aria-hidden="true">{label()}</span>71          {src && (72            <img73              src={src}74              style="display:none"75              onError={() => {}}76              onLoad={async (e) => {77                const img = e.currentTarget as HTMLImageElement;78                if (img.naturalWidth <= 16) return;79                const sig = faviconImgSig(img);80                if (sig !== null) {81                  const gSig = await globeFaviconSig();82                  if (gSig !== null && sig === gSig) return;83                }84                setIsReal(true);85              }}86            />87          )}88        </>89      }90    >91      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />92    </Show>93  );94}9596export function EntryView(props: EntryProps) {97  const [editingField, setEditingField] = createSignal<EditableField>(null);98  const [draft, setDraft] = createSignal("");99  const [saving, setSaving] = createSignal(false);100  const [showPassword, setShowPassword] = createSignal(false);101  const [showGenerator, setShowGenerator] = createSignal(false);102  const [showHistory, setShowHistory] = createSignal(false);103  const [revealedHistory, setRevealedHistory] = createSignal<Set<number>>(new Set());104  const [copied, setCopied] = createSignal<string | null>(null);105  // Local draft for the additional URLs list, edited only while the URL field106  // is in inline-edit mode. Saved alongside `url` when the user confirms.107  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);108  const [editingTags, setEditingTags] = createSignal(false);109  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);110  const [tagInput, setTagInput] = createSignal("");111  const [showCardNumber, setShowCardNumber] = createSignal(false);112  const [showCardCvc, setShowCardCvc] = createSignal(false);113  const [totpTick, setTotpTick] = createSignal(0);114  const [confirmDelete, setConfirmDelete] = createSignal(false);115  const [totpHot, setTotpHot] = createSignal(false);116  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);117  // Auto-open advanced when entry already has passkey or canary configured118  const [showAdvanced, setShowAdvanced] = createSignal(false);119  const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);120  let usernameInlineInput: HTMLInputElement | undefined;121  let emailInlineInput: HTMLInputElement | undefined;122  let usernameIdentityEl: HTMLDivElement | undefined;123  let emailIdentityEl: HTMLDivElement | undefined;124  let lastSavedNote: string | null = null;125  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;126  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });127128  function noteExec(cmd: string, val?: string) {129    document.execCommand(cmd, false, val);130    noteEditorEl()?.focus();131  }132133  async function saveNoteInline(html: string) {134    const entry = props.entry;135    if (!entry) return;136    const currentStored = entry.notes ?? "";137    if (html === currentStored) return;138    if (lastSavedNote !== null && html === lastSavedNote) return;139    lastSavedNote = html;140    setSaving(true);141    try {142      await sendMessage({143        type: "UPDATE_ENTRY",144        payload: { id: entry.id, patch: { notes: html } },145      });146      triggerRing();147      await props.onFieldSaved();148    } catch {149      lastSavedNote = null;150    } finally {151      setSaving(false);152    }153  }154155  const allIdentities = createMemo(() => {156    const emailMap = new Map<string, number>();157    const usernameMap = new Map<string, number>();158    for (const e of props.entries ?? []) {159      const uname = e.username?.trim() ?? "";160      const em = e.email?.trim() ?? "";161      if (uname) {162        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);163        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);164      }165      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);166    }167    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];168    const seen = new Set<string>();169    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {170      if (seen.has(v)) continue;171      seen.add(v);172      const ec = emailMap.get(v) ?? 0;173      const uc = usernameMap.get(v) ?? 0;174      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });175    }176    return result.sort((a, b) => b.count - a.count);177  });178179  const identitySuggestions = createMemo(() => {180    const field = editingField();181    if (field !== "username" && field !== "email") return [];182    const input = draft().toLowerCase();183    return allIdentities()184      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))185      .slice(0, 8);186  });187188  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);189  onCleanup(() => window.clearInterval(interval));190191  const displayDomain = createMemo(() =>192    props.entry?.url ? getDisplayDomain(props.entry.url) : ""193  );194195  // Reset transient prompts (trash confirmation, inline edits, advanced panel)196  // when the selected entry changes so they don't carry over between entries.197  // Track the last id so a same-entry refresh (e.g. after saving a field)198  // doesn't blow away in-flight edit state — that race used to cancel199  // startEdit calls that ran right after onFieldSaved.200  let lastEntryId: string | null = null;201  createEffect(() => {202    const id = props.entry?.id ?? null;203    if (id === lastEntryId) return;204    lastEntryId = id;205    setConfirmDelete(false);206    setEditingField(null);207    setEditingTags(false);208    setShowAdvanced(false);209  });210211  const totpState = createMemo(() => {212    void totpTick();213    if (!props.entry?.totp) return null;214    return generateTOTP(props.entry.totp);215  });216217  let prevTotpCode: string | undefined;218  createEffect(() => {219    const state = totpState();220    if (!state) { prevTotpCode = undefined; return; }221    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {222      setTotpHot(true);223      window.setTimeout(() => setTotpHot(false), 3000);224    }225    prevTotpCode = state.code;226  });227228  const totpProgress = createMemo(() => {229    const state = totpState();230    if (!state) return 0;231    return ((state.remaining - 1) / 29) * 100;232  });233234  createEffect(() => {235    const shell = document.getElementById("hd-shell");236    if (!shell) return;237238    const state = totpState();239    if (!state) {240      delete shell.dataset.totpActive;241      shell.style.removeProperty("--peach-totp-progress");242      return;243    }244245    shell.dataset.totpActive = "true";246    shell.style.setProperty(247      "--peach-totp-progress",248      `${Math.max(0, totpProgress())}%`249    );250251    onCleanup(() => {252      delete shell.dataset.totpActive;253      shell.style.removeProperty("--peach-totp-progress");254    });255  });256257  const titleSizeClass = createMemo(() => {258    const length = props.entry?.title.length ?? 0;259    if (length >= 28) return "peach-entry-name--xxl";260    if (length >= 20) return "peach-entry-name--xl";261    if (length >= 14) return "peach-entry-name--lg";262    return "";263  });264  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);265266  // ── Inline editing ────────────────────────────────────────────────267268  function startEdit(field: EditableField, value: string) {269    setEditingField(field);270    setDraft(value);271    setIdentityHelperSuppressed(false);272    setShowGenerator(false);273    setShowHistory(false);274    setRevealedHistory(new Set<number>());275    setConfirmDelete(false);276    if (field === "url") {277      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);278    }279  }280281  // Compare URLs by string after trim — vault.ts has its own canonicalisation282  // when saving, so the dedupe here is a UX-level "don't show the button if283  // we'd just add a duplicate" rather than a security check.284  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {285    const c = candidate.trim();286    if (!c) return true;287    if (primary.trim() === c) return true;288    return others.some((u) => u.trim() === c);289  }290291  function addCurrentSite(url: string) {292    if (!url) return;293    if (!draft().trim()) {294      setDraft(url);295      return;296    }297    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;298    setAdditionalUrlsDraft((arr) => [...arr, url]);299  }300301  async function saveField() {302    const field = editingField();303    const entry = props.entry;304    if (!field || !entry) return;305    // Don't trim passwords — spaces may be intentional306    let value = field === "password" ? draft() : draft().trim();307    if (field === "totp" && value.startsWith("otpauth://")) {308      const parsed = parseOtpauthURI(value);309      value = parsed?.secret ?? value;310    }311    if (field === "password" && entry.type !== "note" && !value) {312      return;313    }314    setSaving(true);315    try {316      const patch: Partial<VaultEntry> = { [field]: value };317      if (field === "url") {318        // Sanitization layer in vault.ts trims, dedupes, and drops empties.319        patch.additionalUrls = additionalUrlsDraft();320      }321      await sendMessage({322        type: "UPDATE_ENTRY",323        payload: { id: entry.id, patch },324      });325      triggerRing();326      setEditingField(null);327      setShowGenerator(false);328      setShowHistory(false);329      setRevealedHistory(new Set<number>());330      await props.onFieldSaved();331    } catch {332      // silent — leave editing open333    } finally {334      setSaving(false);335    }336  }337338  function startEditTags() {339    setTagsDraft([...(props.entry?.tags ?? [])]);340    setTagInput("");341    setEditingTags(true);342  }343344  function addTag(value: string) {345    const n = value.toLowerCase().trim();346    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;347    setTagsDraft([...tagsDraft(), n]);348    setTagInput("");349  }350351  async function saveTags() {352    if (tagInput()) addTag(tagInput());353    const entry = props.entry;354    if (!entry) return;355    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;356    setEditingTags(false);357    setSaving(true);358    try {359      await sendMessage({360        type: "UPDATE_ENTRY",361        payload: { id: entry.id, patch: { tags: next } },362      });363      triggerRing();364      await props.onFieldSaved();365    } catch {366      // silent367    } finally {368      setSaving(false);369    }370  }371372  function cancelEdit() {373    setEditingField(null);374    setDraft("");375    setShowGenerator(false);376    setShowHistory(false);377    setRevealedHistory(new Set<number>());378  }379380  // Switch from one identity slot to the other.381  //382  // Migration source is the SAVED entry value, never the live draft — using383  // the draft caused data loss when the user typed or cleared the input384  // before clicking +add (the migration would skip and a later Save would385  // overwrite the original email-shape value with whatever was in the input).386  //387  // Drafts only get committed when they are non-empty AND actually changed,388  // so an empty input never accidentally clears a saved field.389  async function addOtherIdentity(target: "username" | "email") {390    const entry = props.entry;391    if (!entry) return;392    const current = editingField();393    const draftValue = draft().trim();394    const savedUsername = entry.username ?? "";395    const savedEmail = entry.email ?? "";396397    if (current === "username" && target === "username") {398      // Legacy fix-up: saved username is email-shaped and no separate email399      // exists yet. Move it to entry.email and free the username slot.400      if (savedUsername.includes("@") && !entry.email) {401        setSaving(true);402        try {403          await sendMessage({404            type: "UPDATE_ENTRY",405            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },406          });407          await props.onFieldSaved();408        } finally {409          setSaving(false);410        }411      }412      startEdit("username", "");413      return;414    }415416    if (current === "username" && target === "email") {417      if (draftValue && draftValue !== savedUsername) {418        setSaving(true);419        try {420          await sendMessage({421            type: "UPDATE_ENTRY",422            payload: { id: entry.id, patch: { username: draftValue } },423          });424          await props.onFieldSaved();425        } finally {426          setSaving(false);427        }428      }429      startEdit("email", "");430      return;431    }432433    if (current === "email" && target === "username") {434      if (draftValue && draftValue !== savedEmail) {435        setSaving(true);436        try {437          await sendMessage({438            type: "UPDATE_ENTRY",439            payload: { id: entry.id, patch: { email: draftValue } },440          });441          await props.onFieldSaved();442        } finally {443          setSaving(false);444        }445      }446      startEdit("username", "");447      return;448    }449  }450451  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {452    switch (field) {453      case "title":454        return entry.title;455      case "url":456        return entry.url ?? "";457      case "username":458        return entry.username ?? "";459      case "email":460        return entry.email ?? "";461      case "password":462        return entry.password ?? "";463      case "notes":464        return entry.notes ?? "";465      case "totp":466        return entry.totp ?? "";467    }468  }469470  function handleEditorFocusOut(471    field: Exclude<EditableField, null>,472    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }473  ) {474    const nextTarget = event.relatedTarget;475    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;476477    // addOtherIdentity / startEdit may have already switched editingField before478    // focusout fires (SolidJS removes the old editor node synchronously, which479    // triggers blur with relatedTarget=null). Don't cancel the new edit.480    if (editingField() !== field) return;481482    const entry = props.entry;483    if (!entry) return;484485    const currentValue = fieldValue(entry, field);486    const nextValue = field === "password" ? draft() : draft().trim();487    if (nextValue === currentValue) {488      cancelEdit();489    }490  }491492  // ── Copy ─────────────────────────────────────────────────────────493494  async function copyField(text: string, label: string, button?: HTMLButtonElement) {495    const ok = await popupCopy(text, label);496    if (ok) {497      const entry = props.entry;498      if (entry?.canary?.webhookUrl) {499        const trigger =500          label === "password"501            ? "password_copied"502            : label === "username"503              ? "username_copied"504              : undefined;505        if (trigger) {506          void sendMessage({507            type: "FIRE_CANARY_PING",508            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },509          });510        }511      }512      triggerRing();513      triggerPipPulse();514      setCopied(null);515      requestAnimationFrame(() => setCopied(label));516      if (button) {517        button.classList.remove("is-copied", "is-retreat");518        void button.offsetWidth;519        const drain = document.createElement("span");520        drain.className = "peach-copy-drain";521        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";522        drain.appendChild(bar);523        button.appendChild(drain);524        window.setTimeout(() => {525          button.classList.remove("is-copied");526          button.classList.add("is-retreat");527          drain.remove();528          window.setTimeout(() => button.classList.remove("is-retreat"), 160);529        }, 1800);530      }531      window.setTimeout(() => setCopied(null), 1800);532    }533  }534535  // ── Keyboard shortcuts ────────────────────────────────────────────536537  createEffect(() => {538    if (!props.entry) return;539    const handler = (e: KeyboardEvent) => {540      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;541      if (inInput) return;542      switch (e.key) {543        case "c": e.preventDefault();544          if (props.entry?.password) void copyField(props.entry.password, "password"); break;545        case "u": e.preventDefault();546          if (props.entry?.username) void copyField(props.entry.username, "username"); break;547        case "Escape": e.preventDefault(); props.onEscape?.(); break;548      }549    };550    window.addEventListener("keydown", handler);551    onCleanup(() => window.removeEventListener("keydown", handler));552  });553554  // ── Input ref focus helper ────────────────────────────────────────555556  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {557    requestAnimationFrame(() => { el.focus(); el.select?.(); });558  }559560  function onEditKeyDown(e: KeyboardEvent) {561    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {562      e.preventDefault();563      void saveField();564    }565    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }566  }567568  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {569    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;570    if (e.key === "ArrowDown" && listEl) {571      e.preventDefault();572      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();573      return;574    }575    onEditKeyDown(e);576  }577578  return (579    <>580      <div581        class="peach-ghost"582        aria-hidden="true"583        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;"584      >585        {props.entry?.title || "empty"}586      </div>587588      <div class="peach-stage" style="position:relative;">589        <Show when={props.entry} fallback={<EmptyVaultState />}>590          {(entry) => (591            <>592              {/* ── Breach strip ─────────────────────────────────── */}593              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>594                <div class="peach-breach-badge">595                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">596                    <path d="M6 1L11 10H1L6 1z" />597                    <line x1="6" y1="5" x2="6" y2="7.5" />598                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />599                  </svg>600                  <span style="flex:1">found in a known data breach</span>601                </div>602              </Show>603604              {/* ── Title + trash ─────────────────────────────────── */}605              <div class="peach-entry-header">606                <div class="peach-entry-header-name">607                  <Show608                    when={editingField() === "title"}609                    fallback={610                      <button611                        class="peach-entry-name-button"612                        title="Click to edit title"613                        onClick={() => startEdit("title", entry().title)}614                      >615                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>616                          <span class="peach-entry-name-text">617                            <Show618                              when={useAnimatedTitle()}619                              fallback={entry().title}620                            >621                              <For each={entry().title.split("")}>622                                {(ch, i) => (623                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>624                                    {ch === " " ? "\u00A0" : ch}625                                  </span>626                                )}627                              </For>628                            </Show>629                          </span>630                        </h1>631                      </button>632                    }633                  >634                    <div635                      class="peach-inline-title-wrap"636                      onFocusOut={(event) => handleEditorFocusOut("title", event)}637                    >638                      <input639                        class="peach-inline-title-input"640                        value={draft()}641                        onInput={(e) => setDraft(e.currentTarget.value)}642                        onKeyDown={onEditKeyDown}643                        ref={autoFocus}644                        disabled={saving()}645                      />646                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />647                    </div>648                  </Show>649                </div>650651                {/* Subtle trash icon — hidden while editing title */}652                <Show when={editingField() !== "title"}>653                  <Show654                    when={confirmDelete()}655                    fallback={656                      <button657                        class="peach-entry-trash-btn"658                        onClick={() => setConfirmDelete(true)}659                        aria-label="Move to trash"660                        title="Delete entry"661                      >662                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">663                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />664                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />665                        </svg>666                      </button>667                    }668                  >669                    <div class="peach-entry-trash-confirm">670                      <span class="peach-entry-trash-confirm-label">trash?</span>671                      <button672                        class="peach-btn peach-btn--sm peach-btn--danger"673                        style="font-size:10px;padding:2px 8px;min-height:22px;"674                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}675                      >676                        yes677                      </button>678                      <button679                        class="peach-btn peach-btn--ghost peach-btn--sm"680                        style="font-size:10px;padding:2px 8px;min-height:22px;"681                        onClick={() => setConfirmDelete(false)}682                      >683                        no684                      </button>685                    </div>686                  </Show>687                </Show>688              </div>689690              {/* ── Domain / URL ─────────────────────────────────── */}691              <Show when={entry().type !== "note" && entry().type !== "card"}>692                <div class="peach-domain" style="position:relative;z-index:1;">693                  <Show694                    when={editingField() === "url"}695                    fallback={696                      <div697                        class="peach-domain-row peach-inline-clickable"698                        title="Click to edit URL"699                        onClick={() => startEdit("url", entry().url ?? "")}700                      >701                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />702                        <span class="peach-domain-value">703                          {displayDomain() || (entry().url ? entry().url : "add a website url")}704                        </span>705                        <span class="peach-inline-edit-hint">edit</span>706                      </div>707                    }708                  >709                    <div710                      class="peach-inline-field-wrap peach-inline-field-wrap--url"711                      onFocusOut={(event) => handleEditorFocusOut("url", event)}712                    >713                      <div class="peach-inline-field-row">714                        <input715                          class="peach-inline-input"716                          type="url"717                          placeholder="https://"718                          value={draft()}719                          onInput={(e) => setDraft(e.currentTarget.value)}720                          onKeyDown={onEditKeyDown}721                          ref={autoFocus}722                          disabled={saving()}723                        />724                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />725                      </div>726                      <Show727                        when={728                          detectHomograph(draft()) ||729                          additionalUrlsDraft().some((u) => detectHomograph(u))730                        }731                      >732                        {/* UI-8 / LV-2: non-blocking homograph warning. */}733                        <p class="peach-homograph-warning">734                          ⚠ This URL contains non-ASCII or punycode characters.735                          Verify it&apos;s the site you intend.736                        </p>737                      </Show>738                      <div class="peach-additional-urls">739                        <Show when={additionalUrlsDraft().length > 0}>740                          <span class="peach-label peach-additional-urls-label">also matches</span>741                        </Show>742                        <For each={additionalUrlsDraft()}>743                          {(value, i) => (744                            <div class="peach-additional-url-row">745                              <input746                                class="peach-inline-input"747                                type="url"748                                placeholder="https://"749                                value={value}750                                onInput={(e) => setAdditionalUrlsDraft((arr) => {751                                  const next = [...arr];752                                  next[i()] = e.currentTarget.value;753                                  return next;754                                })}755                                disabled={saving()}756                              />757                              <button758                                class="peach-additional-url-remove"759                                type="button"760                                aria-label="Remove URL"761                                onMouseDown={(e) => e.preventDefault()}762                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}763                                disabled={saving()}764                              >×</button>765                            </div>766                          )}767                        </For>768                        <button769                          class="peach-add-field-link peach-additional-urls-add"770                          type="button"771                          onMouseDown={(e) => e.preventDefault()}772                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}773                          disabled={saving()}774                        >775                          + add another url776                        </button>777                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>778                          <button779                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"780                            type="button"781                            title={props.currentTabUrl}782                            onMouseDown={(e) => e.preventDefault()}783                            onClick={() => addCurrentSite(props.currentTabUrl!)}784                            disabled={saving()}785                          >786                            + add current site787                          </button>788                        </Show>789                      </div>790                    </div>791                  </Show>792                </div>793              </Show>794795              {/* ── Tags — read-only chips (editing state lives at bottom) */}796              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>797                <div798                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"799                  onClick={startEditTags}800                  title="Click to edit tags"801                >802                  <For each={entry().tags!}>803                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}804                  </For>805                </div>806              </Show>807808              {/* ── Note toolbar + inline editor ─────────────────── */}809              <Show when={entry().type === "note"}>810                <div class="peach-entry-note-toolbar-row">811                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />812                </div>813                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">814                  <NoteEditorInline815                    entryKey={entry().id}816                    initialContent={entry().notes ?? ""}817                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}818                    onBlur={saveNoteInline}819                    autoFocus={false}820                  />821                </section>822              </Show>823824              {/* ── Card detail view ─────────────────────────────── */}825              <Show when={entry().type === "card"}>826                <section class="peach-fields" style="position:relative;z-index:1;">827                  {/* Card subtitle: •••• last4 */}828                  <Show when={entry().last4}>829                    <div class="peach-field-card" style="padding-bottom:4px;">830                      <span class="peach-label">831                        <Show when={entry().network && entry().network !== "unknown"}>832                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>833                        </Show>834                        {entry().cardNickname || `•••• ${entry().last4}`}835                      </span>836                    </div>837                  </Show>838839                  {/* Cardholder name */}840                  <div class="peach-field-card">841                    <span class="peach-label">cardholder name</span>842                    <div class="peach-field-value-row">843                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>844                      <Show when={entry().cardholderName}>845                        <button846                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}847                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}848                          aria-label="Copy cardholder name"849                        >850                          <CopyGlyph /><span class="peach-copy-sheen" />851                        </button>852                      </Show>853                    </div>854                  </div>855856                  {/* Card number */}857                  <div class="peach-field-card">858                    <span class="peach-label">card number</span>859                    <div class="peach-field-value-row">860                      <span class={`peach-field-value peach-card-number`}>861                        <Show when={showCardNumber()} fallback={862                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>863                        }>864                          <span>{entry().cardNumber ?? ""}</span>865                        </Show>866                      </span>867                      <div class="peach-field-actions">868                        <button869                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"870                          onClick={() => setShowCardNumber(!showCardNumber())}871                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}872                        >873                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>874                            <EyeClosedGlyph />875                          </Show>876                        </button>877                        <Show when={entry().cardNumber}>878                          <button879                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}880                            onClick={(e) => {881                              const cardNum = entry().cardNumber!;882                              if (entry().canary?.webhookUrl) {883                                void sendMessage({884                                  type: "FIRE_CANARY_PING",885                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },886                                });887                              }888                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);889                            }}890                            aria-label="Copy card number"891                          >892                            <CopyGlyph /><span class="peach-copy-sheen" />893                          </button>894                        </Show>895                      </div>896                    </div>897                  </div>898899                  {/* Expiry */}900                  <Show when={entry().expMonth || entry().expYear}>901                    <div class="peach-field-card">902                      <span class="peach-label">expiry</span>903                      <div class="peach-field-value-row">904                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>905                        <button906                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}907                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}908                          aria-label="Copy expiry"909                        >910                          <CopyGlyph /><span class="peach-copy-sheen" />911                        </button>912                      </div>913                    </div>914                  </Show>915916                  {/* CVC */}917                  <Show when={entry().cvc}>918                    <div class="peach-field-card">919                      <span class="peach-label">CVC</span>920                      <div class="peach-field-value-row">921                        <span class="peach-field-value">922                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>923                            <span>{entry().cvc}</span>924                          </Show>925                        </span>926                        <div class="peach-field-actions">927                          <button928                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"929                            onClick={() => setShowCardCvc(!showCardCvc())}930                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}931                          >932                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>933                              <EyeClosedGlyph />934                            </Show>935                          </button>936                          <button937                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}938                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}939                            aria-label="Copy CVC"940                          >941                            <CopyGlyph /><span class="peach-copy-sheen" />942                          </button>943                        </div>944                      </div>945                    </div>946                  </Show>947                </section>948              </Show>949950              {/* ── Login fields ─────────────────────────────────── */}951              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>952                <section class="peach-fields" style="position:relative;z-index:1;">953954                  {/* Username — primary identity slot. Auto-labels as "email"955                      when the stored value is email-shaped and no separate956                      email field exists (legacy data path). */}957                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>958                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>959                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>960                      <Show961                        when={editingField() === "username"}962                        fallback={963                          <div class="peach-field-value-row">964                            <span965                              class="peach-field-value peach-inline-clickable"966                              title="Click to edit"967                              onClick={() => startEdit("username", entry().username ?? "")}968                            >969                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}970                            </span>971                            <Show when={entry().username}>972                              <button973                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}974                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}975                                aria-label="Copy username"976                              >977                                <CopyGlyph /><span class="peach-copy-sheen" />978                              </button>979                            </Show>980                          </div>981                        }982                      >983                        <div984                          class="peach-inline-field-wrap"985                          onFocusOut={(event) => handleEditorFocusOut("username", event)}986                        >987                          <input988                            class="peach-inline-input"989                            value={draft()}990                            onInput={(e) => setDraft(e.currentTarget.value)}991                            onKeyDown={(e) => identityKeyDown(e, "username")}992                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}993                            disabled={saving()}994                            autocomplete="off"995                          />996                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />997                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>998                            <IdentitySuggestList999                              listRef={(el) => (usernameIdentityEl = el)}1000                              inputRef={() => usernameInlineInput}1001                              suggestions={identitySuggestions()}1002                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1003                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1004                            />1005                          </Show>1006                          {/* +add the OTHER identity. If the slot is acting as1007                              email (value has @, no separate email), offer1008                              "+ add username" — clicking migrates the email1009                              into entry.email. Otherwise offer "+ add email". */}1010                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1011                            <button1012                              class="peach-add-field-btn"1013                              type="button"1014                              onMouseDown={(e) => e.preventDefault()}1015                              onClick={() => {1016                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1017                                const hasSeparateEmail = !!entry().email;1018                                if (isEmailShape && !hasSeparateEmail) {1019                                  void addOtherIdentity("username");1020                                } else {1021                                  void addOtherIdentity("email");1022                                }1023                              }}1024                            >1025                              {(() => {1026                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1027                                const hasSeparateEmail = !!entry().email;1028                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1029                              })()}1030                            </button>1031                          </Show>1032                        </div>1033                      </Show>1034                    </div>1035                  </Show>10361037                  {/* Email — secondary identity slot. Visible whenever a1038                      separate email field is set, or while it is being edited. */}1039                  <Show when={entry().email || editingField() === "email"}>1040                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1041                      <span class="peach-label">email</span>1042                      <Show1043                        when={editingField() === "email"}1044                        fallback={1045                          <div class="peach-field-value-row">1046                            <span1047                              class="peach-field-value peach-inline-clickable"1048                              title="Click to edit"1049                              onClick={() => startEdit("email", entry().email ?? "")}1050                            >1051                              {entry().email}1052                            </span>1053                            <Show when={entry().email}>1054                              <button1055                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1056                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1057                                aria-label="Copy email"1058                              >1059                                <CopyGlyph /><span class="peach-copy-sheen" />1060                              </button>1061                            </Show>1062                          </div>1063                        }1064                      >1065                        <div1066                          class="peach-inline-field-wrap"1067                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1068                        >1069                          <input1070                            class="peach-inline-input"1071                            value={draft()}1072                            onInput={(e) => setDraft(e.currentTarget.value)}1073                            onKeyDown={(e) => identityKeyDown(e, "email")}1074                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1075                            disabled={saving()}1076                            autocomplete="email"1077                            type="email"1078                          />1079                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1080                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1081                            <IdentitySuggestList1082                              listRef={(el) => (emailIdentityEl = el)}1083                              inputRef={() => emailInlineInput}1084                              suggestions={identitySuggestions()}1085                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1086                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1087                            />1088                          </Show>1089                          <Show when={!entry().username || entry().username?.includes("@")}>1090                            <button1091                              class="peach-add-field-btn"1092                              type="button"1093                              onMouseDown={(e) => e.preventDefault()}1094                              onClick={() => void addOtherIdentity("username")}1095                            >1096                              + add username1097                            </button>1098                          </Show>1099                        </div>1100                      </Show>1101                    </div>1102                  </Show>11031104                  {/* Password */}1105                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1106                    <span class="peach-label">password</span>1107                    <Show1108                      when={editingField() === "password"}1109                      fallback={1110                        <div class="peach-field-value-row">1111                          <span1112                            class="peach-password-value"1113                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1114                            style="cursor:pointer;"1115                            title="Click to edit password"1116                            onClick={() => startEdit("password", entry().password ?? "")}1117                          >1118                            <Show1119                              when={showPassword()}1120                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1121                            >1122                              <span class="peach-password-text">{entry().password ?? ""}</span>1123                            </Show>1124                          </span>1125                          <div class="peach-field-actions">1126                            <button1127                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1128                              onClick={() => setShowPassword(!showPassword())}1129                              aria-label="Reveal password"1130                            >1131                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1132                                <EyeClosedGlyph />1133                              </Show>1134                            </button>1135                            <button1136                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1137                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1138                              aria-label="Copy password"1139                            >1140                              <CopyGlyph /><span class="peach-copy-sheen" />1141                            </button>1142                          </div>1143                        </div>1144                      }1145                    >1146                      {/* ── Edit mode: type / paste / generate ── */}1147                      <div1148                        class="peach-inline-field-wrap"1149                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1150                      >1151                        <div class="peach-password-edit-row">1152                          <input1153                            class="peach-inline-input"1154                            type={showPassword() ? "text" : "password"}1155                            placeholder="paste or type a password"1156                            value={draft()}1157                            onInput={(e) => setDraft(e.currentTarget.value)}1158                            onKeyDown={(e) => {1159                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1160                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1161                            }}1162                            ref={autoFocus}1163                            disabled={saving()}1164                            autocomplete="new-password"1165                          />1166                          <button1167                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1168                            type="button"1169                            onClick={() => setShowPassword(!showPassword())}1170                            aria-label={showPassword() ? "Hide" : "Show"}1171                          >1172                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1173                              <EyeClosedGlyph />1174                            </Show>1175                          </button>1176                        </div>1177                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1178                        <div class="peach-password-edit-links">1179                          <button1180                            class="peach-generate-toggle-link"1181                            type="button"1182                            onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1183                          >1184                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1185                              <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1186                              <path d="M9.5 2.5H11V1" />1187                            </svg>1188                            {showGenerator() ? "hide generator" : "generate"}1189                          </button>1190                          <button1191                            class="peach-generate-toggle-link"1192                            type="button"1193                            onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1194                          >1195                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1196                              <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1197                              <path d="M6.5 3.5V6.5L8.5 8" />1198                              <path d="M1 1.5V4H3.5" />1199                            </svg>1200                            {showHistory()1201                              ? "hide history"1202                              : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1203                          </button>1204                        </div>1205                        <Show when={showGenerator()}>1206                          <GeneratorDrawer1207                            open={true}1208                            compact={true}1209                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1210                          />1211                        </Show>1212                        <Show when={showHistory()}>1213                          <div class="peach-password-history">1214                            <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1215                              <div class="peach-history-empty">1216                                No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1217                              </div>1218                            </Show>1219                            <For each={entry().passwordHistory ?? []}>1220                              {(item, i) => (1221                                <div class="peach-history-row">1222                                  <div class="peach-history-main">1223                                    <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1224                                      <Show1225                                        when={revealedHistory().has(i())}1226                                        fallback={<span class="peach-password-mask">••••••••••••</span>}1227                                      >1228                                        {item.password}1229                                      </Show>1230                                    </span>1231                                    <span class="peach-history-age">{timeSince(item.changedAt)}</span>1232                                  </div>1233                                  <div class="peach-history-actions">1234                                    <button1235                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1236                                      type="button"1237                                      title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1238                                      onClick={() => setRevealedHistory((prev) => {1239                                        const next = new Set(prev);1240                                        if (next.has(i())) next.delete(i()); else next.add(i());1241                                        return next;1242                                      })}1243                                    >1244                                      <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1245                                        <EyeClosedGlyph />1246                                      </Show>1247                                    </button>1248                                    <button1249                                      class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1250                                      type="button"1251                                      title="Copy"1252                                      onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1253                                    >1254                                      <CopyGlyph /><span class="peach-copy-sheen" />1255                                    </button>1256                                    <button1257                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1258                                      type="button"1259                                      title="Use this password again"1260                                      onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1261                                    >1262                                      use1263                                    </button>1264                                  </div>1265                                </div>1266                              )}1267                            </For>1268                          </div>1269                        </Show>1270                      </div>1271                    </Show>1272                  </div>12731274                </section>1275              </Show>12761277              {/* ── Notes (on login entries) ──────────────────────── */}1278              <Show when={entry().type !== "note" && entry().type !== "card"}>1279                <Show1280                  when={entry().notes || editingField() === "notes"}1281                  fallback={1282                    <button1283                      class="peach-add-field-link peach-entry-add-link"1284                      onClick={() => startEdit("notes", "")}1285                    >1286                      + notes1287                    </button>1288                  }1289                >1290                  <div style="position:relative;z-index:1;margin-top:6px;">1291                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1292                    <Show1293                      when={editingField() === "notes"}1294                      fallback={1295                        <div1296                          class="peach-note-content peach-inline-clickable"1297                          style="cursor:text;white-space:pre-wrap;"1298                          onClick={() => startEdit("notes", entry().notes ?? "")}1299                        >1300                          {entry().notes}1301                        </div>1302                      }1303                    >1304                      <div1305                        class="peach-inline-field-wrap"1306                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1307                      >1308                        <textarea1309                          class="peach-inline-textarea"1310                          value={draft()}1311                          onInput={(e) => setDraft(e.currentTarget.value)}1312                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1313                          ref={(el) => requestAnimationFrame(() => el?.focus())}1314                          rows={4}1315                          disabled={saving()}1316                        />1317                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1318                      </div>1319                    </Show>1320                  </div>1321                </Show>1322              </Show>13231324              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1325              <Show1326                when={editingTags()}1327                fallback={1328                  <Show when={!entry().tags || entry().tags!.length === 0}>1329                    <button1330                      class="peach-add-field-link peach-entry-add-link"1331                      onClick={startEditTags}1332                    >1333                      + tags1334                    </button>1335                  </Show>1336                }1337              >1338                <div1339                  class="peach-inline-tag-editor"1340                  onFocusOut={(e) => {1341                    const next = e.relatedTarget;1342                    if (next instanceof Node && e.currentTarget.contains(next)) return;1343                    void saveTags();1344                  }}1345                >1346                  <span class="peach-label">tags</span>1347                  <div class="peach-tag-input-wrap">1348                    <For each={tagsDraft()}>1349                      {(tag) => (1350                        <span class="peach-tag-chip">1351                          {tag}1352                          <button1353                            class="peach-tag-chip-remove"1354                            onMouseDown={(e) => e.preventDefault()}1355                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1356                            aria-label={`Remove ${tag}`}1357                          >×</button>1358                        </span>1359                      )}1360                    </For>1361                    <input1362                      class="peach-tag-input"1363                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1364                      value={tagInput()}1365                      onInput={(e) => setTagInput(e.currentTarget.value)}1366                      onKeyDown={(e) => {1367                        if (e.key === "Enter" || e.key === ",") {1368                          e.preventDefault();1369                          addTag(tagInput());1370                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1371                          setTagsDraft(tagsDraft().slice(0, -1));1372                        } else if (e.key === "Escape") {1373                          e.preventDefault();1374                          setEditingTags(false);1375                          setTagInput("");1376                        }1377                      }}1378                      ref={(el) => requestAnimationFrame(() => el?.focus())}1379                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1380                    />1381                  </div>1382                </div>1383              </Show>13841385              {/* ── TOTP ─────────────────────────────────────────── */}1386              <Show when={entry().type === "login"}>1387                <Show1388                  when={totpState() || editingField() === "totp"}1389                  fallback={1390                    <button1391                      class="peach-add-field-link peach-entry-add-link"1392                      onClick={() => startEdit("totp", "")}1393                    >1394                      + 2FA1395                    </button>1396                  }1397                >1398                  <section class="peach-totp-band">1399                    <Show1400                      when={editingField() === "totp"}1401                      fallback={1402                        <Show when={totpState()}>1403                          {(state) => (1404                            <>1405                              <div1406                                class="peach-totp-main"1407                                onClick={() => {1408                                  // Delay copy so a double-click can cancel it before1409                                  // it overwrites the clipboard the user wants to paste from.1410                                  if (totpClickTimer) return;1411                                  totpClickTimer = setTimeout(() => {1412                                    totpClickTimer = null;1413                                    void copyField(state().code.replace(" ", ""), "totp");1414                                  }, 220);1415                                }}1416                                onDblClick={() => {1417                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1418                                  startEdit("totp", entry().totp ?? "");1419                                }}1420                                title="Click to copy · double-click to edit"1421                              >1422                                <span class="peach-label">2-factor</span>1423                                <div class="peach-totp-code-row">1424                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1425                                    <span class="peach-totp-code-text">1426                                      {state().code.slice(0, 3)} {state().code.slice(3)}1427                                    </span>1428                                  </p>1429                                </div>1430                              </div>1431                              <div class="peach-totp-meta">1432                                <span class="peach-label">expires</span>1433                                <div class="peach-totp-countdown">{state().remaining}s</div>1434                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1435                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1436                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1437                                    stroke-dasharray="106.8"1438                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1439                                  />1440                                </svg>1441                              </div>1442                            </>1443                          )}1444                        </Show>1445                      }1446                    >1447                      <div1448                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1449                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1450                      >1451                        <span class="peach-label">2-factor</span>1452                        <input1453                          class="peach-inline-input"1454                          value={draft()}1455                          onInput={(e) => setDraft(e.currentTarget.value)}1456                          onKeyDown={onEditKeyDown}1457                          ref={autoFocus}1458                          disabled={saving()}1459                          placeholder="otpauth://... or base32 secret"1460                          autocomplete="off"1461                        />1462                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1463                      </div>1464                    </Show>1465                  </section>1466                </Show>1467              </Show>14681469              {/* ── Advanced (canary + passkey) ───────────────────── */}1470              <Show when={entry().type === "login"}>1471                <button1472                  class="peach-add-field-link peach-entry-add-link"1473                  onClick={() => setShowAdvanced((v) => !v)}1474                  aria-expanded={showAdvanced()}1475                >1476                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1477                </button>1478                <Show when={showAdvanced()}>1479                  <Show when={!isPasskeyOnly(entry())}>1480                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1481                  </Show>14821483                  <Show when={entry().passkey}>1484                    {(passkey) => {1485                      const currentBrowser = detectBrowser();1486                      const crossBrowser =1487                        passkey().browser &&1488                        currentBrowser !== "unknown" &&1489                        passkey().browser !== currentBrowser;1490                      const algorithmLabel =1491                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1492                      const formatDate = (ts?: number) =>1493                        ts ? new Date(ts).toLocaleDateString() : "Never";1494                      return (1495                        <section1496                          class="peach-fields peach-passkey-section"1497                          style="position:relative;z-index:1;margin-top:6px;"1498                        >1499                          <div class="peach-field-card">1500                            <span class="peach-label">passkey · {passkey().rpId}</span>1501                            <div class="peach-field-value-row">1502                              <span class="peach-field-value">{passkey().userName}</span>1503                              <span class="peach-passkey-badge">passkey</span>1504                            </div>1505                          </div>15061507                          <div class="peach-field-card">1508                            <span class="peach-label">algorithm</span>1509                            <div class="peach-field-value-row">1510                              <span class="peach-field-value">{algorithmLabel}</span>1511                            </div>1512                          </div>15131514                          <div class="peach-field-card">1515                            <span class="peach-label">activity</span>1516                            <div class="peach-field-value-row peach-passkey-activity">1517                              <span class="peach-field-value">1518                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1519                              </span>1520                              <span class="peach-passkey-meta">1521                                created {formatDate(passkey().createdAt)}1522                              </span>1523                              <span class="peach-passkey-meta">1524                                last used {formatDate(passkey().lastUsedAt)}1525                              </span>1526                            </div>1527                          </div>15281529                          <Show when={crossBrowser}>1530                            <div class="peach-info-notice peach-info-notice--warn">1531                              {`This passkey was created on ${1532                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1533                              }. To use it on ${1534                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1535                              }, re-register it on this device.`}1536                            </div>1537                          </Show>15381539                          <div class="peach-info-notice">1540                            {passkey().browser === "firefox"1541                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1542                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1543                          </div>15441545                          <button1546                            class="peach-add-field-link peach-entry-add-link"1547                            style="color:rgba(220,38,38,0.7);"1548                            onClick={async () => {1549                              const id = entry().id;1550                              await sendMessage({1551                                type: "UPDATE_ENTRY",1552                                payload: { id, patch: { passkey: undefined } },1553                              });1554                              await props.onFieldSaved();1555                            }}1556                          >1557                            − remove passkey1558                          </button>1559                        </section>1560                      );1561                    }}1562                  </Show>1563                </Show>1564              </Show>15651566              {/* ── Bottom spacer ─────────────────────────────────── */}1567              <div style="height:4px;" />1568            </>1569          )}1570        </Show>1571      </div>15721573    </>1574  );1575}15761577// ── Sub-components ────────────────────────────────────────────────────15781579function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1580  return (1581    <div class="peach-inline-actions">1582      <button1583        class="peach-inline-save-btn"1584        onClick={props.onSave}1585        disabled={props.saving}1586        aria-label="Save"1587        title="Save (Enter)"1588      >1589        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1590          <path d="M1 4.5L4 7.5L10 1.5" />1591        </svg>1592      </button>1593      <button1594        class="peach-inline-cancel-btn"1595        onClick={props.onCancel}1596        disabled={props.saving}1597        aria-label="Cancel"1598        title="Cancel (Esc)"1599      >1600        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1601          <path d="M1 1L8 8M8 1L1 8" />1602        </svg>1603      </button>1604    </div>1605  );1606}16071608function EmptyVaultState() {1609  return (1610    <>1611      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1612        <div class="peach-entry-name">1613          <span class="peach-entry-name-text">1614            <span class="peach-entry-name-char" style="--char-index:0">e</span>1615            <span class="peach-entry-name-char" style="--char-index:1">m</span>1616            <span class="peach-entry-name-char" style="--char-index:2">p</span>1617            <span class="peach-entry-name-char" style="--char-index:3">t</span>1618            <span class="peach-entry-name-char" style="--char-index:4">y</span>1619          </span>1620        </div>1621      </div>1622      <div class="peach-empty-copy">1623        <span class="peach-label">vault</span>1624        <p>Your vault is ready for its first entry.</p>1625      </div>1626    </>1627  );1628}16291630function CopyGlyph() {1631  return (1632    <span class="peach-copy-icon">1633      <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">1634        <rect x="4" y="4" width="8" height="9" rx="1.5" />1635        <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" />1636      </svg>1637    </span>1638  );1639}16401641function EyeClosedGlyph() {1642  return (1643    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1644      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1645      <path d="M3.5 5.5L2.5 7.5" />1646      <path d="M7 6.5V8.5" />1647      <path d="M10.5 5.5L11.5 7.5" />1648    </svg>1649  );1650}16511652function EyeOpenGlyph() {1653  return (1654    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1655      <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" />1656      <circle cx="7" cy="5" r="1.75" />1657    </svg>1658  );1659}16601661function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1662  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1663  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1664  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1665  const [testResult, setTestResult] = createSignal<string | null>(null);1666  const [testing, setTesting] = createSignal(false);16671668  // Pro gate entry-point for canary creation. Free users cannot toggle the1669  // canary on; the server-side UPDATE_ENTRY handler is the single hard1670  // enforcement point, but this branch avoids the bad UX of letting the1671  // toggle slip into "on" before the save rejects. Existing canary entries1672  // (e.g. retained after a license deactivation, future-proofing) remain1673  // togglable off — gating only applies to enabling a new canary.1674  const isProGated = () => !props.isPro && !props.entry.canary;16751676  async function save() {1677    const url = webhookUrl().trim();1678    const nextLabel = label().trim() || undefined;1679    const canary = enabled() && url1680      ? {1681          webhookUrl: url,1682          label: nextLabel,1683          lastPinged: props.entry.canary?.lastPinged,1684          pingCount: props.entry.canary?.pingCount,1685        }1686      : undefined;1687    await sendMessage({1688      type: "UPDATE_ENTRY",1689      payload: { id: props.entry.id, patch: { canary } },1690    });1691    await props.onSaved();1692  }16931694  async function testPing() {1695    const url = webhookUrl().trim();1696    if (!url) return;1697    setTesting(true);1698    setTestResult(null);1699    const res = await sendMessage({1700      type: "FIRE_CANARY_PING",1701      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1702    }) as { ok: boolean; error?: string };1703    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1704    setTesting(false);1705  }17061707  return (1708    <div class="peach-canary-section">1709      <div class="peach-field-value-row">1710        <span class="peach-field-value" style="font-size:12px;">1711          canary alert1712          <Show when={isProGated()}>1713            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1714          </Show>1715        </span>1716        <button1717          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1718          onClick={() => {1719            if (isProGated()) {1720              // Pro gate: tap on free tier navigates to the upgrade screen1721              // with Canary Entries highlighted. The toggle never flips on1722              // for a free user, regardless of click count.1723              props.onProGate?.("canary_entry");1724              return;1725            }1726            setEnabled(!enabled());1727            void save();1728          }}1729          aria-label="Toggle canary"1730          aria-disabled={isProGated() ? "true" : "false"}1731        >1732          <span class="peach-toggle-knob" />1733        </button>1734      </div>1735      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1736        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1737      </p>1738      <Show when={enabled()}>1739        <div class="peach-inline-field-wrap" style="margin-top:8px;">1740          <input1741            class="peach-inline-input"1742            placeholder="https://hooks.zapier.com/..."1743            value={webhookUrl()}1744            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1745            onBlur={() => void save()}1746          />1747          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1748            When triggered, the entry title is sent to this URL. Use an endpoint you control.1749          </p>1750          <input1751            class="peach-inline-input"1752            placeholder="label (optional, e.g. AWS decoy)"1753            value={label()}1754            onInput={(e) => setLabel(e.currentTarget.value)}1755            onBlur={() => void save()}1756          />1757          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1758            <button1759              class="peach-btn peach-btn--ghost peach-btn--sm"1760              onClick={() => void testPing()}1761              disabled={testing() || !webhookUrl().trim()}1762            >1763              {testing() ? "sending..." : "test ping"}1764            </button>1765            <Show when={testResult()}>1766              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1767            </Show>1768          </div>1769          <Show when={props.entry.canary?.lastPinged}>1770            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1771              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1772              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1773            </p>1774          </Show>1775        </div>1776      </Show>1777    </div>1778  );1779}