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   * — 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    return allIdentities()183      // Inline editing starts with the saved value selected. Keep the full184      // identity list visible so editing an existing value is a picker, not a185      // one-row exact-match result. AddView still filters while typing.186      .filter(s => s.type === field)187      .slice(0, 8);188  });189190  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);191  onCleanup(() => window.clearInterval(interval));192193  const displayDomain = createMemo(() =>194    props.entry?.url ? getDisplayDomain(props.entry.url) : ""195  );196197  // Reset transient prompts (trash confirmation, inline edits, advanced panel)198  // when the selected entry changes so they don't carry over between entries.199  // Track the last id so a same-entry refresh (e.g. after saving a field)200  // doesn't blow away in-flight edit state — that race used to cancel201  // startEdit calls that ran right after onFieldSaved.202  let lastEntryId: string | null = null;203  createEffect(() => {204    const id = props.entry?.id ?? null;205    if (id === lastEntryId) return;206    lastEntryId = id;207    setConfirmDelete(false);208    setEditingField(null);209    setEditingTags(false);210    setShowAdvanced(false);211  });212213  const totpState = createMemo(() => {214    void totpTick();215    if (!props.entry?.totp) return null;216    return generateTOTP(props.entry.totp);217  });218219  let prevTotpCode: string | undefined;220  createEffect(() => {221    const state = totpState();222    if (!state) { prevTotpCode = undefined; return; }223    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {224      setTotpHot(true);225      window.setTimeout(() => setTotpHot(false), 3000);226    }227    prevTotpCode = state.code;228  });229230  const totpProgress = createMemo(() => {231    const state = totpState();232    if (!state) return 0;233    return ((state.remaining - 1) / 29) * 100;234  });235236  createEffect(() => {237    const shell = document.getElementById("hd-shell");238    if (!shell) return;239240    const state = totpState();241    if (!state) {242      delete shell.dataset.totpActive;243      shell.style.removeProperty("--peach-totp-progress");244      return;245    }246247    shell.dataset.totpActive = "true";248    shell.style.setProperty(249      "--peach-totp-progress",250      `${Math.max(0, totpProgress())}%`251    );252253    onCleanup(() => {254      delete shell.dataset.totpActive;255      shell.style.removeProperty("--peach-totp-progress");256    });257  });258259  const titleSizeClass = createMemo(() => {260    const length = props.entry?.title.length ?? 0;261    if (length >= 28) return "peach-entry-name--xxl";262    if (length >= 20) return "peach-entry-name--xl";263    if (length >= 14) return "peach-entry-name--lg";264    return "";265  });266  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);267268  // ── Inline editing ────────────────────────────────────────────────269270  function startEdit(field: EditableField, value: string) {271    setEditingField(field);272    setDraft(value);273    setIdentityHelperSuppressed(false);274    setShowGenerator(false);275    setShowHistory(false);276    setRevealedHistory(new Set<number>());277    setConfirmDelete(false);278    if (field === "url") {279      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);280    }281  }282283  // Compare URLs by string after trim — vault.ts has its own canonicalisation284  // when saving, so the dedupe here is a UX-level "don't show the button if285  // we'd just add a duplicate" rather than a security check.286  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {287    const c = candidate.trim();288    if (!c) return true;289    if (primary.trim() === c) return true;290    return others.some((u) => u.trim() === c);291  }292293  function addCurrentSite(url: string) {294    if (!url) return;295    if (!draft().trim()) {296      setDraft(url);297      return;298    }299    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;300    setAdditionalUrlsDraft((arr) => [...arr, url]);301  }302303  async function saveField() {304    const field = editingField();305    const entry = props.entry;306    if (!field || !entry) return;307    // Don't trim passwords — spaces may be intentional308    let value = field === "password" ? draft() : draft().trim();309    if (field === "totp" && value.startsWith("otpauth://")) {310      const parsed = parseOtpauthURI(value);311      value = parsed?.secret ?? value;312    }313    if (field === "password" && entry.type !== "note" && !value) {314      return;315    }316    setSaving(true);317    try {318      const patch: Partial<VaultEntry> = { [field]: value };319      if (field === "url") {320        // Sanitization layer in vault.ts trims, dedupes, and drops empties.321        patch.additionalUrls = additionalUrlsDraft();322      }323      await sendMessage({324        type: "UPDATE_ENTRY",325        payload: { id: entry.id, patch },326      });327      triggerRing();328      setEditingField(null);329      setShowGenerator(false);330      setShowHistory(false);331      setRevealedHistory(new Set<number>());332      await props.onFieldSaved();333    } catch {334      // silent — leave editing open335    } finally {336      setSaving(false);337    }338  }339340  function startEditTags() {341    setTagsDraft([...(props.entry?.tags ?? [])]);342    setTagInput("");343    setEditingTags(true);344  }345346  function addTag(value: string) {347    const n = value.toLowerCase().trim();348    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;349    setTagsDraft([...tagsDraft(), n]);350    setTagInput("");351  }352353  async function saveTags() {354    if (tagInput()) addTag(tagInput());355    const entry = props.entry;356    if (!entry) return;357    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;358    setEditingTags(false);359    setSaving(true);360    try {361      await sendMessage({362        type: "UPDATE_ENTRY",363        payload: { id: entry.id, patch: { tags: next } },364      });365      triggerRing();366      await props.onFieldSaved();367    } catch {368      // silent369    } finally {370      setSaving(false);371    }372  }373374  function cancelEdit() {375    setEditingField(null);376    setDraft("");377    setShowGenerator(false);378    setShowHistory(false);379    setRevealedHistory(new Set<number>());380  }381382  // Switch from one identity slot to the other.383  //384  // Migration source is the SAVED entry value, never the live draft — using385  // the draft caused data loss when the user typed or cleared the input386  // before clicking +add (the migration would skip and a later Save would387  // overwrite the original email-shape value with whatever was in the input).388  //389  // Drafts only get committed when they are non-empty AND actually changed,390  // so an empty input never accidentally clears a saved field.391  async function addOtherIdentity(target: "username" | "email") {392    const entry = props.entry;393    if (!entry) return;394    const current = editingField();395    const draftValue = draft().trim();396    const savedUsername = entry.username ?? "";397    const savedEmail = entry.email ?? "";398399    if (current === "username" && target === "username") {400      // Legacy fix-up: saved username is email-shaped and no separate email401      // exists yet. Move it to entry.email and free the username slot.402      if (savedUsername.includes("@") && !entry.email) {403        setSaving(true);404        try {405          await sendMessage({406            type: "UPDATE_ENTRY",407            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },408          });409          await props.onFieldSaved();410        } finally {411          setSaving(false);412        }413      }414      startEdit("username", "");415      return;416    }417418    if (current === "username" && target === "email") {419      if (draftValue && draftValue !== savedUsername) {420        setSaving(true);421        try {422          await sendMessage({423            type: "UPDATE_ENTRY",424            payload: { id: entry.id, patch: { username: draftValue } },425          });426          await props.onFieldSaved();427        } finally {428          setSaving(false);429        }430      }431      startEdit("email", "");432      return;433    }434435    if (current === "email" && target === "username") {436      if (draftValue && draftValue !== savedEmail) {437        setSaving(true);438        try {439          await sendMessage({440            type: "UPDATE_ENTRY",441            payload: { id: entry.id, patch: { email: draftValue } },442          });443          await props.onFieldSaved();444        } finally {445          setSaving(false);446        }447      }448      startEdit("username", "");449      return;450    }451  }452453  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {454    switch (field) {455      case "title":456        return entry.title;457      case "url":458        return entry.url ?? "";459      case "username":460        return entry.username ?? "";461      case "email":462        return entry.email ?? "";463      case "password":464        return entry.password ?? "";465      case "notes":466        return entry.notes ?? "";467      case "totp":468        return entry.totp ?? "";469    }470  }471472  function handleEditorFocusOut(473    field: Exclude<EditableField, null>,474    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }475  ) {476    const nextTarget = event.relatedTarget;477    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;478479    // addOtherIdentity / startEdit may have already switched editingField before480    // focusout fires (SolidJS removes the old editor node synchronously, which481    // triggers blur with relatedTarget=null). Don't cancel the new edit.482    if (editingField() !== field) return;483484    const entry = props.entry;485    if (!entry) return;486487    const currentValue = fieldValue(entry, field);488    const nextValue = field === "password" ? draft() : draft().trim();489    if (nextValue === currentValue) {490      cancelEdit();491    }492  }493494  // ── Copy ─────────────────────────────────────────────────────────495496  async function copyField(text: string, label: string, button?: HTMLButtonElement) {497    const ok = await popupCopy(text, label);498    if (ok) {499      const entry = props.entry;500      if (entry?.canary?.webhookUrl) {501        const trigger =502          label === "password"503            ? "password_copied"504            : label === "username"505              ? "username_copied"506              : undefined;507        if (trigger) {508          void sendMessage({509            type: "FIRE_CANARY_PING",510            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },511          });512        }513      }514      triggerRing();515      triggerPipPulse();516      setCopied(null);517      requestAnimationFrame(() => setCopied(label));518      if (button) {519        button.classList.remove("is-copied", "is-retreat");520        void button.offsetWidth;521        const drain = document.createElement("span");522        drain.className = "peach-copy-drain";523        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";524        drain.appendChild(bar);525        button.appendChild(drain);526        window.setTimeout(() => {527          button.classList.remove("is-copied");528          button.classList.add("is-retreat");529          drain.remove();530          window.setTimeout(() => button.classList.remove("is-retreat"), 160);531        }, 1800);532      }533      window.setTimeout(() => setCopied(null), 1800);534    }535  }536537  // ── Keyboard shortcuts ────────────────────────────────────────────538539  createEffect(() => {540    if (!props.entry) return;541    const handler = (e: KeyboardEvent) => {542      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;543      if (inInput) return;544      switch (e.key) {545        case "c": e.preventDefault();546          if (props.entry?.password) void copyField(props.entry.password, "password"); break;547        case "u": e.preventDefault();548          if (props.entry?.username) void copyField(props.entry.username, "username"); break;549        case "Escape": e.preventDefault(); props.onEscape?.(); break;550      }551    };552    window.addEventListener("keydown", handler);553    onCleanup(() => window.removeEventListener("keydown", handler));554  });555556  // ── Input ref focus helper ────────────────────────────────────────557558  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {559    requestAnimationFrame(() => { el.focus(); el.select?.(); });560  }561562  function onEditKeyDown(e: KeyboardEvent) {563    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {564      e.preventDefault();565      void saveField();566    }567    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }568  }569570  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {571    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;572    if (e.key === "ArrowDown" && listEl) {573      e.preventDefault();574      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();575      return;576    }577    onEditKeyDown(e);578  }579580  return (581    <>582      <div583        class="peach-ghost"584        aria-hidden="true"585        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;"586      >587        {props.entry?.title || "empty"}588      </div>589590      <div class="peach-stage" style="position:relative;">591        <Show when={props.entry} fallback={<EmptyVaultState />}>592          {(entry) => (593            <>594              {/* ── Breach strip ─────────────────────────────────── */}595              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>596                <div class="peach-breach-badge">597                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">598                    <path d="M6 1L11 10H1L6 1z" />599                    <line x1="6" y1="5" x2="6" y2="7.5" />600                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />601                  </svg>602                  <span style="flex:1">found in a known data breach</span>603                </div>604              </Show>605606              {/* ── Title + trash ─────────────────────────────────── */}607              <div class="peach-entry-header">608                <div class="peach-entry-header-name">609                  <Show610                    when={editingField() === "title"}611                    fallback={612                      <button613                        class="peach-entry-name-button"614                        title="Click to edit title"615                        onClick={() => startEdit("title", entry().title)}616                      >617                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>618                          <span class="peach-entry-name-text">619                            <Show620                              when={useAnimatedTitle()}621                              fallback={entry().title}622                            >623                              <For each={entry().title.split("")}>624                                {(ch, i) => (625                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>626                                    {ch === " " ? "\u00A0" : ch}627                                  </span>628                                )}629                              </For>630                            </Show>631                          </span>632                        </h1>633                      </button>634                    }635                  >636                    <div637                      class="peach-inline-title-wrap"638                      onFocusOut={(event) => handleEditorFocusOut("title", event)}639                    >640                      <input641                        class="peach-inline-title-input"642                        value={draft()}643                        onInput={(e) => setDraft(e.currentTarget.value)}644                        onKeyDown={onEditKeyDown}645                        ref={autoFocus}646                        disabled={saving()}647                      />648                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />649                    </div>650                  </Show>651                </div>652653                {/* Subtle trash icon — hidden while editing title */}654                <Show when={editingField() !== "title"}>655                  <Show656                    when={confirmDelete()}657                    fallback={658                      <button659                        class="peach-entry-trash-btn"660                        onClick={() => setConfirmDelete(true)}661                        aria-label="Move to trash"662                        title="Delete entry"663                      >664                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">665                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />666                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />667                        </svg>668                      </button>669                    }670                  >671                    <div class="peach-entry-trash-confirm">672                      <span class="peach-entry-trash-confirm-label">trash?</span>673                      <button674                        class="peach-btn peach-btn--sm peach-btn--danger"675                        style="font-size:10px;padding:2px 8px;min-height:22px;"676                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}677                      >678                        yes679                      </button>680                      <button681                        class="peach-btn peach-btn--ghost peach-btn--sm"682                        style="font-size:10px;padding:2px 8px;min-height:22px;"683                        onClick={() => setConfirmDelete(false)}684                      >685                        no686                      </button>687                    </div>688                  </Show>689                </Show>690              </div>691692              {/* ── Domain / URL ─────────────────────────────────── */}693              <Show when={entry().type !== "note" && entry().type !== "card"}>694                <div class="peach-domain" style="position:relative;z-index:1;">695                  <Show696                    when={editingField() === "url"}697                    fallback={698                      <div699                        class="peach-domain-row peach-inline-clickable"700                        title="Click to edit URL"701                        onClick={() => startEdit("url", entry().url ?? "")}702                      >703                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />704                        <span class="peach-domain-value">705                          {displayDomain() || (entry().url ? entry().url : "add a website url")}706                        </span>707                        <span class="peach-inline-edit-hint">edit</span>708                      </div>709                    }710                  >711                    <div712                      class="peach-inline-field-wrap peach-inline-field-wrap--url"713                      onFocusOut={(event) => handleEditorFocusOut("url", event)}714                    >715                      <div class="peach-inline-field-row">716                        <input717                          class="peach-inline-input"718                          type="url"719                          placeholder="https://"720                          value={draft()}721                          onInput={(e) => setDraft(e.currentTarget.value)}722                          onKeyDown={onEditKeyDown}723                          ref={autoFocus}724                          disabled={saving()}725                        />726                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />727                      </div>728                      <Show729                        when={730                          detectHomograph(draft()) ||731                          additionalUrlsDraft().some((u) => detectHomograph(u))732                        }733                      >734                        {/* UI-8 / LV-2: non-blocking homograph warning. */}735                        <p class="peach-homograph-warning">736                          ⚠ This URL contains non-ASCII or punycode characters.737                          Verify it&apos;s the site you intend.738                        </p>739                      </Show>740                      <div class="peach-additional-urls">741                        <Show when={additionalUrlsDraft().length > 0}>742                          <span class="peach-label peach-additional-urls-label">also matches</span>743                        </Show>744                        <For each={additionalUrlsDraft()}>745                          {(value, i) => (746                            <div class="peach-additional-url-row">747                              <input748                                class="peach-inline-input"749                                type="url"750                                placeholder="https://"751                                value={value}752                                onInput={(e) => setAdditionalUrlsDraft((arr) => {753                                  const next = [...arr];754                                  next[i()] = e.currentTarget.value;755                                  return next;756                                })}757                                disabled={saving()}758                              />759                              <button760                                class="peach-additional-url-remove"761                                type="button"762                                aria-label="Remove URL"763                                onMouseDown={(e) => e.preventDefault()}764                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}765                                disabled={saving()}766                              >×</button>767                            </div>768                          )}769                        </For>770                        <button771                          class="peach-add-field-link peach-additional-urls-add"772                          type="button"773                          onMouseDown={(e) => e.preventDefault()}774                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}775                          disabled={saving()}776                        >777                          + add another url778                        </button>779                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>780                          <button781                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"782                            type="button"783                            title={props.currentTabUrl}784                            onMouseDown={(e) => e.preventDefault()}785                            onClick={() => addCurrentSite(props.currentTabUrl!)}786                            disabled={saving()}787                          >788                            + add current site789                          </button>790                        </Show>791                      </div>792                    </div>793                  </Show>794                </div>795              </Show>796797              {/* ── Tags — read-only chips (editing state lives at bottom) */}798              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>799                <div800                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"801                  onClick={startEditTags}802                  title="Click to edit tags"803                >804                  <For each={entry().tags!}>805                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}806                  </For>807                </div>808              </Show>809810              {/* ── Note toolbar + inline editor ─────────────────── */}811              <Show when={entry().type === "note"}>812                <div class="peach-entry-note-toolbar-row">813                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />814                </div>815                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">816                  <NoteEditorInline817                    entryKey={entry().id}818                    initialContent={entry().notes ?? ""}819                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}820                    onBlur={saveNoteInline}821                    autoFocus={false}822                  />823                </section>824              </Show>825826              {/* ── Card detail view ─────────────────────────────── */}827              <Show when={entry().type === "card"}>828                <section class="peach-fields" style="position:relative;z-index:1;">829                  {/* Card subtitle: •••• last4 */}830                  <Show when={entry().last4}>831                    <div class="peach-field-card" style="padding-bottom:4px;">832                      <span class="peach-label">833                        <Show when={entry().network && entry().network !== "unknown"}>834                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>835                        </Show>836                        {entry().cardNickname || `•••• ${entry().last4}`}837                      </span>838                    </div>839                  </Show>840841                  {/* Cardholder name */}842                  <div class="peach-field-card">843                    <span class="peach-label">cardholder name</span>844                    <div class="peach-field-value-row">845                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>846                      <Show when={entry().cardholderName}>847                        <button848                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}849                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}850                          aria-label="Copy cardholder name"851                        >852                          <CopyGlyph /><span class="peach-copy-sheen" />853                        </button>854                      </Show>855                    </div>856                  </div>857858                  {/* Card number */}859                  <div class="peach-field-card">860                    <span class="peach-label">card number</span>861                    <div class="peach-field-value-row">862                      <span class={`peach-field-value peach-card-number`}>863                        <Show when={showCardNumber()} fallback={864                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>865                        }>866                          <span>{entry().cardNumber ?? ""}</span>867                        </Show>868                      </span>869                      <div class="peach-field-actions">870                        <button871                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"872                          onClick={() => setShowCardNumber(!showCardNumber())}873                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}874                        >875                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>876                            <EyeClosedGlyph />877                          </Show>878                        </button>879                        <Show when={entry().cardNumber}>880                          <button881                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}882                            onClick={(e) => {883                              const cardNum = entry().cardNumber!;884                              if (entry().canary?.webhookUrl) {885                                void sendMessage({886                                  type: "FIRE_CANARY_PING",887                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },888                                });889                              }890                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);891                            }}892                            aria-label="Copy card number"893                          >894                            <CopyGlyph /><span class="peach-copy-sheen" />895                          </button>896                        </Show>897                      </div>898                    </div>899                  </div>900901                  {/* Expiry */}902                  <Show when={entry().expMonth || entry().expYear}>903                    <div class="peach-field-card">904                      <span class="peach-label">expiry</span>905                      <div class="peach-field-value-row">906                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>907                        <button908                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}909                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}910                          aria-label="Copy expiry"911                        >912                          <CopyGlyph /><span class="peach-copy-sheen" />913                        </button>914                      </div>915                    </div>916                  </Show>917918                  {/* CVC */}919                  <Show when={entry().cvc}>920                    <div class="peach-field-card">921                      <span class="peach-label">CVC</span>922                      <div class="peach-field-value-row">923                        <span class="peach-field-value">924                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>925                            <span>{entry().cvc}</span>926                          </Show>927                        </span>928                        <div class="peach-field-actions">929                          <button930                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"931                            onClick={() => setShowCardCvc(!showCardCvc())}932                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}933                          >934                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>935                              <EyeClosedGlyph />936                            </Show>937                          </button>938                          <button939                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}940                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}941                            aria-label="Copy CVC"942                          >943                            <CopyGlyph /><span class="peach-copy-sheen" />944                          </button>945                        </div>946                      </div>947                    </div>948                  </Show>949                </section>950              </Show>951952              {/* ── Login fields ─────────────────────────────────── */}953              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>954                <section class="peach-fields" style="position:relative;z-index:1;">955956                  {/* Username — primary identity slot. Auto-labels as "email"957                      when the stored value is email-shaped and no separate958                      email field exists (legacy data path). */}959                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>960                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>961                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>962                      <Show963                        when={editingField() === "username"}964                        fallback={965                          <div class="peach-field-value-row">966                            <span967                              class="peach-field-value peach-inline-clickable"968                              title="Click to edit"969                              onClick={() => startEdit("username", entry().username ?? "")}970                            >971                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}972                            </span>973                            <Show when={entry().username}>974                              <button975                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}976                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}977                                aria-label="Copy username"978                              >979                                <CopyGlyph /><span class="peach-copy-sheen" />980                              </button>981                            </Show>982                          </div>983                        }984                      >985                        <div986                          class="peach-inline-field-wrap"987                          onFocusOut={(event) => handleEditorFocusOut("username", event)}988                        >989                          <input990                            class="peach-inline-input"991                            value={draft()}992                            onInput={(e) => setDraft(e.currentTarget.value)}993                            onKeyDown={(e) => identityKeyDown(e, "username")}994                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}995                            disabled={saving()}996                            autocomplete="off"997                          />998                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />999                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>1000                            <IdentitySuggestList1001                              listRef={(el) => (usernameIdentityEl = el)}1002                              inputRef={() => usernameInlineInput}1003                              suggestions={identitySuggestions()}1004                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1005                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}1006                            />1007                          </Show>1008                          {/* +add the OTHER identity. If the slot is acting as1009                              email (value has @, no separate email), offer1010                              "+ add username" — clicking migrates the email1011                              into entry.email. Otherwise offer "+ add email". */}1012                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>1013                            <button1014                              class="peach-add-field-btn"1015                              type="button"1016                              onMouseDown={(e) => e.preventDefault()}1017                              onClick={() => {1018                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1019                                const hasSeparateEmail = !!entry().email;1020                                if (isEmailShape && !hasSeparateEmail) {1021                                  void addOtherIdentity("username");1022                                } else {1023                                  void addOtherIdentity("email");1024                                }1025                              }}1026                            >1027                              {(() => {1028                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);1029                                const hasSeparateEmail = !!entry().email;1030                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";1031                              })()}1032                            </button>1033                          </Show>1034                        </div>1035                      </Show>1036                    </div>1037                  </Show>10381039                  {/* Email — secondary identity slot. Visible whenever a1040                      separate email field is set, or while it is being edited. */}1041                  <Show when={entry().email || editingField() === "email"}>1042                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1043                      <span class="peach-label">email</span>1044                      <Show1045                        when={editingField() === "email"}1046                        fallback={1047                          <div class="peach-field-value-row">1048                            <span1049                              class="peach-field-value peach-inline-clickable"1050                              title="Click to edit"1051                              onClick={() => startEdit("email", entry().email ?? "")}1052                            >1053                              {entry().email}1054                            </span>1055                            <Show when={entry().email}>1056                              <button1057                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1058                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1059                                aria-label="Copy email"1060                              >1061                                <CopyGlyph /><span class="peach-copy-sheen" />1062                              </button>1063                            </Show>1064                          </div>1065                        }1066                      >1067                        <div1068                          class="peach-inline-field-wrap"1069                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1070                        >1071                          <input1072                            class="peach-inline-input"1073                            value={draft()}1074                            onInput={(e) => setDraft(e.currentTarget.value)}1075                            onKeyDown={(e) => identityKeyDown(e, "email")}1076                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1077                            disabled={saving()}1078                            autocomplete="email"1079                            type="email"1080                          />1081                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1082                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1083                            <IdentitySuggestList1084                              listRef={(el) => (emailIdentityEl = el)}1085                              inputRef={() => emailInlineInput}1086                              suggestions={identitySuggestions()}1087                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1088                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1089                            />1090                          </Show>1091                          <Show when={!entry().username || entry().username?.includes("@")}>1092                            <button1093                              class="peach-add-field-btn"1094                              type="button"1095                              onMouseDown={(e) => e.preventDefault()}1096                              onClick={() => void addOtherIdentity("username")}1097                            >1098                              + add username1099                            </button>1100                          </Show>1101                        </div>1102                      </Show>1103                    </div>1104                  </Show>11051106                  {/* Password */}1107                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1108                    <span class="peach-label">password</span>1109                    <Show1110                      when={editingField() === "password"}1111                      fallback={1112                        <div class="peach-field-value-row">1113                          <span1114                            class="peach-password-value"1115                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1116                            style="cursor:pointer;"1117                            title="Click to edit password"1118                            onClick={() => startEdit("password", entry().password ?? "")}1119                          >1120                            <Show1121                              when={showPassword()}1122                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1123                            >1124                              <span class="peach-password-text">{entry().password ?? ""}</span>1125                            </Show>1126                          </span>1127                          <div class="peach-field-actions">1128                            <button1129                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1130                              onClick={() => setShowPassword(!showPassword())}1131                              aria-label="Reveal password"1132                            >1133                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1134                                <EyeClosedGlyph />1135                              </Show>1136                            </button>1137                            <button1138                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1139                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1140                              aria-label="Copy password"1141                            >1142                              <CopyGlyph /><span class="peach-copy-sheen" />1143                            </button>1144                          </div>1145                        </div>1146                      }1147                    >1148                      {/* ── Edit mode: type / paste / generate ── */}1149                      <div1150                        class="peach-inline-field-wrap"1151                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1152                      >1153                        <div class="peach-password-edit-row">1154                          <input1155                            class="peach-inline-input"1156                            type={showPassword() ? "text" : "password"}1157                            placeholder="paste or type a password"1158                            value={draft()}1159                            onInput={(e) => setDraft(e.currentTarget.value)}1160                            onKeyDown={(e) => {1161                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1162                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1163                            }}1164                            ref={autoFocus}1165                            disabled={saving()}1166                            autocomplete="new-password"1167                          />1168                          <button1169                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1170                            type="button"1171                            onClick={() => setShowPassword(!showPassword())}1172                            aria-label={showPassword() ? "Hide" : "Show"}1173                          >1174                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1175                              <EyeClosedGlyph />1176                            </Show>1177                          </button>1178                        </div>1179                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1180                        <div class="peach-password-edit-links">1181                          <button1182                            class="peach-generate-toggle-link"1183                            type="button"1184                            onClick={() => { setShowGenerator(!showGenerator()); if (!showGenerator()) setShowHistory(false); }}1185                          >1186                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1187                              <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1188                              <path d="M9.5 2.5H11V1" />1189                            </svg>1190                            {showGenerator() ? "hide generator" : "generate"}1191                          </button>1192                          <button1193                            class="peach-generate-toggle-link"1194                            type="button"1195                            onClick={() => { setShowHistory(!showHistory()); if (!showHistory()) setShowGenerator(false); }}1196                          >1197                            <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1198                              <path d="M6.5 1a5.5 5.5 0 1 0 5.5 5.5" />1199                              <path d="M6.5 3.5V6.5L8.5 8" />1200                              <path d="M1 1.5V4H3.5" />1201                            </svg>1202                            {showHistory()1203                              ? "hide history"1204                              : (entry().passwordHistory?.length ? `history (${entry().passwordHistory!.length})` : "history")}1205                          </button>1206                        </div>1207                        <Show when={showGenerator()}>1208                          <GeneratorDrawer1209                            open={true}1210                            compact={true}1211                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1212                          />1213                        </Show>1214                        <Show when={showHistory()}>1215                          <div class="peach-password-history">1216                            <Show when={(entry().passwordHistory?.length ?? 0) === 0}>1217                              <div class="peach-history-empty">1218                                No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.1219                              </div>1220                            </Show>1221                            <For each={entry().passwordHistory ?? []}>1222                              {(item, i) => (1223                                <div class="peach-history-row">1224                                  <div class="peach-history-main">1225                                    <span class="peach-history-pw" classList={{ "is-revealed": revealedHistory().has(i()) }}>1226                                      <Show1227                                        when={revealedHistory().has(i())}1228                                        fallback={<span class="peach-password-mask">••••••••••••</span>}1229                                      >1230                                        {item.password}1231                                      </Show>1232                                    </span>1233                                    <span class="peach-history-age">{timeSince(item.changedAt)}</span>1234                                  </div>1235                                  <div class="peach-history-actions">1236                                    <button1237                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1238                                      type="button"1239                                      title={revealedHistory().has(i()) ? "Hide" : "Reveal"}1240                                      onClick={() => setRevealedHistory((prev) => {1241                                        const next = new Set(prev);1242                                        if (next.has(i())) next.delete(i()); else next.add(i());1243                                        return next;1244                                      })}1245                                    >1246                                      <Show when={revealedHistory().has(i())} fallback={<EyeOpenGlyph />}>1247                                        <EyeClosedGlyph />1248                                      </Show>1249                                    </button>1250                                    <button1251                                      class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === `history-${i()}` ? "is-copied" : ""}`}1252                                      type="button"1253                                      title="Copy"1254                                      onClick={(e) => void copyField(item.password, `history-${i()}`, e.currentTarget as HTMLButtonElement)}1255                                    >1256                                      <CopyGlyph /><span class="peach-copy-sheen" />1257                                    </button>1258                                    <button1259                                      class="peach-btn peach-btn--ghost peach-btn--sm peach-history-use"1260                                      type="button"1261                                      title="Use this password again"1262                                      onClick={() => { setDraft(item.password); setShowPassword(true); setShowHistory(false); }}1263                                    >1264                                      use1265                                    </button>1266                                  </div>1267                                </div>1268                              )}1269                            </For>1270                          </div>1271                        </Show>1272                      </div>1273                    </Show>1274                  </div>12751276                </section>1277              </Show>12781279              {/* ── Notes (on login entries) ──────────────────────── */}1280              <Show when={entry().type !== "note" && entry().type !== "card"}>1281                <Show1282                  when={entry().notes || editingField() === "notes"}1283                  fallback={1284                    <button1285                      class="peach-add-field-link peach-entry-add-link"1286                      onClick={() => startEdit("notes", "")}1287                    >1288                      + notes1289                    </button>1290                  }1291                >1292                  <div style="position:relative;z-index:1;margin-top:6px;">1293                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1294                    <Show1295                      when={editingField() === "notes"}1296                      fallback={1297                        <div1298                          class="peach-note-content peach-inline-clickable"1299                          style="cursor:text;white-space:pre-wrap;"1300                          onClick={() => startEdit("notes", entry().notes ?? "")}1301                        >1302                          {entry().notes}1303                        </div>1304                      }1305                    >1306                      <div1307                        class="peach-inline-field-wrap"1308                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1309                      >1310                        <textarea1311                          class="peach-inline-textarea"1312                          value={draft()}1313                          onInput={(e) => setDraft(e.currentTarget.value)}1314                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1315                          ref={(el) => requestAnimationFrame(() => el?.focus())}1316                          rows={4}1317                          disabled={saving()}1318                        />1319                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1320                      </div>1321                    </Show>1322                  </div>1323                </Show>1324              </Show>13251326              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1327              <Show1328                when={editingTags()}1329                fallback={1330                  <Show when={!entry().tags || entry().tags!.length === 0}>1331                    <button1332                      class="peach-add-field-link peach-entry-add-link"1333                      onClick={startEditTags}1334                    >1335                      + tags1336                    </button>1337                  </Show>1338                }1339              >1340                <div1341                  class="peach-inline-tag-editor"1342                  onFocusOut={(e) => {1343                    const next = e.relatedTarget;1344                    if (next instanceof Node && e.currentTarget.contains(next)) return;1345                    void saveTags();1346                  }}1347                >1348                  <span class="peach-label">tags</span>1349                  <div class="peach-tag-input-wrap">1350                    <For each={tagsDraft()}>1351                      {(tag) => (1352                        <span class="peach-tag-chip">1353                          {tag}1354                          <button1355                            class="peach-tag-chip-remove"1356                            onMouseDown={(e) => e.preventDefault()}1357                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1358                            aria-label={`Remove ${tag}`}1359                          >×</button>1360                        </span>1361                      )}1362                    </For>1363                    <input1364                      class="peach-tag-input"1365                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1366                      value={tagInput()}1367                      onInput={(e) => setTagInput(e.currentTarget.value)}1368                      onKeyDown={(e) => {1369                        if (e.key === "Enter" || e.key === ",") {1370                          e.preventDefault();1371                          addTag(tagInput());1372                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1373                          setTagsDraft(tagsDraft().slice(0, -1));1374                        } else if (e.key === "Escape") {1375                          e.preventDefault();1376                          setEditingTags(false);1377                          setTagInput("");1378                        }1379                      }}1380                      ref={(el) => requestAnimationFrame(() => el?.focus())}1381                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1382                    />1383                  </div>1384                </div>1385              </Show>13861387              {/* ── TOTP ─────────────────────────────────────────── */}1388              <Show when={entry().type === "login"}>1389                <Show1390                  when={totpState() || editingField() === "totp"}1391                  fallback={1392                    <button1393                      class="peach-add-field-link peach-entry-add-link"1394                      onClick={() => startEdit("totp", "")}1395                    >1396                      + 2FA1397                    </button>1398                  }1399                >1400                  <section class="peach-totp-band">1401                    <Show1402                      when={editingField() === "totp"}1403                      fallback={1404                        <Show when={totpState()}>1405                          {(state) => (1406                            <>1407                              <div1408                                class="peach-totp-main"1409                                onClick={() => {1410                                  // Delay copy so a double-click can cancel it before1411                                  // it overwrites the clipboard the user wants to paste from.1412                                  if (totpClickTimer) return;1413                                  totpClickTimer = setTimeout(() => {1414                                    totpClickTimer = null;1415                                    void copyField(state().code.replace(" ", ""), "totp");1416                                  }, 220);1417                                }}1418                                onDblClick={() => {1419                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1420                                  startEdit("totp", entry().totp ?? "");1421                                }}1422                                title="Click to copy · double-click to edit"1423                              >1424                                <span class="peach-label">2-factor</span>1425                                <div class="peach-totp-code-row">1426                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1427                                    <span class="peach-totp-code-text">1428                                      {state().code.slice(0, 3)} {state().code.slice(3)}1429                                    </span>1430                                  </p>1431                                </div>1432                              </div>1433                              <div class="peach-totp-meta">1434                                <span class="peach-label">expires</span>1435                                <div class="peach-totp-countdown">{state().remaining}s</div>1436                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1437                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1438                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1439                                    stroke-dasharray="106.8"1440                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1441                                  />1442                                </svg>1443                              </div>1444                            </>1445                          )}1446                        </Show>1447                      }1448                    >1449                      <div1450                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1451                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1452                      >1453                        <span class="peach-label">2-factor</span>1454                        <input1455                          class="peach-inline-input"1456                          value={draft()}1457                          onInput={(e) => setDraft(e.currentTarget.value)}1458                          onKeyDown={onEditKeyDown}1459                          ref={autoFocus}1460                          disabled={saving()}1461                          placeholder="otpauth://... or base32 secret"1462                          autocomplete="off"1463                        />1464                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1465                      </div>1466                    </Show>1467                  </section>1468                </Show>1469              </Show>14701471              {/* ── Advanced (canary + passkey) ───────────────────── */}1472              <Show when={entry().type !== "note" && entry().type !== "card"}>1473                <button1474                  class="peach-add-field-link peach-entry-add-link"1475                  onClick={() => setShowAdvanced((v) => !v)}1476                  aria-expanded={showAdvanced()}1477                >1478                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1479                </button>1480                <Show when={showAdvanced()}>1481                  <Show when={!isPasskeyOnly(entry())}>1482                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1483                  </Show>14841485                  <Show when={entry().passkey}>1486                    {(passkey) => {1487                      const currentBrowser = detectBrowser();1488                      const crossBrowser =1489                        passkey().browser &&1490                        currentBrowser !== "unknown" &&1491                        passkey().browser !== currentBrowser;1492                      const algorithmLabel =1493                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1494                      const formatDate = (ts?: number) =>1495                        ts ? new Date(ts).toLocaleDateString() : "Never";1496                      return (1497                        <section1498                          class="peach-fields peach-passkey-section"1499                          style="position:relative;z-index:1;margin-top:6px;"1500                        >1501                          <div class="peach-field-card">1502                            <span class="peach-label">passkey · {passkey().rpId}</span>1503                            <div class="peach-field-value-row">1504                              <span class="peach-field-value">{passkey().userName}</span>1505                              <span class="peach-passkey-badge">passkey</span>1506                            </div>1507                          </div>15081509                          <div class="peach-field-card">1510                            <span class="peach-label">algorithm</span>1511                            <div class="peach-field-value-row">1512                              <span class="peach-field-value">{algorithmLabel}</span>1513                            </div>1514                          </div>15151516                          <div class="peach-field-card">1517                            <span class="peach-label">activity</span>1518                            <div class="peach-field-value-row peach-passkey-activity">1519                              <span class="peach-field-value">1520                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1521                              </span>1522                              <span class="peach-passkey-meta">1523                                created {formatDate(passkey().createdAt)}1524                              </span>1525                              <span class="peach-passkey-meta">1526                                last used {formatDate(passkey().lastUsedAt)}1527                              </span>1528                            </div>1529                          </div>15301531                          <Show when={crossBrowser}>1532                            <div class="peach-info-notice peach-info-notice--warn">1533                              {`This passkey was created on ${1534                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1535                              }. To use it on ${1536                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1537                              }, re-register it on this device.`}1538                            </div>1539                          </Show>15401541                          <div class="peach-info-notice">1542                            {passkey().browser === "firefox"1543                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1544                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1545                          </div>15461547                          <button1548                            class="peach-add-field-link peach-entry-add-link"1549                            style="color:rgba(220,38,38,0.7);"1550                            onClick={async () => {1551                              const id = entry().id;1552                              await sendMessage({1553                                type: "UPDATE_ENTRY",1554                                payload: { id, patch: { passkey: undefined } },1555                              });1556                              await props.onFieldSaved();1557                            }}1558                          >1559                            − remove passkey1560                          </button>1561                        </section>1562                      );1563                    }}1564                  </Show>1565                </Show>1566              </Show>15671568              {/* ── Bottom spacer ─────────────────────────────────── */}1569              <div style="height:4px;" />1570            </>1571          )}1572        </Show>1573      </div>15741575    </>1576  );1577}15781579// ── Sub-components ────────────────────────────────────────────────────15801581function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1582  return (1583    <div class="peach-inline-actions">1584      <button1585        class="peach-inline-save-btn"1586        onClick={props.onSave}1587        disabled={props.saving}1588        aria-label="Save"1589        title="Save (Enter)"1590      >1591        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1592          <path d="M1 4.5L4 7.5L10 1.5" />1593        </svg>1594      </button>1595      <button1596        class="peach-inline-cancel-btn"1597        onClick={props.onCancel}1598        disabled={props.saving}1599        aria-label="Cancel"1600        title="Cancel (Esc)"1601      >1602        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1603          <path d="M1 1L8 8M8 1L1 8" />1604        </svg>1605      </button>1606    </div>1607  );1608}16091610function EmptyVaultState() {1611  return (1612    <>1613      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1614        <div class="peach-entry-name">1615          <span class="peach-entry-name-text">1616            <span class="peach-entry-name-char" style="--char-index:0">e</span>1617            <span class="peach-entry-name-char" style="--char-index:1">m</span>1618            <span class="peach-entry-name-char" style="--char-index:2">p</span>1619            <span class="peach-entry-name-char" style="--char-index:3">t</span>1620            <span class="peach-entry-name-char" style="--char-index:4">y</span>1621          </span>1622        </div>1623      </div>1624      <div class="peach-empty-copy">1625        <span class="peach-label">vault</span>1626        <p>Your vault is ready for its first entry.</p>1627      </div>1628    </>1629  );1630}16311632function CopyGlyph() {1633  return (1634    <span class="peach-copy-icon">1635      <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">1636        <rect x="4" y="4" width="8" height="9" rx="1.5" />1637        <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" />1638      </svg>1639    </span>1640  );1641}16421643function EyeClosedGlyph() {1644  return (1645    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1646      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1647      <path d="M3.5 5.5L2.5 7.5" />1648      <path d="M7 6.5V8.5" />1649      <path d="M10.5 5.5L11.5 7.5" />1650    </svg>1651  );1652}16531654function EyeOpenGlyph() {1655  return (1656    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1657      <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" />1658      <circle cx="7" cy="5" r="1.75" />1659    </svg>1660  );1661}16621663function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1664  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1665  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1666  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1667  const [testResult, setTestResult] = createSignal<string | null>(null);1668  const [testing, setTesting] = createSignal(false);16691670  // Pro gate entry-point for canary creation. Free users cannot toggle the1671  // canary on; the server-side UPDATE_ENTRY handler is the single hard1672  // enforcement point, but this branch avoids the bad UX of letting the1673  // toggle slip into "on" before the save rejects. Existing canary entries1674  // (e.g. retained after a license deactivation, future-proofing) remain1675  // togglable off — gating only applies to enabling a new canary.1676  const isProGated = () => !props.isPro && !props.entry.canary;16771678  async function save() {1679    const url = webhookUrl().trim();1680    const nextLabel = label().trim() || undefined;1681    const canary = enabled() && url1682      ? {1683          webhookUrl: url,1684          label: nextLabel,1685          lastPinged: props.entry.canary?.lastPinged,1686          pingCount: props.entry.canary?.pingCount,1687        }1688      : undefined;1689    await sendMessage({1690      type: "UPDATE_ENTRY",1691      payload: { id: props.entry.id, patch: { canary } },1692    });1693    await props.onSaved();1694  }16951696  async function testPing() {1697    const url = webhookUrl().trim();1698    if (!url) return;1699    setTesting(true);1700    setTestResult(null);1701    const res = await sendMessage({1702      type: "FIRE_CANARY_PING",1703      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1704    }) as { ok: boolean; error?: string };1705    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1706    setTesting(false);1707  }17081709  return (1710    <div class="peach-canary-section">1711      <div class="peach-field-value-row">1712        <span class="peach-field-value" style="font-size:12px;">1713          canary alert1714          <Show when={isProGated()}>1715            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1716          </Show>1717        </span>1718        <button1719          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1720          onClick={() => {1721            if (isProGated()) {1722              // Pro gate: tap on free tier navigates to the upgrade screen1723              // with Canary Entries highlighted. The toggle never flips on1724              // for a free user, regardless of click count.1725              props.onProGate?.("canary_entry");1726              return;1727            }1728            setEnabled(!enabled());1729            void save();1730          }}1731          aria-label="Toggle canary"1732          aria-disabled={isProGated() ? "true" : "false"}1733        >1734          <span class="peach-toggle-knob" />1735        </button>1736      </div>1737      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1738        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1739      </p>1740      <Show when={enabled()}>1741        <div class="peach-inline-field-wrap" style="margin-top:8px;">1742          <input1743            class="peach-inline-input"1744            placeholder="https://hooks.zapier.com/..."1745            value={webhookUrl()}1746            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1747            onBlur={() => void save()}1748          />1749          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1750            When triggered, the entry title is sent to this URL. Use an endpoint you control.1751          </p>1752          <input1753            class="peach-inline-input"1754            placeholder="label (optional, e.g. AWS decoy)"1755            value={label()}1756            onInput={(e) => setLabel(e.currentTarget.value)}1757            onBlur={() => void save()}1758          />1759          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1760            <button1761              class="peach-btn peach-btn--ghost peach-btn--sm"1762              onClick={() => void testPing()}1763              disabled={testing() || !webhookUrl().trim()}1764            >1765              {testing() ? "sending..." : "test ping"}1766            </button>1767            <Show when={testResult()}>1768              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1769            </Show>1770          </div>1771          <Show when={props.entry.canary?.lastPinged}>1772            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1773              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1774              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1775            </p>1776          </Show>1777        </div>1778      </Show>1779    </div>1780  );1781}